Motivation
Three existing tasks.trigger() options each miss the "cron scanner drops duplicates" pattern:
| Option |
Behavior on match |
Problem for scheduled scanners |
idempotencyKey |
Returns existing run including completed ones until TTL expires |
Blocks scheduled re-runs after the first success |
concurrencyKey |
Queues FIFO behind the existing run |
Backlog grows unboundedly when a sync hangs; dashboard fills with QUEUED runs |
Manual runs.list() in user code |
✅ correct drop-on-conflict semantics |
Very expensive on self-hosted: task_runs_v2 FINAL + hasAny(tags) in ClickHouse. Observed 59+ concurrent pile-up pegging CH CPU on production (context in #3426). Reinvented poorly in every downstream project. |
There's no first-class "drop the trigger if an in-flight run matches" primitive. This issue proposes one.
Proposal
New option on TriggerTaskRequestBody.options and BatchTriggerTaskItem.options:
Semantics (when skipIfActive: true AND at least one tag is supplied):
- Server looks up
"TaskRun" rows scoped to the trigger's runtimeEnvironmentId + taskIdentifier, with status in the non-terminal set, where runTags @> ARRAY[<supplied tags>]::text[].
- If a match is found → trigger is a no-op. Existing run is returned with
isCached: true + new wasSkipped: true flag on the response. No new TaskRun row, no queue entry, no trace span for the would-be new run.
- If no match → trigger proceeds normally.
Requires at least one tag — skipIfActive: true with no tags is a documented no-op (prevents surprising global dedup).
Patch ready — branch + diff on fork
Full implementation: https://github.com/eni9889/trigger.dev/tree/eni/feat-skip-if-active (commit a66fa130b2)
Attempted to open as #3433 but the PR was auto-closed by the vouched-contributor check. Happy to:
- Open a new PR once vouched
- Or have a maintainer cherry-pick / re-push the branch —
.changeset already included
Diff size: 8 files, +377 LOC (~60 LOC production code, rest is tests + docs).
| File |
Change |
packages/core/src/v3/schemas/api.ts |
Add skipIfActive to request options (both single + batch item) and wasSkipped to response |
apps/webapp/app/runEngine/concerns/skipIfActive.server.ts (new) |
SkipIfActiveConcern — mirrors the IdempotencyKeyConcern pattern |
apps/webapp/app/runEngine/services/triggerTask.server.ts |
Wire concern into RunEngineTriggerTaskService.call() after idempotency, before run creation |
apps/webapp/app/v3/services/triggerTask.server.ts |
Optional wasSkipped?: boolean on TriggerTaskServiceResult |
apps/webapp/app/routes/api.v1.tasks.$taskId.trigger.ts |
Surface wasSkipped in the JSON response |
apps/webapp/test/engine/skipIfActiveConcern.test.ts (new) |
Unit tests (7 cases) — flag-unset, no-tags, no-match, match, string-tag normalization, race |
docs/skip-if-active.mdx (new) + docs/docs.json |
Documentation page + nav entry |
.changeset/skip-if-active.md |
Minor version bump for @trigger.dev/core |
Design decisions
- Engine v2 only.
TriggerTaskServiceV1 is intentionally untouched — v1 is frozen.
- No schema migration. Uses existing
TaskRun_runTags_idx (GIN) + TaskRun_status_runtimeEnvironmentId_createdAt_id_idx (composite). Benchmarked EXPLAIN (ANALYZE) on a 12M-row production TaskRun table at <100ms for a 3-tag AND lookup.
- Per-item for batches. Each
BatchTriggerTaskItem can carry its own skipIfActive; some items can skip while others enqueue.
- Response shape is additive. Older SDKs ignore the unknown
wasSkipped field. isCached: true is reused for parity with idempotency cache-hits; wasSkipped: true distinguishes drop-on-active from idempotency reuse when the caller needs to tell them apart.
- Non-terminal status set mirrors
internal-packages/database/prisma/schema.prisma's TaskRunStatus "NON-FINAL" group: DELAYED, PENDING, PENDING_VERSION, WAITING_FOR_DEPLOY, DEQUEUED, EXECUTING, WAITING_TO_RESUME, RETRYING_AFTER_FAILURE, PAUSED.
Interaction with existing options
| Combined with |
Behavior |
idempotencyKey |
Idempotency runs first — an explicit key match wins, skipIfActive never fires. |
concurrencyKey |
skipIfActive runs before queue admission. A match drops; the queue is not touched. |
delay |
A skipped trigger never creates a delayed run. |
ttl |
Irrelevant for skipped triggers — no new run is created. |
batchTrigger |
Per-item check — some items skip, others enqueue. |
Use case (context for relevance)
We run a maxcare orchestrator cron that fires every minute for each of N EHR connectors, triggering ezderm-notes-fetch, modmed-claims-fetch, etc. If a previous sync is still running, we want to skip — NOT queue a backlog — because the next minute's tick will re-evaluate with fresher state anyway.
We currently use runs.list({ tag: [...], status: [...], taskIdentifier: ... }) per-tick to dedup client-side. Under load this was the root cause of the CH meltdown tracked in #3426 (59 concurrent FINAL + hasAny queries). Moved to a direct PG read on "TaskRun" as a workaround but that couples us to internal schema. First-class skipIfActive would let us drop the workaround.
I'd guess the pattern is common — it's any "fire at cadence X, dedup on tag Y, drop on conflict" use case. Webhook debouncers, cron polls, workflow gating against external state.
Refs
Happy to take feedback on the design, rework the patch, or hand it off to a maintainer to pick up the branch directly.
Motivation
Three existing
tasks.trigger()options each miss the "cron scanner drops duplicates" pattern:idempotencyKeyconcurrencyKeyruns.list()in user codetask_runs_v2 FINAL + hasAny(tags)in ClickHouse. Observed 59+ concurrent pile-up pegging CH CPU on production (context in #3426). Reinvented poorly in every downstream project.There's no first-class "drop the trigger if an in-flight run matches" primitive. This issue proposes one.
Proposal
New option on
TriggerTaskRequestBody.optionsandBatchTriggerTaskItem.options:Semantics (when
skipIfActive: trueAND at least onetagis supplied):"TaskRun"rows scoped to the trigger'sruntimeEnvironmentId+taskIdentifier, withstatusin the non-terminal set, whererunTags @> ARRAY[<supplied tags>]::text[].isCached: true+ newwasSkipped: trueflag on the response. No new TaskRun row, no queue entry, no trace span for the would-be new run.Requires at least one tag —
skipIfActive: truewith no tags is a documented no-op (prevents surprising global dedup).Patch ready — branch + diff on fork
Full implementation: https://github.com/eni9889/trigger.dev/tree/eni/feat-skip-if-active (commit
a66fa130b2)Attempted to open as #3433 but the PR was auto-closed by the vouched-contributor check. Happy to:
.changesetalready includedDiff size: 8 files, +377 LOC (~60 LOC production code, rest is tests + docs).
packages/core/src/v3/schemas/api.tsskipIfActiveto request options (both single + batch item) andwasSkippedto responseapps/webapp/app/runEngine/concerns/skipIfActive.server.ts(new)SkipIfActiveConcern— mirrors theIdempotencyKeyConcernpatternapps/webapp/app/runEngine/services/triggerTask.server.tsRunEngineTriggerTaskService.call()after idempotency, before run creationapps/webapp/app/v3/services/triggerTask.server.tswasSkipped?: booleanonTriggerTaskServiceResultapps/webapp/app/routes/api.v1.tasks.$taskId.trigger.tswasSkippedin the JSON responseapps/webapp/test/engine/skipIfActiveConcern.test.ts(new)docs/skip-if-active.mdx(new) +docs/docs.json.changeset/skip-if-active.md@trigger.dev/coreDesign decisions
TriggerTaskServiceV1is intentionally untouched — v1 is frozen.TaskRun_runTags_idx(GIN) +TaskRun_status_runtimeEnvironmentId_createdAt_id_idx(composite). BenchmarkedEXPLAIN (ANALYZE)on a 12M-row productionTaskRuntable at <100ms for a 3-tag AND lookup.BatchTriggerTaskItemcan carry its ownskipIfActive; some items can skip while others enqueue.wasSkippedfield.isCached: trueis reused for parity with idempotency cache-hits;wasSkipped: truedistinguishes drop-on-active from idempotency reuse when the caller needs to tell them apart.internal-packages/database/prisma/schema.prisma'sTaskRunStatus"NON-FINAL" group:DELAYED, PENDING, PENDING_VERSION, WAITING_FOR_DEPLOY, DEQUEUED, EXECUTING, WAITING_TO_RESUME, RETRYING_AFTER_FAILURE, PAUSED.Interaction with existing options
idempotencyKeyskipIfActivenever fires.concurrencyKeyskipIfActiveruns before queue admission. A match drops; the queue is not touched.delayttlbatchTriggerUse case (context for relevance)
We run a maxcare orchestrator cron that fires every minute for each of N EHR connectors, triggering
ezderm-notes-fetch,modmed-claims-fetch, etc. If a previous sync is still running, we want to skip — NOT queue a backlog — because the next minute's tick will re-evaluate with fresher state anyway.We currently use
runs.list({ tag: [...], status: [...], taskIdentifier: ... })per-tick to dedup client-side. Under load this was the root cause of the CH meltdown tracked in #3426 (59 concurrentFINAL + hasAnyqueries). Moved to a direct PG read on"TaskRun"as a workaround but that couples us to internal schema. First-classskipIfActivewould let us drop the workaround.I'd guess the pattern is common — it's any "fire at cadence X, dedup on tag Y, drop on conflict" use case. Webhook debouncers, cron polls, workflow gating against external state.
Refs
task_runs_v2 FINAL + hasAny(tags)(related context for why dedup-in-user-code is expensive)LogicalReplicationClientstuck renewal loop (unrelated but filed by me against the same investigation)Happy to take feedback on the design, rework the patch, or hand it off to a maintainer to pick up the branch directly.