Skip to content

feat(run-engine): trigger tasks pinned to an external deployment id - #4664

Open
0ski wants to merge 1 commit into
oskar/feat-external-id-deployfrom
oskar/feat-external-id-trigger
Open

feat(run-engine): trigger tasks pinned to an external deployment id#4664
0ski wants to merge 1 commit into
oskar/feat-external-id-deployfrom
oskar/feat-external-id-trigger

Conversation

@0ski

@0ski 0ski commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

The SDK discovers an external deployment id at runtime (explicit TRIGGER_EXTERNAL_DEPLOYMENT_ID always; platform commit-SHA variables and generic fallbacks when TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1) and sends it alongside lockToVersion; the server resolves precedence (version > external id > current). An id held by a deployed deployment pins the run to that worker; an in-flight or unknown id parks the run in PENDING_VERSION with the id in TaskRun.annotations, wakes it pinned when a deployment carrying the id finalizes (ClickHouse candidates, Postgres authoritative), and expires it after a deadline that re-checks Postgres before acting. Parking outranks delaying and preserves delayUntil. The id is projected to ClickHouse task_runs_v2.external_deployment_id during replication. Redis cache for id-to-worker resolution, guarded version-aware writes.

Ids are not unique. Several deployments can hold one id - a --force rebuild is the ordinary way to get there - so resolution always picks the highest version among the candidates, never the newest by timestamp. The rule is applied identically on both paths that can bind a run to a worker: resolveExternalDeployment at trigger time, and PendingVersionSystem when a landing deployment wakes a parked run. Version comparison is numeric on the counter half, so 20260807.10 outranks 20260807.9.

A run whose id never lands expires at the deadline with EXTERNAL_DEPLOYMENT_NOT_FOUND and an error naming the id it waited for, which is what a failed build or a typo looks like from the caller. Default deadline is one hour (EXTERNAL_DEPLOYMENT_PARK_DEADLINE_MS).

Debounce registration happens in both the parked and the delayed branch through one helper, so a debounced run that parks still binds its debounce key; without it every later trigger for the same key created another parked run, and all of them executed when the deployment landed. The two DELAYED-only status checks in DebounceSystem also accept PENDING_VERSION, without which the lock-contention fallback would rethrow a 5xx the SDK retries and amplifies, and the fast path would push every trigger on a parked key through the redlock.

Resolution is skipped in development. A dev environment cannot hold a WorkerDeployment - trigger dev registers a BackgroundWorker with nothing behind it, and deploy --env refuses dev - so an external deployment id there could only ever park, and the parked run then expired against the dev TTL while a connected dev worker sat idle. The id is still annotated so the dashboard shows what the app sent (TRI-13000).

@changeset-bot

changeset-bot Bot commented Aug 18, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: bb5d381

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
@trigger.dev/core Patch
@trigger.dev/sdk Patch
@trigger.dev/build Patch
trigger.dev Patch
@trigger.dev/python Patch
@trigger.dev/redis-worker Patch
@trigger.dev/schema-to-json Patch
@internal/clickhouse Patch
@internal/llm-model-catalog Patch
@internal/metrics-pipeline Patch
@trigger.dev/rbac Patch
@internal/redis Patch
@internal/replication Patch
@internal/run-engine Patch
@internal/run-store Patch
@internal/schedule-engine Patch
@internal/tracing Patch
@internal/webhook-engine Patch
@internal/webhook-sources Patch
@internal/dashboard-agent Patch
@internal/cache Patch
@trigger.dev/react-hooks Patch
@trigger.dev/rsc Patch
@trigger.dev/database Patch
@trigger.dev/otlp-importer Patch
@trigger.dev/sso Patch
@internal/testcontainers Patch

Not sure what this means? Click here to learn what changesets are.

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

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: c953b991-fa19-4f58-b253-ab738d5dbdf8

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

@0ski
0ski marked this pull request as ready for review August 18, 2026 13:15
devin-ai-integration[bot]

This comment was marked as resolved.

Comment thread .changeset/trigger-external-deployment-id.md Outdated
The SDK discovers an external deployment id at runtime (explicit TRIGGER_EXTERNAL_DEPLOYMENT_ID always; platform commit-SHA variables and generic fallbacks when TRIGGER_AUTOMATIC_SKEW_VERSION_PROTECTION=1) and sends it alongside lockToVersion; the server resolves precedence (version > external id > current). An id held by a deployed deployment pins the run to that worker; an in-flight or unknown id parks the run in PENDING_VERSION with the id in TaskRun.annotations, wakes it pinned when a deployment carrying the id finalizes (ClickHouse candidates, Postgres authoritative), and expires it after a deadline that re-checks Postgres before acting. Parking outranks delaying and preserves delayUntil. The id is projected to ClickHouse task_runs_v2.external_deployment_id during replication. Redis cache for id-to-worker resolution, guarded version-aware writes.

Ids are not unique. Several deployments can hold one id - a --force rebuild is the ordinary way to get there - so resolution always picks the highest version among the candidates, never the newest by timestamp. The rule is applied identically on both paths that can bind a run to a worker: resolveExternalDeployment at trigger time, and PendingVersionSystem when a landing deployment wakes a parked run. Version comparison is numeric on the counter half, so 20260807.10 outranks 20260807.9.

A run whose id never lands expires at the deadline with EXTERNAL_DEPLOYMENT_NOT_FOUND and an error naming the id it waited for, which is what a failed build or a typo looks like from the caller. Default deadline is one hour (EXTERNAL_DEPLOYMENT_PARK_DEADLINE_MS).

Debounce registration happens in both the parked and the delayed branch through one helper, so a debounced run that parks still binds its debounce key; without it every later trigger for the same key created another parked run, and all of them executed when the deployment landed. The two DELAYED-only status checks in DebounceSystem also accept PENDING_VERSION, without which the lock-contention fallback would rethrow a 5xx the SDK retries and amplifies, and the fast path would push every trigger on a parked key through the redlock.

Resolution is skipped in development. A dev environment cannot hold a WorkerDeployment - trigger dev registers a BackgroundWorker with nothing behind it, and deploy --env refuses dev - so an external deployment id there could only ever park, and the parked run then expired against the dev TTL while a connected dev worker sat idle. The id is still annotated so the dashboard shows what the app sent (TRI-13000).
@0ski
0ski force-pushed the oskar/feat-external-id-trigger branch from 8f7617a to bb5d381 Compare August 18, 2026 18:50
@pkg-pr-new

pkg-pr-new Bot commented Aug 18, 2026

Copy link
Copy Markdown

Open in StackBlitz

@trigger.dev/build

npm i https://pkg.pr.new/@trigger.dev/build@bb5d381

trigger.dev

npm i https://pkg.pr.new/trigger.dev@bb5d381

@trigger.dev/core

npm i https://pkg.pr.new/@trigger.dev/core@bb5d381

@trigger.dev/python

npm i https://pkg.pr.new/@trigger.dev/python@bb5d381

@trigger.dev/react-hooks

npm i https://pkg.pr.new/@trigger.dev/react-hooks@bb5d381

@trigger.dev/redis-worker

npm i https://pkg.pr.new/@trigger.dev/redis-worker@bb5d381

@trigger.dev/rsc

npm i https://pkg.pr.new/@trigger.dev/rsc@bb5d381

@trigger.dev/schema-to-json

npm i https://pkg.pr.new/@trigger.dev/schema-to-json@bb5d381

@trigger.dev/sdk

npm i https://pkg.pr.new/@trigger.dev/sdk@bb5d381

commit: bb5d381

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

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.

Devin Review found 3 new potential issues.

Open in Devin Review

Comment on lines +1381 to +1382
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === "P2025") {
return { count: 0 };

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.

🟡 Runs that expire and get released at the same moment can crash the background job instead of quietly doing nothing

The "row no longer matches" case is only recognised when the error object comes from the control-plane database client (error instanceof Prisma.PrismaClientKnownRequestError at internal-packages/run-store/src/PostgresRunStore.ts:1381), so on the split database setup the harmless race is rethrown as a hard failure instead of being treated as a no-op.
Impact: When a waiting run is released by a landing deployment at the same instant its wait deadline fires, the deadline job errors out and retries repeatedly, filling logs with spurious failures.

Cross-generation Prisma error classes make the P2025 guard ineffective on the dedicated run-ops client

expireParkedRun issues prisma.taskRun.update({ where: { id, status: "PENDING_VERSION" } }) directly (not through #updateTaskRunWithSelect, which routes errors through normalizeRunOpsError at internal-packages/run-store/src/PostgresRunStore.ts:554-559). When the run has already been promoted out of PENDING_VERSION, Prisma raises P2025. The store's own comment block at internal-packages/run-store/src/PostgresRunStore.ts:485-500 documents that the run-ops generated client has its OWN PrismaClientKnownRequestError class object, so error instanceof Prisma.PrismaClientKnownRequestError is false for errors raised by that client. The return { count: 0 } branch is therefore skipped and the error escapes PendingVersionSystem.expireParkedExternalDeploymentRun, failing the expireParkedExternalDeploymentRun worker job instead of returning cleanly.

The existing helpers key on the runtime name + string code (isForeignPrismaKnownRequestError) precisely for this reason; the new guard should do the same (or route the write through the normalizing helper).

Prompt for agents
In PostgresRunStore.expireParkedRun the P2025 catch uses `error instanceof Prisma.PrismaClientKnownRequestError`. As documented near isForeignPrismaKnownRequestError/normalizeRunOpsError in the same file, errors raised by the run-ops generated client are instances of a DIFFERENT PrismaClientKnownRequestError class, so instanceof is false and the intended `return { count: 0 }` no-op is skipped — the error escapes and fails the expireParkedExternalDeploymentRun worker job whenever the run was concurrently promoted out of PENDING_VERSION. Detect the P2025 in a generation-agnostic way (e.g. reuse the existing foreign-error detection that keys on the runtime `name` plus the string `code`, or route the update through the helper that already normalizes errors) so both schema variants behave identically.
Open in Devin Review

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

prisma
);
if (!probe || probe.status !== "DELAYED" || !probe.delayUntil) {
if (!probe || !DEBOUNCEABLE_RUN_STATUSES.includes(probe.status) || !probe.delayUntil) {

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.

🟡 Repeated triggers on a waiting, debounced run all take the slow contended path they were meant to skip

The quick check that lets a repeated debounce trigger return the existing run without taking the distributed lock was widened to include runs waiting on a deployment (DEBOUNCEABLE_RUN_STATUSES.includes(probe.status) at internal-packages/run-engine/src/engine/systems/debounceSystem.ts:649) but the second half of the same check was left as before, so such runs always fall through to the lock anyway.
Impact: A hot debounce key whose run is waiting on a deployment funnels every trigger through the shared lock, adding latency and lock contention on the busiest code path.

The follow-up full-run read still hard-codes DELAYED

#tryFastPathSkip was updated in two places (the probe at internal-packages/run-engine/src/engine/systems/debounceSystem.ts:649 and the lock-contention fallback at :707), but the full-run re-read a few lines later still reads if (!fullRun || fullRun.status !== "DELAYED") return null; (internal-packages/run-engine/src/engine/systems/debounceSystem.ts:672). A run parked with PENDING_VERSION passes the probe and then always returns null there, so the fast path can never short-circuit for parked keys — the exact case the change set out to cover. Behaviour stays correct (the locked path re-checks and skips rescheduling), but the redlock is acquired on every trigger for that key.

Prompt for agents
In DebounceSystem.#tryFastPathSkip the initial probe now accepts both DELAYED and PENDING_VERSION via DEBOUNCEABLE_RUN_STATUSES, but the subsequent full-run read still bails on `fullRun.status !== "DELAYED"`, so the unlocked fast path never short-circuits for a run parked on an external deployment id and every trigger on that debounce key takes the redlock. Make the second status check use the same DEBOUNCEABLE_RUN_STATUSES set so the two checks agree.
Open in Devin Review

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

Comment on lines +147 to +149
expect(byRunId.get(withId.id)).toBe("fa1eade47b73733d6312d5abfad33ce9e4068081");
expect(byRunId.get(withoutId.id)).toBe("");
expect(byRunId.get(withUnparseableAnnotations.id)).toBe("commit-on-a-broken-blob");

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.

🔍 New replication test contradicts the statusReason gate and will fail

The new ClickHouse projection is deliberately gated on run.statusReason === PARKED_ON_EXTERNAL_DEPLOYMENT_STATUS_REASON (apps/webapp/app/services/runsReplicationService.server.ts:1369), which is required for correctness: the pending-version lookup filters external_deployment_id = '' when the landing deployment carries no external id (apps/webapp/app/v3/services/clickhousePendingVersionLookup.server.ts:87), so a run that merely reports an id in annotations but is parked for NO_WORKER must project an empty column or it would never be released — exactly the case the engine test "releases a run that reports an external deployment id but is parked for an unrelated reason" asserts.

The new replication test, however, creates all three runs with statusReason unset and asserts the id is projected anyway (apps/webapp/test/runsReplicationServiceExternalDeploymentId.test.ts:147,149). With the gate in place those rows project "", so the test fails. Either the test needs to set statusReason: "EXTERNAL_DEPLOYMENT_PENDING" on the two rows expected to carry an id, or the intended semantics differ from the code — worth confirming which before merge.

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.

2 participants