Skip to content

Proposal: skipIfActive trigger option for drop-on-conflict dedup (patch ready) #3435

@eni9889

Description

@eni9889

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:

skipIfActive?: boolean;

Semantics (when skipIfActive: true AND at least one tag is supplied):

  1. 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[].
  2. 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.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions