fix(execution): give a cancelled async run its terminal metadata - #6693
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
PR SummaryMedium Risk Overview The Trigger.dev job adapter now sets The workflow cancel API’s Reviewed by Cursor Bugbot for commit 9dab895. Configure here. |
Greptile SummaryThe PR fills terminal metadata for asynchronously cancelled Trigger.dev runs and standardizes cancellation-log duration writes.
Confidence Score: 5/5The PR appears safe to merge because no blocking failure remains. No blocking failure remains.
|
| Filename | Overview |
|---|---|
| apps/sim/lib/core/async-jobs/backends/trigger-dev.ts | Maps terminal runs without finishedAt to a stable completion timestamp using updatedAt, while preserving finishedAt precedence and excluding active statuses. |
| apps/sim/lib/core/async-jobs/types.ts | Defines the shared terminal-status set and documents the completion timestamp contract. |
| apps/sim/lib/logs/execution/cancellation.ts | Centralizes the terminal cancellation payload, including deadline clearing and duration derivation. |
| apps/sim/lib/logs/execution/duration.ts | Produces bounded, timezone-stable elapsed durations while preserving paused-run checkpoints. |
| apps/sim/app/api/workflows/[id]/executions/[executionId]/cancel/route.ts | Applies the shared terminal cancellation fields to the internal execution-log claim. |
| apps/sim/app/api/cron/cleanup-stale-executions/route.ts | Replaces duplicated duration SQL with the shared elapsed-duration helper. |
| packages/db/schema.ts | Documents the existing totalDurationMs semantics without changing the persisted schema. |
Sequence Diagram
sequenceDiagram
participant Client
participant CancelAPI as Cancellation path
participant Queue as Trigger.dev
participant StatusAPI as Execution status
participant Logs as workflow_execution_logs
Client->>CancelAPI: Cancel async execution
CancelAPI->>Queue: Cancel job
Queue-->>CancelAPI: CANCELED with updatedAt
CancelAPI->>Logs: Write terminal fields when log exists
Client->>StatusAPI: Poll execution status
StatusAPI->>Logs: Read execution log
alt Log row exists
Logs-->>StatusAPI: cancelled + endedAt + totalDurationMs
else Log row not written yet
StatusAPI->>Queue: Retrieve run
Queue-->>StatusAPI: CANCELED + updatedAt
StatusAPI-->>Client: cancelled + projected endedAt/duration
end
Reviews (2): Last reviewed commit: "refactor(execution): derive a cancelled ..." | Re-trigger Greptile
The staging integration suite has been failing integ-cancel-async-api-key/async-execution-becomes-cancelled: the run reports status cancelled with a null endedAt and a null duration, so the assertion fails and the dependent worker-stop check never runs. The run resource falls back to the queue job whenever no execution-log row exists yet, and a cancel that lands before the worker has written that row leaves exactly that state. Trigger.dev marks a run canceled the moment it accepts the cancellation but only stamps its finish time when the worker drains, so for the seconds in between the job is terminal with no timestamp, and the projection faithfully reports a terminal status with nothing to date it. The run writes a correct log row when it finally drains, which is why the endpoint heals itself and the alarm fires intermittently rather than always. The backend was discarding the one timestamp that is always present: the retrieve response carries a required updatedAt beside the optional finishedAt. A finish time still wins wherever it exists, so nothing that already reports correctly changes, and the fallback is taken only once the mapped status is terminal — an active run's updatedAt marks progress, and reading it as an end would retire a run that is still going. It records the server's last transition for the run rather than the reader's clock, so it stays put across polls instead of growing. Also carries the duration on a fifth cancellation write, in the internal cancel route, that the earlier pass missed because its sweep covered lib and not app.
…place The five cancellation paths each hand-assembled the same four-key payload for the workflow-execution log, and one of them had already drifted: the direct cancel never cleared `execution_deadline_at`, leaving a cancelled row carrying the deadline of an attempt that had stopped running. Extract the payload so the key set cannot vary between them, and leave the paths themselves alone — they differ in handle, claim predicate, whether they read the row back, and what they do when the claim is lost, so they stay separate statements. Bind the end instant through the `started_at` column encoder rather than a pre-stringified ISO literal. `check:sql-date-binding` exists to enforce exactly that binding; the literal passed only because it was already a string. Collapse the duration expression to one `COALESCE` over a valueless-`ELSE` `CASE`, which builds the elapsed fragment once instead of in both branches, and reuse it for the stale-execution sweeper, which carried its own copy along with a second int4 ceiling constant. Document the invariants the fix depends on where a reader meets them: that `total_duration_ms` means wall clock for a terminal row and active time for a paused one, and that a terminal job must carry its transition instant.
9dab895 to
34ed113
Compare
|
@greptile review |
Fixes the firing staging alarm
sim-staging-us-east-1-integ-failure—integ-cancel-async-api-key/async-execution-becomes-cancelledreturnsstatus=cancelledwith a nullendedAtand nulltotalDurationMs, failing the assertion and skipping the dependent worker-stop check. Recurring across deployments since at least 2026-08-13 21:56 UTC.Not the same bug as #6686
#6686 merged ~3h into the incident window and only touched the four
workflow_execution_logsterminal writes. I verified it live at 01:58 UTC on a table workflow-group run (totalDurationMs: 16358) — that path has a log row. This is the async path, which does not.Mechanism
execution-status.tsconsults the queue job only when no log row exists (...(!logRow ? [jobId] : [])). A cancel that lands before the worker writes that row —LoggingSession.startwrites it after preprocessing — takes the cancel route'sif (!execution)branch, cancels the queue jobs, returnsreason: 'queue_cancelled', and writes no log row. The next status poll therefore projects the queue job:It is a race, not an absence. Trigger.dev flips a run to
CANCELEDthe moment it accepts the cancellation and stampsfinishedAtonly when the worker drains. Verified against live staging runs (projectproj_kufttkwzywcydwtccqhx, envstg) bearing the failing test's own execution IDs — they ran ~12s before the cancel drained. The run does write a correct terminal log row on drain, which is why the endpoint self-heals and the alarm fires intermittently rather than always.Backend confirmed empirically rather than from config:
getAsyncBackendTypeselects trigger-dev only whenTRIGGER_DEV_ENABLEDplus project/secret env are set, which is not in the repo — so this was established from the live runs. The database backend is unaffected: bothcancelJobandcancelByExecutionsetcompletedAt: now.Fix — at the mapping, not the projection
The backend was discarding the one timestamp always present:
RetrieveRunResponsecarries a required, non-optionalupdatedAtbeside the optionalfinishedAt.finishedAtstill wins wherever present, so nothing already reporting correctly changes.updatedAtmeans progress, and reading it as an end would retire a run still in flight.updatedAtis the server's record of the run's last transition, not a read-time clock, so it is stable across polls. (new Date()was explicitly disqualified: it would report a duration that grows after the run is over.)Rejected alternatives. Withholding the terminal status until metadata is final would report
runningfor a run the user already cancelled — a worse lie — and depends on the log row always landing, which the never-dequeued case does not guarantee. Writing a log row on async cancel would add a fifth log-writer to a route that deliberately returnsqueue_cancelledwithout touching the log; the projection is already correct once the job carries a timestamp.Plus a fifth cancellation write
claimExecutionLogCancellationin the internal cancel route wroteendedAtwith nototalDurationMs— same shape as the four #6686 covered. It hid because that sweep searchedlib/and this lives underapp/. The sweep now spans both; all five carry the duration.Two pre-existing tests asserting the exact
.set()payload failed when the field was added, which is its own confirmation the path is live.Verification
check:audits26/26 — all cleanAdjacent, not fixed
Whether the queued-but-never-dequeued case can still produce a terminal job with no timestamp on the database backend — its cancel writes are guarded by
WHERE status IN (PENDING, PROCESSING), so a job in another state would not update. Not observed, and out of scope for this incident.