Skip to content

fix(schedule): reconcile interrupted schedule executions - #6780

Merged
waleedlatif1 merged 6 commits into
stagingfrom
codex/schedule-recovery-reconciliation
Aug 20, 2026
Merged

fix(schedule): reconcile interrupted schedule executions#6780
waleedlatif1 merged 6 commits into
stagingfrom
codex/schedule-recovery-reconciliation

Conversation

@BillLeoutsakosvl346

Copy link
Copy Markdown
Contributor

Summary

Reconcile claimed schedule jobs after outages from persisted workflow execution logs instead of redispatching them. Untouched database jobs still execute only while pending with zero attempts, while claimed jobs are resolved as success, failure, cancellation, pause, or an indeterminate failure. Schedule accounting uses the existing occurrence claim guard, and cleanup leaves active schedule carriers to schedule recovery.

This intentionally keeps database and Trigger.dev recovery independent. Switching queue providers while schedule jobs are outstanding remains unsupported; deployments must drain outstanding schedule jobs before changing providers.

No schema migration, public API change, or new module is introduced.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation
  • Other

Testing

  • 72 focused schedule execution and cleanup tests
  • TypeScript type-check
  • Biome on all touched files
  • API validation audit
  • SQL date-binding audit
  • git diff --check

Reviewer focus: at-most-once behavior after a carrier is claimed, claim-guarded schedule accounting, cancellation handling, and cleanup ownership.

Checklist

  • Code follows project style guidelines
  • Self-reviewed my changes
  • Tests added/updated and passing
  • No new warnings introduced
  • I confirm that I have read and agree to the terms outlined in the Contributor License Agreement (CLA)

Screenshots/Videos

Not applicable; server-side recovery change.

@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Aug 20, 2026 6:31pm

Request Review

@cursor

cursor Bot commented Aug 17, 2026

Copy link
Copy Markdown

PR Summary

High Risk
Changes at-most-once schedule recovery, claim-guarded cadence accounting, and stale-job cleanup ownership. Incorrect classification can skip, double-fire, or fail live occurrences.

Overview
Stops redispatching claimed schedule jobs after outages. Recovery now classifies the occurrence from the persisted execution log (success, pause, failure, cancel, or indeterminate) and applies claim-guarded schedule accounting. Only untouched pending carriers (attempts = 0) still execute.

Unknown enqueue/lookup outcomes leave the claim in place instead of advancing cadence. Terminal carriers get a scheduleReconciled metadata stamp before retention can delete them; malformed payloads are marked irrecoverable and held longer. Stale cleanup no longer fails pending/processing schedule-execution jobs, and redacting logs are swept on the generic window so a slow mask is not treated as a failed run.

Adds concurrent partial indexes for unreconciled terminal carriers and redacting logs so recovery and cleanup stay index-backed. Database and Trigger.dev recovery remain independent; outstanding schedule jobs must drain before switching providers.

Reviewed by Cursor Bugbot for commit 6512dc2. Configure here.

Comment thread apps/sim/app/api/schedules/execute/route.ts Outdated
@greptile-apps

greptile-apps Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Reworks interrupted schedule execution recovery to reconcile claimed carriers against persisted workflow logs rather than redispatching them.

  • Adds claim-guarded success, failure, cancellation, pause, and indeterminate-outcome reconciliation.
  • Transfers active and unreconciled schedule-carrier cleanup ownership to schedule recovery.
  • Adds carrier metadata helpers and partial indexes supporting recovery and retention queries.
  • Extends focused tests for recovery states, concurrency guards, cancellation, cleanup, and retention.

Confidence Score: 5/5

The PR appears safe to merge because no eligible blocking failure remains in the supplied follow-up-review scope.

No blocking failure remains.

Important Files Changed

Filename Overview
apps/sim/app/api/schedules/execute/route.ts Introduces persisted-log reconciliation, guarded claim restoration, carrier settlement, and retry/defer handling for interrupted schedule occurrences.
apps/sim/background/schedule-execution.ts Exposes and reuses claim-guarded schedule accounting operations required by recovery.
apps/sim/app/api/cron/cleanup-stale-executions/route.ts Excludes active schedule carriers from generic stale cleanup and retains terminal carriers until recovery records reconciliation.
apps/sim/lib/workflows/schedules/carrier-metadata.ts Centralizes reconciliation and irrecoverability metadata markers and matching SQL predicates.
packages/db/migrations/0295_chilly_franklin_storm.sql Adds concurrent partial indexes for stale execution sweeping and unreconciled schedule-carrier recovery.
packages/db/schema.ts Declares the new partial indexes in the Drizzle schema consistently with the migration.
apps/sim/app/api/schedules/execute/route.test.ts Adds extensive coverage for recovery outcomes, claim races, malformed carriers, cancellation, and ambiguous queue acceptance.
apps/sim/app/api/cron/cleanup-stale-executions/route.test.ts Covers status-specific stale-log cleanup and schedule-carrier retention ownership.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  Tick[Schedule cron tick] --> Carrier{Existing carrier?}
  Carrier -->|Untouched pending, zero attempts| Execute[Execute occurrence]
  Carrier -->|Previously claimed or terminal| Log[Read persisted execution log]
  Log --> Outcome{Persisted outcome}
  Outcome -->|Completed / paused| Success[Apply claim-guarded success accounting]
  Outcome -->|Failed / indeterminate| Failure[Apply claim-guarded failure accounting]
  Outcome -->|Cancelled| Cancel[Apply claim-guarded cancellation accounting]
  Success --> Mark[Stamp carrier reconciled]
  Failure --> Mark
  Cancel --> Mark
  Mark --> Cleanup[Cleanup after retention window]
Loading

Reviews (3): Last reviewed commit: "refactor(schedules): drop vestigial reco..." | Re-trigger Greptile

…dex its scan

Follow-up hardening on the schedule recovery reconciliation.

Reconciliation settled every carrier it examined, including ones that had
already reached a terminal status and recorded their own outcome. Because the
normal completion path never stamps the reconciled marker, each successful
run's carrier was picked up on the next tick and rewritten: `completedAt`
bumped, `error` nulled and `output` replaced with a recovery stub, all of which
`GET /api/jobs/[jobId]` surfaces. A completed carrier whose execution log had
aged out was additionally flipped to failed. Settle only carriers still in
flight; a terminal one is owed schedule accounting and the marker, nothing more.

The recovery scan matched no index. There was none on `async_jobs.updated_at`,
and the unreconciled-terminal branch tested a jsonb extraction, so the whole
OR fell back to a sequential scan and sort of `async_jobs` on every tick. Add
the partial index, and spell the branch's status list and metadata key as SQL
literals: Postgres cannot prove a parameterised predicate implies a literal
index predicate, so a bound key would have left the new index unused.

Irrecoverable carrier tombstones were exempted from retention with no secondary
expiry, so the one class of row that can never reconcile grew without bound.
Give them a longer bounded window instead.

`WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN` had become a per-status budget when the
stale-execution sweep gained its `redacting` pass, silently doubling the
per-run cap. Share the budget across both passes, and add the matching partial
indexes so the second pass keeps an index rather than seq-scanning.

Also consolidates the carrier metadata keys, their predicates and the jsonb
merge into one module -- the two routes were the writer and reader of the same
keys with no shared symbol, so a rename type-checked clean while silently
breaking retention.
@waleedlatif1

Copy link
Copy Markdown
Collaborator

Pushed a follow-up commit (c2ca817) after a full LOC-level audit of this PR, plus a merge of staging (the branch was 88 commits behind, which mattered because the fix needs a migration and would otherwise have collided with 0292). The reconciliation design here holds up — I could not construct a double-execution or lost-cadence path — so this is hardening, not a rework. Four things:

1. Reconciliation was rewriting carriers that needed no repair. completeJob never stamps scheduleReconciled, so every normal, successful run's carrier was picked up by unreconciledTerminalScheduleExecutionJobsFilter on the next tick and re-written: completedAt bumped to now, error nulled, output replaced with {recovered:true,…} — and async_jobs.output is user-visible via GET /api/jobs/[jobId]. If the execution log was missing or unclassifiable, a genuinely COMPLETED carrier was flipped to FAILED. Schedule accounting stayed correct throughout (the claim guard and the nextRunAt-mismatch guard both no-op), so this was record fidelity rather than cadence. The settle write is now gated on the carrier still being in flight; a terminal one gets accounting and the marker only.

2. The recovery scan matched no index. There is no index on async_jobs.updated_at (the pre-PR code ordered by started_at, which matched a partial index), and the unreconciled-terminal branch tests a jsonb extraction, so the whole or(...) fell back to a seq scan + sort of async_jobs on every tick — and again per stale schedule item, since processScheduleItem re-runs the whole pass. Added the partial index in 0295. The index alone would have been dead weight: the filter bound the status list and metadata key as parameters, and Postgres cannot prove a parameterised predicate implies a literal index predicate, so it would have kept seq-scanning with the index unused. Both are now literals, with a test asserting the key does not render as a bind param.

3. Irrecoverable tombstones were retained forever. Retention exempted scheduleRecoveryIrrecoverable with no secondary expiry — the one class of row that can never reconcile, so it grew without bound and was also what the seq scan in (2) re-walked. Now a longer bounded window (30d) instead of infinite.

4. WORKFLOW_EXECUTION_MAX_ROWS_PER_RUN had silently become a per-status budget when the sweep gained its redacting pass, doubling the per-run cap. Budget is now shared across both passes. I kept the one-status-per-pass loop deliberately — folding it to status IN (...) would match neither partial index and would de-index the running sweep too, so I added the mirrored redacting partial indexes instead.

Also consolidated the carrier metadata keys, their predicates and the jsonb merge into lib/workflows/schedules/carrier-metadata.ts. The two routes were the writer and the reader of the same jsonb keys with no shared symbol, so renaming either type-checked clean while silently breaking retention.

Every fix is mutation-tested — I reverted each change and confirmed the tests go red, then green on restore. One assertion did not discriminate on the first pass and was tightened. type-check, check:audits (32/32), check:migrations and biome are all clean.

Two things I deliberately left alone: the at-most-once latency trade (a transient enqueue failure now waits out the reservation TTL rather than taking the fast infra-retry backoff — that's the point of the PR, worth a line in the description), and extracting the reconciliation out of the 1759-line route file, which is real but is a mechanical refactor better done separately.

Comment thread apps/sim/app/api/schedules/execute/route.ts Outdated
Comment thread apps/sim/app/api/schedules/execute/route.ts
Comment thread apps/sim/app/api/cron/cleanup-stale-executions/route.ts
… failing live runs

Addresses the review findings on the reconciliation hardening.

A carrier whose accounting was deferred got no write at all, so it kept its
`updatedAt` and stayed at the head of the `updatedAt`-ordered recovery batch on
every tick, starving every other claimed carrier and never becoming eligible for
retention. This was a regression from the previous commit: settling used to bump
the timestamp for every examined row, and skipping the settle for already-terminal
carriers removed the bump with it. A payload with no `scheduledFor` can never
reconcile, so such a row pinned a batch slot permanently. Bump `updatedAt`
unconditionally and keep only the reconciled marker conditional.

The stale-execution sweep terminalized `redacting` logs on the execution deadline.
That deadline bounds execution, while `redacting` covers payload masking after the
run already finished -- so a run that used most of its budget entered redaction
with the deadline due, and the sweep failed it five minutes later while the worker
was still masking. Schedule recovery then read the log as a failed occurrence and
counted a failure that never happened, even though the worker's terminal write
later restored `completed`. Sweep `redacting` on the generic stale window only.

`getScheduleNextRunAt` falls back to a daily cadence when a schedule has no cron
expression. Deployment cannot persist such a schedule, so the branch is
unreachable, but this change widened its use from failure recovery to every
outcome -- log a warning when it fires rather than silently guessing a cadence.

`executionDeadlineAt` was missing from the shared `workflowExecutionLogs` schema
mock, so it read as `undefined` and assertions comparing against that column were
trivially true. Add it.
@waleedlatif1

Copy link
Copy Markdown
Collaborator

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit cb22726. Configure here.

@waleedlatif1 waleedlatif1 changed the title fix: reconcile interrupted schedule executions fix(schedule): reconcile interrupted schedule executions Aug 20, 2026
… gap

Follow-ups from a full re-read of the change. No behavior change except the
removed dead code paths.

The metadata merge stripped a `scheduleRecoveryBlocked` key on every write. That
key has never been written by any shipped code -- it appears nowhere in staging
and nowhere in history outside this branch -- so the strip guarded against a
state that cannot exist, at the cost of an extra jsonb operation and a bind
parameter on every reconciliation write.

`processScheduleItem` set `carrierObservedOrLookupUncertain` immediately before
returning on an ambiguous enqueue. The flag is only read from the surrounding
catch block, which a normal return skips, so the assignment was dead and read as
though it were load-bearing. Replaced with a comment stating why the occurrence
is preserved.

The stale-execution sweep carried two near-identical `jsonb_set` templates that
differed only in their error expression, kept flat because the test mock renders
nested SQL fragments as placeholders. The suite now has a recursive renderer, so
the error expression is a named per-status value and there is one `jsonb_set`.

Success was the only schedule outcome without a named update builder, which left
`executeScheduleJob` using two idioms for the same guarded write and left the
update shape untested. Add `buildScheduleSuccessUpdate` beside its cancellation
and failure siblings, use it from both call sites, and cover it the way
`buildScheduleCancellationUpdate` is covered -- a mutation of its `failedCount`
reset previously passed every suite.
@waleedlatif1

Copy link
Copy Markdown
Collaborator

Did a full line-by-line re-read of the final state. Design verdict below, plus four things I cleaned up in 6512dc2.

The mechanism is the right one. The question "did this occurrence already run?" has exactly one durable answer in this system — the workflow_execution_logs row the executor itself writes — and reconciliation reads it. The alternatives are worse: trusting the carrier's terminal status can't distinguish "worker died before executing" from "workflow failed", and gives you nothing at all for a stale processing carrier; a heartbeat tells you the worker died but not what it did; workflow-level idempotency is impossible when workflows call arbitrary third-party APIs. Deriving the outcome from the log is strictly more informative than any of them, and it needs no new writer. Optimistic concurrency on lastQueuedAt for every accounting write is the correct guard and matches what the pre-existing code already used.

Two design points I considered and concluded are fine:

  • Reusing attempts as the claimed/unclaimed marker. Mild semantic overload, and it does make maxAttempts dead for this job type, but "how many times has a worker picked this up" is precisely the question being asked, and the filter's TSDoc says so.
  • Reconciliation state in async_jobs.metadata rather than a dedicated column. I went back and forth. A reconciled_at column would index normally and wouldn't echo through GET /api/jobs/[jobId]. But async_jobs is a generic table shared by six job types, a column only ever set for one of them is its own kind of pollution, and this table already carries control-plane state in metadata (getScheduleExecutionLeaseMs reads metadata.maxDurationSeconds). Consistent with the table's existing conventions, so I left it.

What I fixed:

  1. The metadata merge stripped a scheduleRecoveryBlocked key that has never existed. git log --all -S finds it only in this branch's own commits; it is not in staging and not anywhere in history. It was guarding a state that cannot occur, at the cost of a jsonb operation and a bind param on every reconciliation write. Removed.
  2. A dead store that read as load-bearing. carrierObservedOrLookupUncertain = true was set immediately before return on an ambiguous enqueue — but the flag is only read from the enclosing catch, which a normal return skips. The preserve-the-occurrence behavior came entirely from the return. Replaced the assignment with a comment saying why we preserve.
  3. Duplicated jsonb_set in the stale sweep. Two near-identical templates differing only in the error expression, kept flat because the test mock renders nested SQL fragments as ?. That is a harness limitation shaping production code. The suite now has a recursive renderer, so it's one jsonb_set with a named per-status error expression.
  4. A real coverage gap. Success was the only outcome without a named update builder, so executeScheduleJob used two idioms for the same guarded write and the update's field contents were asserted nowhere — I mutated failedCount: 0 to 1 and the entire suite still passed. Added buildScheduleSuccessUpdate beside its cancellation and failure siblings, wired both call sites through it, and covered it the way buildScheduleCancellationUpdate already was. The mutation now fails.

Remaining, not addressed here because they're pre-existing and structural rather than defects: processScheduleItem is still a long dual-backend branch (though this PR net-simplified it — five bespoke cancel+release sequences now converge on one reconcileExistingScheduleJob call), recoverStaleDatabaseScheduleJobs is a global sweep invoked from a per-item path (advisory-lock guarded, so safe), and the route file is 1759 lines behind an 18-line GET that belongs in lib/workflows/schedules/.

Verification: full suite 28,613 tests — one failure in unreadable-document.test.ts, which I confirmed pre-existing by reverting my harness change and watching it fail identically. type-check, check:audits 32/32, check:migrations, biome all clean.

@waleedlatif1

Copy link
Copy Markdown
Collaborator

@greptile

@waleedlatif1

Copy link
Copy Markdown
Collaborator

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 6512dc2. Configure here.

@waleedlatif1
waleedlatif1 merged commit 3b4d9e9 into staging Aug 20, 2026
30 checks passed
@waleedlatif1
waleedlatif1 deleted the codex/schedule-recovery-reconciliation branch August 20, 2026 18:55
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