Skip to content

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup - #3162

Open
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs
Open

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup#3162
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs

Conversation

@Mohith26

Copy link
Copy Markdown

Fixes#3119, reported by @Jiarui-Ni.

Startup recovery re-enqueued every non-terminal run, including parked runs, and enqueued recovery jobs without a stable job key. Two problems followed: parked runs were woken up when the world restarted, and repeated boots accumulated duplicate recovery jobs for the same run.

Fix: skip parked runs during startup recovery, and enqueue recovery jobs with a stable startup-recovery:<runId> job key so graphile-worker dedupes them across boots. Includes a changeset (patch for @workflow/world-postgres).

All 38 world-postgres tests pass locally; the new parked-run and dedupe tests fail without the fix.

…very
Startup recovery previously re-enqueued every pending/running run via the
generic reenqueueActiveRuns helper, replaying runs that are durably parked
on unresolved hooks or not-yet-due waits, and minting a fresh graphile job
key per boot so repeated restarts accumulated duplicate outstanding jobs.
Replace it with a Postgres-specific reenqueueRecoverableRuns that:
- classifies running runs against persisted state (steps/waits/hooks) and
skips runs whose only live state is open hooks and/or waits that are not
due yet - their wake-up jobs live durably in the same database and
survive restarts
- still recovers runs with interrupted (non-terminal) step work, due
waits, and unclassifiable runs with no persisted suspension state
(fail open)
- attaches a stable per-run idempotency key (startup-recovery:<runId>),
which the queue uses as the graphile-worker job_key, so repeated boots
replace the outstanding recovery job instead of adding another, without
suppressing later legitimate wakes
Fixesvercel#3119
Add unit tests for reenqueueRecoverableRuns (hook-parked, future-wait and
indefinite-wait runs skipped; due waits, interrupted steps, pending and
ambiguous runs recovered; stable idempotency key across repeated boots;
fail-open on classification errors) and extend the createWorld() startup
tests to assert parked runs are skipped end-to-end and that the recovery
enqueue uses the stable startup-recovery:<runId> graphile job key on every
boot.
@Mohith26
Mohith26 requested review from a team and ijjk as code ownersJuly 28, 2026 18:42
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9603206

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

This PR includes changesets to release 1 package
NameType
@workflow/world-postgresPatch

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

@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@Mohith26 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious

Copy link
Copy Markdown
Member

@Mohith26 We require commits to be signed. Could you squash+sign the PR and force-push the branch? Separate review following soon.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

runnable.add(runId);
continue;
}
if (openHookRunIds.has(runId) || waitingRunIds.has(runId)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This treats "a hook row exists" as "durably parked", but the hooks table carries no per-delivery state: the row is written at hook_created and removed only at hook_disposed (see packages/world-postgres/src/drizzle/schema.ts — no status column). In packages/core/src/runtime/resume-hook.ts the hook_received event is awaited before the workflow re-trigger is enqueued. A crash or lost enqueue between those two writes leaves exactly the state this branch classifies as parked: an undisposed hook row, no runnable step, no due wait — but with a payload already recorded that nothing will ever act on. Nothing else re-drives it, so the run is skipped on every subsequent restart. reenqueueActiveRuns recovered it.

Verified against this branch: run in running, one hook row, no steps/waits → reenqueueActiveRuns enqueues 1, reenqueueRecoverableRuns enqueues 0.

Waits don't have this failure mode because waits.status flips to completed, which lands in the fail-open branch below. Hooks do, because row deletion is the only transition. Issue #3119's own repro describes the parked fixture as having "no received hook delivery" — that precondition isn't checkable from these tables. Classifying from the event log the way openHookAndWaitState() in packages/core/src/runtime.ts does (hook_created without hook_disposed, and no dangling hook_received), or adding a delivered marker to the hook row, would close it.

await enqueue(
queueName,
{ runId: run.runId },
{ idempotencyKey: startupRecoveryIdempotencyKey(run.runId) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Attaching an idempotency key to a workflow re-trigger message opts it out of the per-run replay serialization. In packages/world-postgres/src/queue.ts, createTaskHandler only consults workflowRunSerializationKey (workflow:${runId}, set for any workflow message without a stepId — i.e. exactly this { runId } payload) inside the if (!idempotencyKey) branch, with the comment "prevent two workflow replays from mutating the same run's event log at the same time". Keyed messages skip straight past it. Startup is precisely when other pending jobs for the same run are also being picked up.

Verified by adapting the existing serializes workflow queue execution for the same runId test in queue.test.ts: two { runId } messages for the same run, the second carrying startup-recovery:<runId>maxActiveRequests is 2. The unmodified test (no key) asserts 1. Two concurrent replays of one run's event log.

Two further consequences of the same key:

  • completedMessages is an in-memory LRU keyed on the idempotency key, so within one process a second recovery attempt for a run is silently dropped rather than executed.
  • The key propagates into messageData.idempotencyKey and is reused as jobKey when the handler reschedules (queue.ts ~L555). graphile-worker's default replace mode means a later addJob with the same key overwrites the outstanding job's run_at — so a recovery enqueue and a suspended run's own future wake-up job can clobber each other, in either direction depending on ordering.

If the intent is only "don't accumulate duplicate recovery jobs across restarts", the queue's own per-run serialization already collapses redundant replays, and the event log makes duplicate replays harmless. Worth considering leaving the payload key-less, or threading a job key that doesn't route the message through the idempotency branch.

* @param enqueue - Queue's enqueue method
* @param label - Log prefix identifying the world implementation
* @param namespace - Optional queue namespace. Defaults to WORKFLOW_QUEUE_NAMESPACE.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

This doc block documents reenqueueRecoverableRuns (@param runs, @param drizzle, …) but sits immediately above recoverPage's own doc block, so recoverPage ends up with two comments and the exported function with none. Looks like a copy/paste — move it down to the export.

* and suspending, so it must be replayed to continue; we fail open for
* this ambiguous case because the enqueue is deduplicated.
*
* A run counts as parked (skipped) when its only live state is open hooks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

On the fix as a whole: the problem is real (#3119 — restart storms re-enqueue every suspended run, and duplicate recovery jobs pile up), the page-at-a-time classifier with three batched queries is the right shape, and classifyPageSafe failing open is a good call. The tests are thorough for the classifier itself (22 pass locally).

What makes it risky as written is that both mechanisms are inferred from entity tables that don't carry enough state to distinguish "parked" from "lost wake-up" (see the hooks comment), and the dedupe rides on the queue's idempotency channel, which already has other semantics attached (see the enqueue comment). A narrower first step that only skips runs with a futurewaits.resumeAt — the case where the wake-up job is genuinely persisted in the same database and provably survives the restart — would get most of the benefit with none of the wedge risk, and could be extended to hooks once hook rows can express delivery state.

Sign up for freeto 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.

world-postgres: startup recovery re-enqueues parked runs and accumulates duplicate jobs

2 participants

@Mohith26@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
fix(world-postgres): skip parked runs and dedupe recovery jobs on startup by Mohith26 · Pull Request #3162 · vercel/workflow · GitHub
Skip to content

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup - #3162

Open
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs
Open

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup#3162
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs

Conversation

@Mohith26

Copy link
Copy Markdown

Fixes#3119, reported by @Jiarui-Ni.

Startup recovery re-enqueued every non-terminal run, including parked runs, and enqueued recovery jobs without a stable job key. Two problems followed: parked runs were woken up when the world restarted, and repeated boots accumulated duplicate recovery jobs for the same run.

Fix: skip parked runs during startup recovery, and enqueue recovery jobs with a stable startup-recovery:<runId> job key so graphile-worker dedupes them across boots. Includes a changeset (patch for @workflow/world-postgres).

All 38 world-postgres tests pass locally; the new parked-run and dedupe tests fail without the fix.

…very
Startup recovery previously re-enqueued every pending/running run via the
generic reenqueueActiveRuns helper, replaying runs that are durably parked
on unresolved hooks or not-yet-due waits, and minting a fresh graphile job
key per boot so repeated restarts accumulated duplicate outstanding jobs.
Replace it with a Postgres-specific reenqueueRecoverableRuns that:
- classifies running runs against persisted state (steps/waits/hooks) and
skips runs whose only live state is open hooks and/or waits that are not
due yet - their wake-up jobs live durably in the same database and
survive restarts
- still recovers runs with interrupted (non-terminal) step work, due
waits, and unclassifiable runs with no persisted suspension state
(fail open)
- attaches a stable per-run idempotency key (startup-recovery:<runId>),
which the queue uses as the graphile-worker job_key, so repeated boots
replace the outstanding recovery job instead of adding another, without
suppressing later legitimate wakes
Fixesvercel#3119
Add unit tests for reenqueueRecoverableRuns (hook-parked, future-wait and
indefinite-wait runs skipped; due waits, interrupted steps, pending and
ambiguous runs recovered; stable idempotency key across repeated boots;
fail-open on classification errors) and extend the createWorld() startup
tests to assert parked runs are skipped end-to-end and that the recovery
enqueue uses the stable startup-recovery:<runId> graphile job key on every
boot.
@Mohith26
Mohith26 requested review from a team and ijjk as code ownersJuly 28, 2026 18:42
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9603206

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

This PR includes changesets to release 1 package
NameType
@workflow/world-postgresPatch

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

@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@Mohith26 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious

Copy link
Copy Markdown
Member

@Mohith26 We require commits to be signed. Could you squash+sign the PR and force-push the branch? Separate review following soon.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

runnable.add(runId);
continue;
}
if (openHookRunIds.has(runId) || waitingRunIds.has(runId)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This treats "a hook row exists" as "durably parked", but the hooks table carries no per-delivery state: the row is written at hook_created and removed only at hook_disposed (see packages/world-postgres/src/drizzle/schema.ts — no status column). In packages/core/src/runtime/resume-hook.ts the hook_received event is awaited before the workflow re-trigger is enqueued. A crash or lost enqueue between those two writes leaves exactly the state this branch classifies as parked: an undisposed hook row, no runnable step, no due wait — but with a payload already recorded that nothing will ever act on. Nothing else re-drives it, so the run is skipped on every subsequent restart. reenqueueActiveRuns recovered it.

Verified against this branch: run in running, one hook row, no steps/waits → reenqueueActiveRuns enqueues 1, reenqueueRecoverableRuns enqueues 0.

Waits don't have this failure mode because waits.status flips to completed, which lands in the fail-open branch below. Hooks do, because row deletion is the only transition. Issue #3119's own repro describes the parked fixture as having "no received hook delivery" — that precondition isn't checkable from these tables. Classifying from the event log the way openHookAndWaitState() in packages/core/src/runtime.ts does (hook_created without hook_disposed, and no dangling hook_received), or adding a delivered marker to the hook row, would close it.

await enqueue(
queueName,
{ runId: run.runId },
{ idempotencyKey: startupRecoveryIdempotencyKey(run.runId) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Attaching an idempotency key to a workflow re-trigger message opts it out of the per-run replay serialization. In packages/world-postgres/src/queue.ts, createTaskHandler only consults workflowRunSerializationKey (workflow:${runId}, set for any workflow message without a stepId — i.e. exactly this { runId } payload) inside the if (!idempotencyKey) branch, with the comment "prevent two workflow replays from mutating the same run's event log at the same time". Keyed messages skip straight past it. Startup is precisely when other pending jobs for the same run are also being picked up.

Verified by adapting the existing serializes workflow queue execution for the same runId test in queue.test.ts: two { runId } messages for the same run, the second carrying startup-recovery:<runId>maxActiveRequests is 2. The unmodified test (no key) asserts 1. Two concurrent replays of one run's event log.

Two further consequences of the same key:

  • completedMessages is an in-memory LRU keyed on the idempotency key, so within one process a second recovery attempt for a run is silently dropped rather than executed.
  • The key propagates into messageData.idempotencyKey and is reused as jobKey when the handler reschedules (queue.ts ~L555). graphile-worker's default replace mode means a later addJob with the same key overwrites the outstanding job's run_at — so a recovery enqueue and a suspended run's own future wake-up job can clobber each other, in either direction depending on ordering.

If the intent is only "don't accumulate duplicate recovery jobs across restarts", the queue's own per-run serialization already collapses redundant replays, and the event log makes duplicate replays harmless. Worth considering leaving the payload key-less, or threading a job key that doesn't route the message through the idempotency branch.

* @param enqueue - Queue's enqueue method
* @param label - Log prefix identifying the world implementation
* @param namespace - Optional queue namespace. Defaults to WORKFLOW_QUEUE_NAMESPACE.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

This doc block documents reenqueueRecoverableRuns (@param runs, @param drizzle, …) but sits immediately above recoverPage's own doc block, so recoverPage ends up with two comments and the exported function with none. Looks like a copy/paste — move it down to the export.

* and suspending, so it must be replayed to continue; we fail open for
* this ambiguous case because the enqueue is deduplicated.
*
* A run counts as parked (skipped) when its only live state is open hooks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

On the fix as a whole: the problem is real (#3119 — restart storms re-enqueue every suspended run, and duplicate recovery jobs pile up), the page-at-a-time classifier with three batched queries is the right shape, and classifyPageSafe failing open is a good call. The tests are thorough for the classifier itself (22 pass locally).

What makes it risky as written is that both mechanisms are inferred from entity tables that don't carry enough state to distinguish "parked" from "lost wake-up" (see the hooks comment), and the dedupe rides on the queue's idempotency channel, which already has other semantics attached (see the enqueue comment). A narrower first step that only skips runs with a futurewaits.resumeAt — the case where the wake-up job is genuinely persisted in the same database and provably survives the restart — would get most of the benefit with none of the wedge risk, and could be extended to hooks once hook rows can express delivery state.

Sign up for freeto 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.

world-postgres: startup recovery re-enqueues parked runs and accumulates duplicate jobs

2 participants

@Mohith26@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(world-postgres): skip parked runs and dedupe recovery jobs on startup by Mohith26 · Pull Request #3162 · vercel/workflow · GitHub
Skip to content

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup - #3162

Open
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs
Open

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup#3162
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs

Conversation

@Mohith26

Copy link
Copy Markdown

Fixes#3119, reported by @Jiarui-Ni.

Startup recovery re-enqueued every non-terminal run, including parked runs, and enqueued recovery jobs without a stable job key. Two problems followed: parked runs were woken up when the world restarted, and repeated boots accumulated duplicate recovery jobs for the same run.

Fix: skip parked runs during startup recovery, and enqueue recovery jobs with a stable startup-recovery:<runId> job key so graphile-worker dedupes them across boots. Includes a changeset (patch for @workflow/world-postgres).

All 38 world-postgres tests pass locally; the new parked-run and dedupe tests fail without the fix.

…very
Startup recovery previously re-enqueued every pending/running run via the
generic reenqueueActiveRuns helper, replaying runs that are durably parked
on unresolved hooks or not-yet-due waits, and minting a fresh graphile job
key per boot so repeated restarts accumulated duplicate outstanding jobs.
Replace it with a Postgres-specific reenqueueRecoverableRuns that:
- classifies running runs against persisted state (steps/waits/hooks) and
skips runs whose only live state is open hooks and/or waits that are not
due yet - their wake-up jobs live durably in the same database and
survive restarts
- still recovers runs with interrupted (non-terminal) step work, due
waits, and unclassifiable runs with no persisted suspension state
(fail open)
- attaches a stable per-run idempotency key (startup-recovery:<runId>),
which the queue uses as the graphile-worker job_key, so repeated boots
replace the outstanding recovery job instead of adding another, without
suppressing later legitimate wakes
Fixesvercel#3119
Add unit tests for reenqueueRecoverableRuns (hook-parked, future-wait and
indefinite-wait runs skipped; due waits, interrupted steps, pending and
ambiguous runs recovered; stable idempotency key across repeated boots;
fail-open on classification errors) and extend the createWorld() startup
tests to assert parked runs are skipped end-to-end and that the recovery
enqueue uses the stable startup-recovery:<runId> graphile job key on every
boot.
@Mohith26
Mohith26 requested review from a team and ijjk as code ownersJuly 28, 2026 18:42
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9603206

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

This PR includes changesets to release 1 package
NameType
@workflow/world-postgresPatch

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

@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@Mohith26 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious

Copy link
Copy Markdown
Member

@Mohith26 We require commits to be signed. Could you squash+sign the PR and force-push the branch? Separate review following soon.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

runnable.add(runId);
continue;
}
if (openHookRunIds.has(runId) || waitingRunIds.has(runId)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This treats "a hook row exists" as "durably parked", but the hooks table carries no per-delivery state: the row is written at hook_created and removed only at hook_disposed (see packages/world-postgres/src/drizzle/schema.ts — no status column). In packages/core/src/runtime/resume-hook.ts the hook_received event is awaited before the workflow re-trigger is enqueued. A crash or lost enqueue between those two writes leaves exactly the state this branch classifies as parked: an undisposed hook row, no runnable step, no due wait — but with a payload already recorded that nothing will ever act on. Nothing else re-drives it, so the run is skipped on every subsequent restart. reenqueueActiveRuns recovered it.

Verified against this branch: run in running, one hook row, no steps/waits → reenqueueActiveRuns enqueues 1, reenqueueRecoverableRuns enqueues 0.

Waits don't have this failure mode because waits.status flips to completed, which lands in the fail-open branch below. Hooks do, because row deletion is the only transition. Issue #3119's own repro describes the parked fixture as having "no received hook delivery" — that precondition isn't checkable from these tables. Classifying from the event log the way openHookAndWaitState() in packages/core/src/runtime.ts does (hook_created without hook_disposed, and no dangling hook_received), or adding a delivered marker to the hook row, would close it.

await enqueue(
queueName,
{ runId: run.runId },
{ idempotencyKey: startupRecoveryIdempotencyKey(run.runId) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Attaching an idempotency key to a workflow re-trigger message opts it out of the per-run replay serialization. In packages/world-postgres/src/queue.ts, createTaskHandler only consults workflowRunSerializationKey (workflow:${runId}, set for any workflow message without a stepId — i.e. exactly this { runId } payload) inside the if (!idempotencyKey) branch, with the comment "prevent two workflow replays from mutating the same run's event log at the same time". Keyed messages skip straight past it. Startup is precisely when other pending jobs for the same run are also being picked up.

Verified by adapting the existing serializes workflow queue execution for the same runId test in queue.test.ts: two { runId } messages for the same run, the second carrying startup-recovery:<runId>maxActiveRequests is 2. The unmodified test (no key) asserts 1. Two concurrent replays of one run's event log.

Two further consequences of the same key:

  • completedMessages is an in-memory LRU keyed on the idempotency key, so within one process a second recovery attempt for a run is silently dropped rather than executed.
  • The key propagates into messageData.idempotencyKey and is reused as jobKey when the handler reschedules (queue.ts ~L555). graphile-worker's default replace mode means a later addJob with the same key overwrites the outstanding job's run_at — so a recovery enqueue and a suspended run's own future wake-up job can clobber each other, in either direction depending on ordering.

If the intent is only "don't accumulate duplicate recovery jobs across restarts", the queue's own per-run serialization already collapses redundant replays, and the event log makes duplicate replays harmless. Worth considering leaving the payload key-less, or threading a job key that doesn't route the message through the idempotency branch.

* @param enqueue - Queue's enqueue method
* @param label - Log prefix identifying the world implementation
* @param namespace - Optional queue namespace. Defaults to WORKFLOW_QUEUE_NAMESPACE.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

This doc block documents reenqueueRecoverableRuns (@param runs, @param drizzle, …) but sits immediately above recoverPage's own doc block, so recoverPage ends up with two comments and the exported function with none. Looks like a copy/paste — move it down to the export.

* and suspending, so it must be replayed to continue; we fail open for
* this ambiguous case because the enqueue is deduplicated.
*
* A run counts as parked (skipped) when its only live state is open hooks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

On the fix as a whole: the problem is real (#3119 — restart storms re-enqueue every suspended run, and duplicate recovery jobs pile up), the page-at-a-time classifier with three batched queries is the right shape, and classifyPageSafe failing open is a good call. The tests are thorough for the classifier itself (22 pass locally).

What makes it risky as written is that both mechanisms are inferred from entity tables that don't carry enough state to distinguish "parked" from "lost wake-up" (see the hooks comment), and the dedupe rides on the queue's idempotency channel, which already has other semantics attached (see the enqueue comment). A narrower first step that only skips runs with a futurewaits.resumeAt — the case where the wake-up job is genuinely persisted in the same database and provably survives the restart — would get most of the benefit with none of the wedge risk, and could be extended to hooks once hook rows can express delivery state.

Sign up for freeto 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.

world-postgres: startup recovery re-enqueues parked runs and accumulates duplicate jobs

2 participants

@Mohith26@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(world-postgres): skip parked runs and dedupe recovery jobs on startup by Mohith26 · Pull Request #3162 · vercel/workflow · GitHub
Skip to content

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup - #3162

Open
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs
Open

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup#3162
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs

Conversation

@Mohith26

Copy link
Copy Markdown

Fixes#3119, reported by @Jiarui-Ni.

Startup recovery re-enqueued every non-terminal run, including parked runs, and enqueued recovery jobs without a stable job key. Two problems followed: parked runs were woken up when the world restarted, and repeated boots accumulated duplicate recovery jobs for the same run.

Fix: skip parked runs during startup recovery, and enqueue recovery jobs with a stable startup-recovery:<runId> job key so graphile-worker dedupes them across boots. Includes a changeset (patch for @workflow/world-postgres).

All 38 world-postgres tests pass locally; the new parked-run and dedupe tests fail without the fix.

…very
Startup recovery previously re-enqueued every pending/running run via the
generic reenqueueActiveRuns helper, replaying runs that are durably parked
on unresolved hooks or not-yet-due waits, and minting a fresh graphile job
key per boot so repeated restarts accumulated duplicate outstanding jobs.
Replace it with a Postgres-specific reenqueueRecoverableRuns that:
- classifies running runs against persisted state (steps/waits/hooks) and
skips runs whose only live state is open hooks and/or waits that are not
due yet - their wake-up jobs live durably in the same database and
survive restarts
- still recovers runs with interrupted (non-terminal) step work, due
waits, and unclassifiable runs with no persisted suspension state
(fail open)
- attaches a stable per-run idempotency key (startup-recovery:<runId>),
which the queue uses as the graphile-worker job_key, so repeated boots
replace the outstanding recovery job instead of adding another, without
suppressing later legitimate wakes
Fixesvercel#3119
Add unit tests for reenqueueRecoverableRuns (hook-parked, future-wait and
indefinite-wait runs skipped; due waits, interrupted steps, pending and
ambiguous runs recovered; stable idempotency key across repeated boots;
fail-open on classification errors) and extend the createWorld() startup
tests to assert parked runs are skipped end-to-end and that the recovery
enqueue uses the stable startup-recovery:<runId> graphile job key on every
boot.
@Mohith26
Mohith26 requested review from a team and ijjk as code ownersJuly 28, 2026 18:42
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9603206

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

This PR includes changesets to release 1 package
NameType
@workflow/world-postgresPatch

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

@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@Mohith26 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious

Copy link
Copy Markdown
Member

@Mohith26 We require commits to be signed. Could you squash+sign the PR and force-push the branch? Separate review following soon.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

runnable.add(runId);
continue;
}
if (openHookRunIds.has(runId) || waitingRunIds.has(runId)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This treats "a hook row exists" as "durably parked", but the hooks table carries no per-delivery state: the row is written at hook_created and removed only at hook_disposed (see packages/world-postgres/src/drizzle/schema.ts — no status column). In packages/core/src/runtime/resume-hook.ts the hook_received event is awaited before the workflow re-trigger is enqueued. A crash or lost enqueue between those two writes leaves exactly the state this branch classifies as parked: an undisposed hook row, no runnable step, no due wait — but with a payload already recorded that nothing will ever act on. Nothing else re-drives it, so the run is skipped on every subsequent restart. reenqueueActiveRuns recovered it.

Verified against this branch: run in running, one hook row, no steps/waits → reenqueueActiveRuns enqueues 1, reenqueueRecoverableRuns enqueues 0.

Waits don't have this failure mode because waits.status flips to completed, which lands in the fail-open branch below. Hooks do, because row deletion is the only transition. Issue #3119's own repro describes the parked fixture as having "no received hook delivery" — that precondition isn't checkable from these tables. Classifying from the event log the way openHookAndWaitState() in packages/core/src/runtime.ts does (hook_created without hook_disposed, and no dangling hook_received), or adding a delivered marker to the hook row, would close it.

await enqueue(
queueName,
{ runId: run.runId },
{ idempotencyKey: startupRecoveryIdempotencyKey(run.runId) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Attaching an idempotency key to a workflow re-trigger message opts it out of the per-run replay serialization. In packages/world-postgres/src/queue.ts, createTaskHandler only consults workflowRunSerializationKey (workflow:${runId}, set for any workflow message without a stepId — i.e. exactly this { runId } payload) inside the if (!idempotencyKey) branch, with the comment "prevent two workflow replays from mutating the same run's event log at the same time". Keyed messages skip straight past it. Startup is precisely when other pending jobs for the same run are also being picked up.

Verified by adapting the existing serializes workflow queue execution for the same runId test in queue.test.ts: two { runId } messages for the same run, the second carrying startup-recovery:<runId>maxActiveRequests is 2. The unmodified test (no key) asserts 1. Two concurrent replays of one run's event log.

Two further consequences of the same key:

  • completedMessages is an in-memory LRU keyed on the idempotency key, so within one process a second recovery attempt for a run is silently dropped rather than executed.
  • The key propagates into messageData.idempotencyKey and is reused as jobKey when the handler reschedules (queue.ts ~L555). graphile-worker's default replace mode means a later addJob with the same key overwrites the outstanding job's run_at — so a recovery enqueue and a suspended run's own future wake-up job can clobber each other, in either direction depending on ordering.

If the intent is only "don't accumulate duplicate recovery jobs across restarts", the queue's own per-run serialization already collapses redundant replays, and the event log makes duplicate replays harmless. Worth considering leaving the payload key-less, or threading a job key that doesn't route the message through the idempotency branch.

* @param enqueue - Queue's enqueue method
* @param label - Log prefix identifying the world implementation
* @param namespace - Optional queue namespace. Defaults to WORKFLOW_QUEUE_NAMESPACE.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

This doc block documents reenqueueRecoverableRuns (@param runs, @param drizzle, …) but sits immediately above recoverPage's own doc block, so recoverPage ends up with two comments and the exported function with none. Looks like a copy/paste — move it down to the export.

* and suspending, so it must be replayed to continue; we fail open for
* this ambiguous case because the enqueue is deduplicated.
*
* A run counts as parked (skipped) when its only live state is open hooks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

On the fix as a whole: the problem is real (#3119 — restart storms re-enqueue every suspended run, and duplicate recovery jobs pile up), the page-at-a-time classifier with three batched queries is the right shape, and classifyPageSafe failing open is a good call. The tests are thorough for the classifier itself (22 pass locally).

What makes it risky as written is that both mechanisms are inferred from entity tables that don't carry enough state to distinguish "parked" from "lost wake-up" (see the hooks comment), and the dedupe rides on the queue's idempotency channel, which already has other semantics attached (see the enqueue comment). A narrower first step that only skips runs with a futurewaits.resumeAt — the case where the wake-up job is genuinely persisted in the same database and provably survives the restart — would get most of the benefit with none of the wedge risk, and could be extended to hooks once hook rows can express delivery state.

Sign up for freeto 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.

world-postgres: startup recovery re-enqueues parked runs and accumulates duplicate jobs

2 participants

@Mohith26@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' fix(world-postgres): skip parked runs and dedupe recovery jobs on startup by Mohith26 · Pull Request #3162 · vercel/workflow · GitHub
Skip to content

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup - #3162

Open
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs
Open

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup#3162
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs

Conversation

@Mohith26

Copy link
Copy Markdown

Fixes#3119, reported by @Jiarui-Ni.

Startup recovery re-enqueued every non-terminal run, including parked runs, and enqueued recovery jobs without a stable job key. Two problems followed: parked runs were woken up when the world restarted, and repeated boots accumulated duplicate recovery jobs for the same run.

Fix: skip parked runs during startup recovery, and enqueue recovery jobs with a stable startup-recovery:<runId> job key so graphile-worker dedupes them across boots. Includes a changeset (patch for @workflow/world-postgres).

All 38 world-postgres tests pass locally; the new parked-run and dedupe tests fail without the fix.

…very
Startup recovery previously re-enqueued every pending/running run via the
generic reenqueueActiveRuns helper, replaying runs that are durably parked
on unresolved hooks or not-yet-due waits, and minting a fresh graphile job
key per boot so repeated restarts accumulated duplicate outstanding jobs.
Replace it with a Postgres-specific reenqueueRecoverableRuns that:
- classifies running runs against persisted state (steps/waits/hooks) and
skips runs whose only live state is open hooks and/or waits that are not
due yet - their wake-up jobs live durably in the same database and
survive restarts
- still recovers runs with interrupted (non-terminal) step work, due
waits, and unclassifiable runs with no persisted suspension state
(fail open)
- attaches a stable per-run idempotency key (startup-recovery:<runId>),
which the queue uses as the graphile-worker job_key, so repeated boots
replace the outstanding recovery job instead of adding another, without
suppressing later legitimate wakes
Fixesvercel#3119
Add unit tests for reenqueueRecoverableRuns (hook-parked, future-wait and
indefinite-wait runs skipped; due waits, interrupted steps, pending and
ambiguous runs recovered; stable idempotency key across repeated boots;
fail-open on classification errors) and extend the createWorld() startup
tests to assert parked runs are skipped end-to-end and that the recovery
enqueue uses the stable startup-recovery:<runId> graphile job key on every
boot.
@Mohith26
Mohith26 requested review from a team and ijjk as code ownersJuly 28, 2026 18:42
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9603206

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

This PR includes changesets to release 1 package
NameType
@workflow/world-postgresPatch

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

@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@Mohith26 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious

Copy link
Copy Markdown
Member

@Mohith26 We require commits to be signed. Could you squash+sign the PR and force-push the branch? Separate review following soon.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

runnable.add(runId);
continue;
}
if (openHookRunIds.has(runId) || waitingRunIds.has(runId)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This treats "a hook row exists" as "durably parked", but the hooks table carries no per-delivery state: the row is written at hook_created and removed only at hook_disposed (see packages/world-postgres/src/drizzle/schema.ts — no status column). In packages/core/src/runtime/resume-hook.ts the hook_received event is awaited before the workflow re-trigger is enqueued. A crash or lost enqueue between those two writes leaves exactly the state this branch classifies as parked: an undisposed hook row, no runnable step, no due wait — but with a payload already recorded that nothing will ever act on. Nothing else re-drives it, so the run is skipped on every subsequent restart. reenqueueActiveRuns recovered it.

Verified against this branch: run in running, one hook row, no steps/waits → reenqueueActiveRuns enqueues 1, reenqueueRecoverableRuns enqueues 0.

Waits don't have this failure mode because waits.status flips to completed, which lands in the fail-open branch below. Hooks do, because row deletion is the only transition. Issue #3119's own repro describes the parked fixture as having "no received hook delivery" — that precondition isn't checkable from these tables. Classifying from the event log the way openHookAndWaitState() in packages/core/src/runtime.ts does (hook_created without hook_disposed, and no dangling hook_received), or adding a delivered marker to the hook row, would close it.

await enqueue(
queueName,
{ runId: run.runId },
{ idempotencyKey: startupRecoveryIdempotencyKey(run.runId) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Attaching an idempotency key to a workflow re-trigger message opts it out of the per-run replay serialization. In packages/world-postgres/src/queue.ts, createTaskHandler only consults workflowRunSerializationKey (workflow:${runId}, set for any workflow message without a stepId — i.e. exactly this { runId } payload) inside the if (!idempotencyKey) branch, with the comment "prevent two workflow replays from mutating the same run's event log at the same time". Keyed messages skip straight past it. Startup is precisely when other pending jobs for the same run are also being picked up.

Verified by adapting the existing serializes workflow queue execution for the same runId test in queue.test.ts: two { runId } messages for the same run, the second carrying startup-recovery:<runId>maxActiveRequests is 2. The unmodified test (no key) asserts 1. Two concurrent replays of one run's event log.

Two further consequences of the same key:

  • completedMessages is an in-memory LRU keyed on the idempotency key, so within one process a second recovery attempt for a run is silently dropped rather than executed.
  • The key propagates into messageData.idempotencyKey and is reused as jobKey when the handler reschedules (queue.ts ~L555). graphile-worker's default replace mode means a later addJob with the same key overwrites the outstanding job's run_at — so a recovery enqueue and a suspended run's own future wake-up job can clobber each other, in either direction depending on ordering.

If the intent is only "don't accumulate duplicate recovery jobs across restarts", the queue's own per-run serialization already collapses redundant replays, and the event log makes duplicate replays harmless. Worth considering leaving the payload key-less, or threading a job key that doesn't route the message through the idempotency branch.

* @param enqueue - Queue's enqueue method
* @param label - Log prefix identifying the world implementation
* @param namespace - Optional queue namespace. Defaults to WORKFLOW_QUEUE_NAMESPACE.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

This doc block documents reenqueueRecoverableRuns (@param runs, @param drizzle, …) but sits immediately above recoverPage's own doc block, so recoverPage ends up with two comments and the exported function with none. Looks like a copy/paste — move it down to the export.

* and suspending, so it must be replayed to continue; we fail open for
* this ambiguous case because the enqueue is deduplicated.
*
* A run counts as parked (skipped) when its only live state is open hooks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

On the fix as a whole: the problem is real (#3119 — restart storms re-enqueue every suspended run, and duplicate recovery jobs pile up), the page-at-a-time classifier with three batched queries is the right shape, and classifyPageSafe failing open is a good call. The tests are thorough for the classifier itself (22 pass locally).

What makes it risky as written is that both mechanisms are inferred from entity tables that don't carry enough state to distinguish "parked" from "lost wake-up" (see the hooks comment), and the dedupe rides on the queue's idempotency channel, which already has other semantics attached (see the enqueue comment). A narrower first step that only skips runs with a futurewaits.resumeAt — the case where the wake-up job is genuinely persisted in the same database and provably survives the restart — would get most of the benefit with none of the wedge risk, and could be extended to hooks once hook rows can express delivery state.

Sign up for freeto 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.

world-postgres: startup recovery re-enqueues parked runs and accumulates duplicate jobs

2 participants

@Mohith26@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(world-postgres): skip parked runs and dedupe recovery jobs on startup by Mohith26 · Pull Request #3162 · vercel/workflow · GitHub
Skip to content

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup - #3162

Open
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs
Open

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup#3162
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs

Conversation

@Mohith26

Copy link
Copy Markdown

Fixes#3119, reported by @Jiarui-Ni.

Startup recovery re-enqueued every non-terminal run, including parked runs, and enqueued recovery jobs without a stable job key. Two problems followed: parked runs were woken up when the world restarted, and repeated boots accumulated duplicate recovery jobs for the same run.

Fix: skip parked runs during startup recovery, and enqueue recovery jobs with a stable startup-recovery:<runId> job key so graphile-worker dedupes them across boots. Includes a changeset (patch for @workflow/world-postgres).

All 38 world-postgres tests pass locally; the new parked-run and dedupe tests fail without the fix.

…very
Startup recovery previously re-enqueued every pending/running run via the
generic reenqueueActiveRuns helper, replaying runs that are durably parked
on unresolved hooks or not-yet-due waits, and minting a fresh graphile job
key per boot so repeated restarts accumulated duplicate outstanding jobs.
Replace it with a Postgres-specific reenqueueRecoverableRuns that:
- classifies running runs against persisted state (steps/waits/hooks) and
skips runs whose only live state is open hooks and/or waits that are not
due yet - their wake-up jobs live durably in the same database and
survive restarts
- still recovers runs with interrupted (non-terminal) step work, due
waits, and unclassifiable runs with no persisted suspension state
(fail open)
- attaches a stable per-run idempotency key (startup-recovery:<runId>),
which the queue uses as the graphile-worker job_key, so repeated boots
replace the outstanding recovery job instead of adding another, without
suppressing later legitimate wakes
Fixesvercel#3119
Add unit tests for reenqueueRecoverableRuns (hook-parked, future-wait and
indefinite-wait runs skipped; due waits, interrupted steps, pending and
ambiguous runs recovered; stable idempotency key across repeated boots;
fail-open on classification errors) and extend the createWorld() startup
tests to assert parked runs are skipped end-to-end and that the recovery
enqueue uses the stable startup-recovery:<runId> graphile job key on every
boot.
@Mohith26
Mohith26 requested review from a team and ijjk as code ownersJuly 28, 2026 18:42
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9603206

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

This PR includes changesets to release 1 package
NameType
@workflow/world-postgresPatch

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

@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@Mohith26 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious

Copy link
Copy Markdown
Member

@Mohith26 We require commits to be signed. Could you squash+sign the PR and force-push the branch? Separate review following soon.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

runnable.add(runId);
continue;
}
if (openHookRunIds.has(runId) || waitingRunIds.has(runId)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This treats "a hook row exists" as "durably parked", but the hooks table carries no per-delivery state: the row is written at hook_created and removed only at hook_disposed (see packages/world-postgres/src/drizzle/schema.ts — no status column). In packages/core/src/runtime/resume-hook.ts the hook_received event is awaited before the workflow re-trigger is enqueued. A crash or lost enqueue between those two writes leaves exactly the state this branch classifies as parked: an undisposed hook row, no runnable step, no due wait — but with a payload already recorded that nothing will ever act on. Nothing else re-drives it, so the run is skipped on every subsequent restart. reenqueueActiveRuns recovered it.

Verified against this branch: run in running, one hook row, no steps/waits → reenqueueActiveRuns enqueues 1, reenqueueRecoverableRuns enqueues 0.

Waits don't have this failure mode because waits.status flips to completed, which lands in the fail-open branch below. Hooks do, because row deletion is the only transition. Issue #3119's own repro describes the parked fixture as having "no received hook delivery" — that precondition isn't checkable from these tables. Classifying from the event log the way openHookAndWaitState() in packages/core/src/runtime.ts does (hook_created without hook_disposed, and no dangling hook_received), or adding a delivered marker to the hook row, would close it.

await enqueue(
queueName,
{ runId: run.runId },
{ idempotencyKey: startupRecoveryIdempotencyKey(run.runId) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Attaching an idempotency key to a workflow re-trigger message opts it out of the per-run replay serialization. In packages/world-postgres/src/queue.ts, createTaskHandler only consults workflowRunSerializationKey (workflow:${runId}, set for any workflow message without a stepId — i.e. exactly this { runId } payload) inside the if (!idempotencyKey) branch, with the comment "prevent two workflow replays from mutating the same run's event log at the same time". Keyed messages skip straight past it. Startup is precisely when other pending jobs for the same run are also being picked up.

Verified by adapting the existing serializes workflow queue execution for the same runId test in queue.test.ts: two { runId } messages for the same run, the second carrying startup-recovery:<runId>maxActiveRequests is 2. The unmodified test (no key) asserts 1. Two concurrent replays of one run's event log.

Two further consequences of the same key:

  • completedMessages is an in-memory LRU keyed on the idempotency key, so within one process a second recovery attempt for a run is silently dropped rather than executed.
  • The key propagates into messageData.idempotencyKey and is reused as jobKey when the handler reschedules (queue.ts ~L555). graphile-worker's default replace mode means a later addJob with the same key overwrites the outstanding job's run_at — so a recovery enqueue and a suspended run's own future wake-up job can clobber each other, in either direction depending on ordering.

If the intent is only "don't accumulate duplicate recovery jobs across restarts", the queue's own per-run serialization already collapses redundant replays, and the event log makes duplicate replays harmless. Worth considering leaving the payload key-less, or threading a job key that doesn't route the message through the idempotency branch.

* @param enqueue - Queue's enqueue method
* @param label - Log prefix identifying the world implementation
* @param namespace - Optional queue namespace. Defaults to WORKFLOW_QUEUE_NAMESPACE.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

This doc block documents reenqueueRecoverableRuns (@param runs, @param drizzle, …) but sits immediately above recoverPage's own doc block, so recoverPage ends up with two comments and the exported function with none. Looks like a copy/paste — move it down to the export.

* and suspending, so it must be replayed to continue; we fail open for
* this ambiguous case because the enqueue is deduplicated.
*
* A run counts as parked (skipped) when its only live state is open hooks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

On the fix as a whole: the problem is real (#3119 — restart storms re-enqueue every suspended run, and duplicate recovery jobs pile up), the page-at-a-time classifier with three batched queries is the right shape, and classifyPageSafe failing open is a good call. The tests are thorough for the classifier itself (22 pass locally).

What makes it risky as written is that both mechanisms are inferred from entity tables that don't carry enough state to distinguish "parked" from "lost wake-up" (see the hooks comment), and the dedupe rides on the queue's idempotency channel, which already has other semantics attached (see the enqueue comment). A narrower first step that only skips runs with a futurewaits.resumeAt — the case where the wake-up job is genuinely persisted in the same database and provably survives the restart — would get most of the benefit with none of the wedge risk, and could be extended to hooks once hook rows can express delivery state.

Sign up for freeto 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.

world-postgres: startup recovery re-enqueues parked runs and accumulates duplicate jobs

2 participants

@Mohith26@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' fix(world-postgres): skip parked runs and dedupe recovery jobs on startup by Mohith26 · Pull Request #3162 · vercel/workflow · GitHub
Skip to content

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup - #3162

Open
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs
Open

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup#3162
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs

Conversation

@Mohith26

Copy link
Copy Markdown

Fixes#3119, reported by @Jiarui-Ni.

Startup recovery re-enqueued every non-terminal run, including parked runs, and enqueued recovery jobs without a stable job key. Two problems followed: parked runs were woken up when the world restarted, and repeated boots accumulated duplicate recovery jobs for the same run.

Fix: skip parked runs during startup recovery, and enqueue recovery jobs with a stable startup-recovery:<runId> job key so graphile-worker dedupes them across boots. Includes a changeset (patch for @workflow/world-postgres).

All 38 world-postgres tests pass locally; the new parked-run and dedupe tests fail without the fix.

…very
Startup recovery previously re-enqueued every pending/running run via the
generic reenqueueActiveRuns helper, replaying runs that are durably parked
on unresolved hooks or not-yet-due waits, and minting a fresh graphile job
key per boot so repeated restarts accumulated duplicate outstanding jobs.
Replace it with a Postgres-specific reenqueueRecoverableRuns that:
- classifies running runs against persisted state (steps/waits/hooks) and
skips runs whose only live state is open hooks and/or waits that are not
due yet - their wake-up jobs live durably in the same database and
survive restarts
- still recovers runs with interrupted (non-terminal) step work, due
waits, and unclassifiable runs with no persisted suspension state
(fail open)
- attaches a stable per-run idempotency key (startup-recovery:<runId>),
which the queue uses as the graphile-worker job_key, so repeated boots
replace the outstanding recovery job instead of adding another, without
suppressing later legitimate wakes
Fixesvercel#3119
Add unit tests for reenqueueRecoverableRuns (hook-parked, future-wait and
indefinite-wait runs skipped; due waits, interrupted steps, pending and
ambiguous runs recovered; stable idempotency key across repeated boots;
fail-open on classification errors) and extend the createWorld() startup
tests to assert parked runs are skipped end-to-end and that the recovery
enqueue uses the stable startup-recovery:<runId> graphile job key on every
boot.
@Mohith26
Mohith26 requested review from a team and ijjk as code ownersJuly 28, 2026 18:42
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9603206

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

This PR includes changesets to release 1 package
NameType
@workflow/world-postgresPatch

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

@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@Mohith26 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious

Copy link
Copy Markdown
Member

@Mohith26 We require commits to be signed. Could you squash+sign the PR and force-push the branch? Separate review following soon.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

runnable.add(runId);
continue;
}
if (openHookRunIds.has(runId) || waitingRunIds.has(runId)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This treats "a hook row exists" as "durably parked", but the hooks table carries no per-delivery state: the row is written at hook_created and removed only at hook_disposed (see packages/world-postgres/src/drizzle/schema.ts — no status column). In packages/core/src/runtime/resume-hook.ts the hook_received event is awaited before the workflow re-trigger is enqueued. A crash or lost enqueue between those two writes leaves exactly the state this branch classifies as parked: an undisposed hook row, no runnable step, no due wait — but with a payload already recorded that nothing will ever act on. Nothing else re-drives it, so the run is skipped on every subsequent restart. reenqueueActiveRuns recovered it.

Verified against this branch: run in running, one hook row, no steps/waits → reenqueueActiveRuns enqueues 1, reenqueueRecoverableRuns enqueues 0.

Waits don't have this failure mode because waits.status flips to completed, which lands in the fail-open branch below. Hooks do, because row deletion is the only transition. Issue #3119's own repro describes the parked fixture as having "no received hook delivery" — that precondition isn't checkable from these tables. Classifying from the event log the way openHookAndWaitState() in packages/core/src/runtime.ts does (hook_created without hook_disposed, and no dangling hook_received), or adding a delivered marker to the hook row, would close it.

await enqueue(
queueName,
{ runId: run.runId },
{ idempotencyKey: startupRecoveryIdempotencyKey(run.runId) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Attaching an idempotency key to a workflow re-trigger message opts it out of the per-run replay serialization. In packages/world-postgres/src/queue.ts, createTaskHandler only consults workflowRunSerializationKey (workflow:${runId}, set for any workflow message without a stepId — i.e. exactly this { runId } payload) inside the if (!idempotencyKey) branch, with the comment "prevent two workflow replays from mutating the same run's event log at the same time". Keyed messages skip straight past it. Startup is precisely when other pending jobs for the same run are also being picked up.

Verified by adapting the existing serializes workflow queue execution for the same runId test in queue.test.ts: two { runId } messages for the same run, the second carrying startup-recovery:<runId>maxActiveRequests is 2. The unmodified test (no key) asserts 1. Two concurrent replays of one run's event log.

Two further consequences of the same key:

  • completedMessages is an in-memory LRU keyed on the idempotency key, so within one process a second recovery attempt for a run is silently dropped rather than executed.
  • The key propagates into messageData.idempotencyKey and is reused as jobKey when the handler reschedules (queue.ts ~L555). graphile-worker's default replace mode means a later addJob with the same key overwrites the outstanding job's run_at — so a recovery enqueue and a suspended run's own future wake-up job can clobber each other, in either direction depending on ordering.

If the intent is only "don't accumulate duplicate recovery jobs across restarts", the queue's own per-run serialization already collapses redundant replays, and the event log makes duplicate replays harmless. Worth considering leaving the payload key-less, or threading a job key that doesn't route the message through the idempotency branch.

* @param enqueue - Queue's enqueue method
* @param label - Log prefix identifying the world implementation
* @param namespace - Optional queue namespace. Defaults to WORKFLOW_QUEUE_NAMESPACE.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

This doc block documents reenqueueRecoverableRuns (@param runs, @param drizzle, …) but sits immediately above recoverPage's own doc block, so recoverPage ends up with two comments and the exported function with none. Looks like a copy/paste — move it down to the export.

* and suspending, so it must be replayed to continue; we fail open for
* this ambiguous case because the enqueue is deduplicated.
*
* A run counts as parked (skipped) when its only live state is open hooks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

On the fix as a whole: the problem is real (#3119 — restart storms re-enqueue every suspended run, and duplicate recovery jobs pile up), the page-at-a-time classifier with three batched queries is the right shape, and classifyPageSafe failing open is a good call. The tests are thorough for the classifier itself (22 pass locally).

What makes it risky as written is that both mechanisms are inferred from entity tables that don't carry enough state to distinguish "parked" from "lost wake-up" (see the hooks comment), and the dedupe rides on the queue's idempotency channel, which already has other semantics attached (see the enqueue comment). A narrower first step that only skips runs with a futurewaits.resumeAt — the case where the wake-up job is genuinely persisted in the same database and provably survives the restart — would get most of the benefit with none of the wedge risk, and could be extended to hooks once hook rows can express delivery state.

Sign up for freeto 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.

world-postgres: startup recovery re-enqueues parked runs and accumulates duplicate jobs

2 participants

@Mohith26@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); fix(world-postgres): skip parked runs and dedupe recovery jobs on startup by Mohith26 · Pull Request #3162 · vercel/workflow · GitHub
Skip to content

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup - #3162

Open
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs
Open

fix(world-postgres): skip parked runs and dedupe recovery jobs on startup#3162
Mohith26 wants to merge 2 commits into
vercel:mainfrom
Mohith26:fix/world-postgres-recovery-parked-runs

Conversation

@Mohith26

Copy link
Copy Markdown

Fixes#3119, reported by @Jiarui-Ni.

Startup recovery re-enqueued every non-terminal run, including parked runs, and enqueued recovery jobs without a stable job key. Two problems followed: parked runs were woken up when the world restarted, and repeated boots accumulated duplicate recovery jobs for the same run.

Fix: skip parked runs during startup recovery, and enqueue recovery jobs with a stable startup-recovery:<runId> job key so graphile-worker dedupes them across boots. Includes a changeset (patch for @workflow/world-postgres).

All 38 world-postgres tests pass locally; the new parked-run and dedupe tests fail without the fix.

…very
Startup recovery previously re-enqueued every pending/running run via the
generic reenqueueActiveRuns helper, replaying runs that are durably parked
on unresolved hooks or not-yet-due waits, and minting a fresh graphile job
key per boot so repeated restarts accumulated duplicate outstanding jobs.
Replace it with a Postgres-specific reenqueueRecoverableRuns that:
- classifies running runs against persisted state (steps/waits/hooks) and
skips runs whose only live state is open hooks and/or waits that are not
due yet - their wake-up jobs live durably in the same database and
survive restarts
- still recovers runs with interrupted (non-terminal) step work, due
waits, and unclassifiable runs with no persisted suspension state
(fail open)
- attaches a stable per-run idempotency key (startup-recovery:<runId>),
which the queue uses as the graphile-worker job_key, so repeated boots
replace the outstanding recovery job instead of adding another, without
suppressing later legitimate wakes
Fixesvercel#3119
Add unit tests for reenqueueRecoverableRuns (hook-parked, future-wait and
indefinite-wait runs skipped; due waits, interrupted steps, pending and
ambiguous runs recovered; stable idempotency key across repeated boots;
fail-open on classification errors) and extend the createWorld() startup
tests to assert parked runs are skipped end-to-end and that the recovery
enqueue uses the stable startup-recovery:<runId> graphile job key on every
boot.
@Mohith26
Mohith26 requested review from a team and ijjk as code ownersJuly 28, 2026 18:42
@changeset-bot

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9603206

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

This PR includes changesets to release 1 package
NameType
@workflow/world-postgresPatch

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

@vercel

vercelBot commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

@Mohith26 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@VaguelySerious

Copy link
Copy Markdown
Member

@Mohith26 We require commits to be signed. Could you squash+sign the PR and force-push the branch? Separate review following soon.

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI review: blocking issues found

runnable.add(runId);
continue;
}
if (openHookRunIds.has(runId) || waitingRunIds.has(runId)) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

This treats "a hook row exists" as "durably parked", but the hooks table carries no per-delivery state: the row is written at hook_created and removed only at hook_disposed (see packages/world-postgres/src/drizzle/schema.ts — no status column). In packages/core/src/runtime/resume-hook.ts the hook_received event is awaited before the workflow re-trigger is enqueued. A crash or lost enqueue between those two writes leaves exactly the state this branch classifies as parked: an undisposed hook row, no runnable step, no due wait — but with a payload already recorded that nothing will ever act on. Nothing else re-drives it, so the run is skipped on every subsequent restart. reenqueueActiveRuns recovered it.

Verified against this branch: run in running, one hook row, no steps/waits → reenqueueActiveRuns enqueues 1, reenqueueRecoverableRuns enqueues 0.

Waits don't have this failure mode because waits.status flips to completed, which lands in the fail-open branch below. Hooks do, because row deletion is the only transition. Issue #3119's own repro describes the parked fixture as having "no received hook delivery" — that precondition isn't checkable from these tables. Classifying from the event log the way openHookAndWaitState() in packages/core/src/runtime.ts does (hook_created without hook_disposed, and no dangling hook_received), or adding a delivered marker to the hook row, would close it.

await enqueue(
queueName,
{ runId: run.runId },
{ idempotencyKey: startupRecoveryIdempotencyKey(run.runId) }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Blocking

Attaching an idempotency key to a workflow re-trigger message opts it out of the per-run replay serialization. In packages/world-postgres/src/queue.ts, createTaskHandler only consults workflowRunSerializationKey (workflow:${runId}, set for any workflow message without a stepId — i.e. exactly this { runId } payload) inside the if (!idempotencyKey) branch, with the comment "prevent two workflow replays from mutating the same run's event log at the same time". Keyed messages skip straight past it. Startup is precisely when other pending jobs for the same run are also being picked up.

Verified by adapting the existing serializes workflow queue execution for the same runId test in queue.test.ts: two { runId } messages for the same run, the second carrying startup-recovery:<runId>maxActiveRequests is 2. The unmodified test (no key) asserts 1. Two concurrent replays of one run's event log.

Two further consequences of the same key:

  • completedMessages is an in-memory LRU keyed on the idempotency key, so within one process a second recovery attempt for a run is silently dropped rather than executed.
  • The key propagates into messageData.idempotencyKey and is reused as jobKey when the handler reschedules (queue.ts ~L555). graphile-worker's default replace mode means a later addJob with the same key overwrites the outstanding job's run_at — so a recovery enqueue and a suspended run's own future wake-up job can clobber each other, in either direction depending on ordering.

If the intent is only "don't accumulate duplicate recovery jobs across restarts", the queue's own per-run serialization already collapses redundant replays, and the event log makes duplicate replays harmless. Worth considering leaving the payload key-less, or threading a job key that doesn't route the message through the idempotency branch.

* @param enqueue - Queue's enqueue method
* @param label - Log prefix identifying the world implementation
* @param namespace - Optional queue namespace. Defaults to WORKFLOW_QUEUE_NAMESPACE.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Nit

This doc block documents reenqueueRecoverableRuns (@param runs, @param drizzle, …) but sits immediately above recoverPage's own doc block, so recoverPage ends up with two comments and the exported function with none. Looks like a copy/paste — move it down to the export.

* and suspending, so it must be replayed to continue; we fail open for
* this ambiguous case because the enqueue is deduplicated.
*
* A run counts as parked (skipped) when its only live state is open hooks

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

AI Review: Note

On the fix as a whole: the problem is real (#3119 — restart storms re-enqueue every suspended run, and duplicate recovery jobs pile up), the page-at-a-time classifier with three batched queries is the right shape, and classifyPageSafe failing open is a good call. The tests are thorough for the classifier itself (22 pass locally).

What makes it risky as written is that both mechanisms are inferred from entity tables that don't carry enough state to distinguish "parked" from "lost wake-up" (see the hooks comment), and the dedupe rides on the queue's idempotency channel, which already has other semantics attached (see the enqueue comment). A narrower first step that only skips runs with a futurewaits.resumeAt — the case where the wake-up job is genuinely persisted in the same database and provably survives the restart — would get most of the benefit with none of the wedge risk, and could be extended to hooks once hook rows can express delivery state.

Sign up for freeto 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.

world-postgres: startup recovery re-enqueues parked runs and accumulates duplicate jobs

2 participants

@Mohith26@VaguelySerious