Skip to content

feat(webapp): add skipIfActive trigger option for drop-on-conflict dedup#3433

Closed
eni9889 wants to merge 1 commit intotriggerdotdev:mainfrom
eni9889:eni/feat-skip-if-active
Closed

feat(webapp): add skipIfActive trigger option for drop-on-conflict dedup#3433
eni9889 wants to merge 1 commit intotriggerdotdev:mainfrom
eni9889:eni/feat-skip-if-active

Conversation

@eni9889
Copy link
Copy Markdown

@eni9889 eni9889 commented Apr 23, 2026

Refs #3428
Closes (none — filed alongside this PR; happy to open a tracking issue if you'd prefer)

✅ Checklist

  • I have followed every step in the contributing guide
  • The PR title follows the convention.
  • I ran and tested the code works
  • Changeset added (.changeset/skip-if-active.md@trigger.dev/core minor)
  • "Allow edits from maintainers" enabled on the fork

Testing

Unit tests added at apps/webapp/test/engine/skipIfActiveConcern.test.ts (no DB, mocks Prisma). Covers:

  1. Flag unset → no-op, no query executed.
  2. Flag false → no-op, no query executed.
  3. Flag true but no tags → no-op (documented: tag scope required).
  4. Flag true + tags + no match → proceed to normal run creation.
  5. Flag true + tags + match → return existing run, wasSkipped: true, no new run.
  6. Single-string tag (tags: "foo") normalizes to array correctly.
  7. Row-disappeared-mid-query race (probe returns id, subsequent fetch returns null) → falls back to creating instead of failing.

Local end-to-end verification against prod-like Postgres (maxcare's self-hosted Trigger.dev v4 instance running this patch):

  • skipIfActive=true with matching in-flight run → response { id, isCached: true, wasSkipped: true }, no new row in "TaskRun".
  • skipIfActive=true with no match → new run created normally.
  • skipIfActive=true combined with idempotencyKey → idempotency match wins, the skip-check never fires.
  • skipIfActive=true with tags: [] → no-op (proceeds to create).
  • Query plan: Bitmap Heap Scan on TaskRun_runTags_idx + TaskRun_status_runtimeEnvironmentId_createdAt_id_idx, Execution Time <100ms against a 12M-row production TaskRun table.

No DB schema change — uses existing indexes.


Changelog

New: skipIfActive: boolean option on tasks.trigger() and batch items.

When set, and at least one tag is supplied, the server short-circuits the trigger if an in-flight (non-terminal) TaskRun with the same taskIdentifier + environment + all of the supplied tags already exists. Returns the existing run with isCached: true + new wasSkipped: true flag. No new run is created. No queue entry. No trace span for the would-be new run.

Why a new option? Existing options each miss the "cron scanner drops duplicates" case:

Option Behavior on match Cron-scanner fit
idempotencyKey Returns existing run, including completed runs, until TTL expires Blocks scheduled re-runs after success
concurrencyKey Queues FIFO behind the existing run Backlog grows unboundedly when a sync hangs
runs.list() in user code ✅ correct semantics Expensive: task_runs_v2 FINAL + hasAny(tags) in ClickHouse. On our self-hosted v4 instance we observed 59+ concurrent pile-up pegging CH at 100% CPU. See issue #3426 for background.

skipIfActive gives a first-class drop-on-conflict primitive that lets the server do the dedup with one indexed Postgres lookup.

Implementation

Mirrors the existing IdempotencyKeyConcern pattern end-to-end.

  • Schema (packages/core/src/v3/schemas/api.ts): adds the optional field to TriggerTaskRequestBody.options and BatchTriggerTaskItem.options. Adds optional wasSkipped: boolean to TriggerTaskResponse.
  • New concern (apps/webapp/app/runEngine/concerns/skipIfActive.server.ts): one indexed SELECT on "TaskRun" scoped to non-terminal statuses. runTags @> ARRAY[...]::text[] for AND-match on tags. Returns { wasSkipped: true, run } on match.
  • Wire-in (apps/webapp/app/runEngine/services/triggerTask.server.ts): invoked from RunEngineTriggerTaskService.call() after idempotency (so explicit idempotency cache-hits win) and before run creation (so skipped triggers never touch the queue).
  • Route (apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts): surfaces wasSkipped in the JSON response.
  • TriggerTaskServiceResult gains optional wasSkipped?: boolean.

Scope

  • Engine v2 only. TriggerTaskServiceV1 is intentionally untouched — v1 is frozen.
  • Batch: per-item. Each BatchTriggerTaskItem can carry its own skipIfActive; the same concern fires per item.
  • No migration. Uses existing TaskRun_runTags_idx + composite status/env index.
  • Requires ≥1 tag — skipIfActive: true with no tags is a documented no-op (prevents surprising global dedup).
  • Interaction with idempotencyKey / concurrencyKey / delay / ttl / batchTrigger documented in docs/skip-if-active.mdx.

Out of scope (follow-ups)

  • V1 parity (v1 freeze).
  • Metrics counter for skipped triggers.
  • SDK convenience wrapper (the raw option works today; SDK devs may want a typed helper).

Docs

  • New page: docs/skip-if-active.mdx — compares with idempotencyKey and concurrencyKey, walks through the cron-scanner example, lists interaction matrix.
  • Registered in docs/docs.json under the existing idempotency entry.

Screenshots

N/A — server-side feature, no UI.


💯

…dedup

`skipIfActive: boolean` is a new option on `tasks.trigger()` (and batch
items). When set, and at least one `tag` is supplied, the webapp checks
Postgres for an in-flight (non-terminal) TaskRun with the same
`taskIdentifier` + environment + all-of the supplied tags. If one exists,
the trigger is a no-op: the existing run is returned with
`isCached: true` + new `wasSkipped: true` flag, and no new run is
created.

Why: current options don't cover the "cron scanner drops duplicates"
pattern.
- `idempotencyKey` caches successful completions too -> scheduled
  retriggers blocked until TTL expires.
- `concurrencyKey` queues duplicates FIFO -> backlog grows when a sync
  hangs; dashboard fills with redundant QUEUED rows.
- Manual `runs.list()` dedup in user code is expensive (ClickHouse
  `task_runs_v2 FINAL + hasAny(tags)` under load) and gets reinvented
  poorly in every downstream project.

Implementation follows the `IdempotencyKeyConcern` pattern:
- `SkipIfActiveConcern.handleTriggerRequest()` does one indexed SELECT
  on `"TaskRun"` (GIN `runTags_idx` + composite status/env index).
- Invoked in `RunEngineTriggerTaskService` AFTER idempotency
  (explicit idempotency match wins) but BEFORE run creation (skipped
  triggers never touch the queue).
- Route surfaces `wasSkipped: true` in the response. Additive — older
  SDK clients ignore unknown field.

Scope:
- Engine v2 only (the V1 `TriggerTaskServiceV1` path is not touched;
  new code in v1 is frozen upstream).
- Per-item honor in batch trigger via the same per-item concern call.
- No schema migration; reuses existing indexes.

Docs: `docs/skip-if-active.mdx` compares with `idempotencyKey` /
`concurrencyKey` and documents the interaction matrix.

Tests: unit tests for `SkipIfActiveConcern` covering no-flag, no-tags,
no-match, single-match, string-tag normalization, and the
row-disappeared-mid-query race.

Refs: triggerdotdev#3428 (related
context for cron-scanner load patterns that motivated this option).
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented Apr 23, 2026

⚠️ No Changeset found

Latest commit: f30f251

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 1846ccb2-a938-487c-b4d7-e9d2edcc9e33

📥 Commits

Reviewing files that changed from the base of the PR and between 87b6716 and f30f251.

📒 Files selected for processing (8)
  • apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts
  • apps/webapp/app/runEngine/concerns/skipIfActive.server.ts
  • apps/webapp/app/runEngine/services/triggerTask.server.ts
  • apps/webapp/app/v3/services/triggerTask.server.ts
  • apps/webapp/test/engine/skipIfActiveConcern.test.ts
  • docs/docs.json
  • docs/skip-if-active.mdx
  • packages/core/src/v3/schemas/api.ts

Walkthrough

This pull request introduces a new skipIfActive trigger option for task execution. The feature adds a SkipIfActiveConcern class that queries the database for non-terminal task runs matching the same task identifier and supplied tags within an environment. If a match is found, the trigger returns the existing run instead of creating a new one, reporting wasSkipped: true. The implementation includes integration into the RunEngineTriggerTaskService trigger flow, response schema updates to include a wasSkipped field, API schema changes to support the new option on both standard and batch triggers, comprehensive test coverage, and documentation of the feature's behavior and constraints.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Details

Key Changes

New Core Logic:

  • skipIfActive.server.ts introduces the SkipIfActiveConcern class which performs database queries to detect existing non-terminal runs matching task identifiers and tag sets, with fallback recovery for race conditions.

Service Integration:

  • RunEngineTriggerTaskService now invokes the skip concern after idempotency checks, returning early with isCached: true, wasSkipped: true when a match is found.

Type & Schema Updates:

  • TriggerTaskServiceResult includes optional wasSkipped field to distinguish skip-based responses from idempotency-cached ones.
  • API schemas (TriggerTaskRequestBody, TriggerTaskResponse, BatchTriggerTaskItem) support the new skipIfActive option and response flag.

Testing & Documentation:

  • New test suite validates query behavior, tag matching logic, and edge cases including race conditions.
  • Documentation page explains the feature, its interactions with other options, and provides usage examples and scenarios.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
⚔️ Resolve merge conflicts
  • Resolve merge conflict in branch eni/feat-skip-if-active

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

Hi @eni9889, thanks for your interest in contributing!

This project requires that pull request authors are vouched, and you are not in the list of vouched users.

This PR will be closed automatically. See https://github.com/triggerdotdev/trigger.dev/blob/main/CONTRIBUTING.md for more details.

@github-actions github-actions Bot closed this Apr 23, 2026
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment on lines +3 to +10
vi.mock("~/services/logger.server", () => ({
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Test file uses mocks and spies, violating mandatory repository testing rules

The test file apps/webapp/test/engine/skipIfActiveConcern.test.ts extensively uses vi.mock (line 3), vi.fn() (lines 5–9, 50–51), and hand-rolled mock objects for the Prisma client, directly violating the mandatory testing rules in ai/references/tests.md (referenced by AGENTS.md). Those rules explicitly state: "Do not mock anything", "Do not use mocks in tests", "Do not use spies in tests", "Do not use stubs in tests", "Do not use fakes in tests". The AGENTS.md further states: "Tests should avoid mocks or stubs and use the helpers from @internal/testcontainers when Redis or Postgres are needed." The test should use postgresTest from @internal/testcontainers to run against a real Postgres database, similar to how apps/webapp/test/engine/triggerTask.test.ts uses containerTest for its core assertions.

Prompt for agents
The test file apps/webapp/test/engine/skipIfActiveConcern.test.ts uses vi.mock for the logger (line 3) and vi.fn() throughout to build mock Prisma clients. This violates the mandatory repo rules in ai/references/tests.md which say 'Do not mock anything' and 'Do not use mocks in tests'.

The AGENTS.md also says: 'Tests should avoid mocks or stubs and use the helpers from @internal/testcontainers when Redis or Postgres are needed.'

To fix this, rewrite the test to use postgresTest (or containerTest) from @internal/testcontainers, which provides a real PrismaClient connected to a real Postgres instance. The test should:
1. Use postgresTest or containerTest to get a real prisma client
2. Set up real TaskRun rows in the database with appropriate statuses and tags using prisma.taskRun.create()
3. Create a SkipIfActiveConcern with the real prisma client
4. Assert behavior against real database queries

See apps/webapp/test/engine/triggerTask.test.ts for an example of how to structure tests using containerTest in this directory.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

* unlike `concurrencyKey` (which queues duplicates). Requires at least
* one tag to scope the check.
*/
skipIfActive: z.boolean().optional(),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 SDK trigger functions don't yet pass skipIfActive through to the API

The skipIfActive option was added to the Zod schemas (TriggerTaskRequestBody and BatchTriggerTaskItem in packages/core/src/v3/schemas/api.ts), but the SDK's trigger_internal function in packages/trigger-sdk/src/v3/shared.ts (around line 1166) builds the request body by explicitly mapping known options. There's no code in the SDK to pass skipIfActive from TriggerOptions to the API request body. This means SDK users can't actually use this feature via tasks.trigger() yet — they'd need to use the API directly. The TriggerOptions type in packages/core/src/v3/types/tasks.ts may also need updating. This may be intentional (SDK support in a follow-up PR) but is worth confirming.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant