@@ -38,6 +38,7 @@ export class EnqueueSystem {
3838 workerId,
3939 runnerId,
4040 skipRunLock,
41+ armPublishGuard = false ,
4142 includeTtl = false ,
4243 anchorEligibilityAtQueuePosition = false ,
4344 enableFastPath = false ,
@@ -61,6 +62,17 @@ export class EnqueueSystem {
6162 workerId ?: string ;
6263 runnerId ?: string ;
6364 skipRunLock ?: boolean ;
65+ /**
66+ * Arm the write-ahead publish guard before the snapshot write (see below). Only the waitpoint
67+ * SUSPENDED->QUEUED resume sets this: it publishes with default options (so the guard's option-less
68+ * replay is faithful) AND the run was SUSPENDED with its concurrency already released, which the
69+ * guard's in-flight check relies on to tell a lost publish from a live run. The delayed and
70+ * pending-version enqueues do NOT set it (they need `includeTtl`/`anchorEligibilityAtQueuePosition`
71+ * the guard can't reconstruct); the checkpoint re-queue does NOT either (it releases concurrency
72+ * after enqueue, so a lost publish there would leave a claim the in-flight check can't distinguish).
73+ * Default false. Still gated by the runtime blip-retry flag.
74+ */
75+ armPublishGuard ?: boolean ;
6476 /**
6577 * When true, arm the run's TTL on the queued message. Set by every path that is the run's
6678 * first real entry into the queue: trigger, a delayed run coming due, and the pending-version
@@ -95,8 +107,10 @@ export class EnqueueSystem {
95107 // Write-ahead publish guard: the snapshot write (Postgres) and the queue publish (Redis) are
96108 // not atomic, so a lost publish would leave a QUEUED run absent from the queue. When enabled,
97109 // pre-mint the snapshot id, arm the guard keyed by it BEFORE the snapshot write, and ack it
98- // only after the publish succeeds; the guard replays the publish idempotently otherwise.
99- const armGuard = await this . $ . isBlipRetryEnabled ( ) ;
110+ // only after the publish succeeds; the guard replays the publish idempotently otherwise. Scoped
111+ // to the resume re-enqueues (armPublishGuard) so it never has to reconstruct publish options it
112+ // wasn't given; the flag check is skipped entirely when not armed, so other paths do no extra work.
113+ const armGuard = armPublishGuard ? await this . $ . isBlipRetryEnabled ( ) : false ;
100114 const snapshotId = armGuard ? SnapshotId . generate ( ) . id : undefined ;
101115 if ( armGuard && snapshotId ) {
102116 await this . #scheduleRunPublishedGuard( run . id , snapshotId ) ;
@@ -134,35 +148,53 @@ export class EnqueueSystem {
134148 enableFastPath,
135149 } ) ;
136150
137- if ( armGuard ) {
138- await this . #ackRunPublishedGuard( run . id ) ;
151+ if ( armGuard && snapshotId ) {
152+ await this . #ackRunPublishedGuard( run . id , snapshotId ) ;
139153 }
140154
141155 return newSnapshot ;
142156 } ) ;
143157 }
144158
145- #runPublishedGuardId( runId : string ) : string {
146- return `ensureRunPublished:${ runId } ` ;
159+ // Keyed by BOTH run id and snapshot id: a run goes through many QUEUED transitions over its life,
160+ // so a run-only key would let a stale guard from an earlier transition block enqueueOnce for the
161+ // next one, leaving the newer transition unprotected.
162+ #runPublishedGuardId( runId : string , snapshotId : string ) : string {
163+ return `ensureRunPublished:${ runId } :${ snapshotId } ` ;
147164 }
148165
149166 async #scheduleRunPublishedGuard( runId : string , snapshotId : string ) : Promise < void > {
150167 await this . $ . worker . enqueueOnce ( {
151- id : this . #runPublishedGuardId( runId ) ,
168+ id : this . #runPublishedGuardId( runId , snapshotId ) ,
152169 job : "ensureRunPublished" ,
153170 payload : { runId, snapshotId } ,
154171 availableAt : new Date ( Date . now ( ) + this . $ . guardDelayMs ) ,
155172 } ) ;
156173 }
157174
158- async #ackRunPublishedGuard( runId : string ) : Promise < void > {
159- await this . $ . worker . ack ( this . #runPublishedGuardId( runId ) ) ;
175+ async #ackRunPublishedGuard( runId : string , snapshotId : string ) : Promise < void > {
176+ await this . $ . worker . ack ( this . #runPublishedGuardId( runId , snapshotId ) ) ;
160177 }
161178
162179 /**
163- * Redelivery handler for the publish guard: re-publishes a run whose snapshot committed but whose
164- * queue publish was lost. Idempotent — publishRun/enqueueMessage dedupes on run id — and a no-op
165- * once the snapshot is superseded (already dequeued/executing) so it never re-queues a live run.
180+ * Redelivery handler for the publish guard: re-publishes a resume-enqueued run whose snapshot
181+ * committed but whose queue publish was lost. It re-publishes ONLY when the run is genuinely absent
182+ * from the queue, which two checks establish together:
183+ * 1. `snapshotId` is still the latest snapshot AND is QUEUED. Once a consumer dequeues the run the
184+ * snapshot moves off QUEUED, so a lost ack after a successful publish is a no-op here.
185+ * 2. `messageInFlight` is false. While the snapshot still reads QUEUED the message may already be
186+ * live in the queue: either waiting in the sorted set, or dispatched to a worker queue (where it
187+ * holds a concurrency claim) but not yet consumed. Re-publishing in that window would BOTH add a
188+ * duplicate queue entry AND strip the live run's concurrency claim (the enqueue Lua SREMs it), so
189+ * we must skip. The guard is scoped to resume enqueues, and a suspend releases all concurrency,
190+ * so a genuinely-lost resume-publish holds no claim and correctly re-publishes.
191+ * We deliberately do NOT gate on the message key existing: it is written at trigger and lives for the
192+ * whole run lifecycle (deleted only on ack/TTL-expiry), so it is present for every resume.
193+ *
194+ * The whole decide-then-publish runs under the SAME run lock as `enqueueRun` (keyed by runId), so the
195+ * original resume publisher and this guard cannot both observe "absent" and publish across a
196+ * queue-dispatch transition: whichever runs second acquires the lock only after the first has finished
197+ * publishing, sees the message in-flight, and skips.
166198 */
167199 public async ensureRunPublished ( {
168200 runId,
@@ -171,21 +203,33 @@ export class EnqueueSystem {
171203 runId : string ;
172204 snapshotId : string ;
173205 } ) : Promise < void > {
174- const run = await this . $ . runStore . findRun ( { id : runId } , this . $ . prisma ) ;
175- if ( ! run ) {
176- return ;
177- }
178- const latest = await getLatestExecutionSnapshot ( this . $ . prisma , runId , this . $ . runStore ) ;
179- if ( latest . id !== snapshotId || latest . executionStatus !== "QUEUED" ) {
180- // Superseded (already dequeued/executing) or a different transition owns the run now.
181- return ;
182- }
183- const env = await this . $ . controlPlaneResolver . resolveEnv ( run . runtimeEnvironmentId ) ;
184- if ( ! env ) {
185- this . $ . logger . error ( "ensureRunPublished: environment not found" , { runId } ) ;
186- return ;
187- }
188- await this . publishRun ( { run, env } ) ;
206+ await this . $ . runLock . lock ( "ensureRunPublished" , [ runId ] , async ( ) => {
207+ const run = await this . $ . runStore . findRun ( { id : runId } , this . $ . prisma ) ;
208+ if ( ! run ) {
209+ return ;
210+ }
211+ const latest = await getLatestExecutionSnapshot ( this . $ . prisma , runId , this . $ . runStore ) ;
212+ if ( latest . id !== snapshotId || latest . executionStatus !== "QUEUED" ) {
213+ // Superseded (already dequeued/executing) or a different transition owns the run now.
214+ return ;
215+ }
216+ const env = await this . $ . controlPlaneResolver . resolveEnv ( run . runtimeEnvironmentId ) ;
217+ if ( ! env ) {
218+ this . $ . logger . error ( "ensureRunPublished: environment not found" , { runId } ) ;
219+ return ;
220+ }
221+ const inFlight = await this . $ . runQueue . messageInFlight (
222+ env ,
223+ run . queue ,
224+ run . id ,
225+ run . concurrencyKey ?? undefined
226+ ) ;
227+ if ( inFlight ) {
228+ // Already waiting or dispatched; re-publishing would duplicate it and strip its concurrency.
229+ return ;
230+ }
231+ await this . publishRun ( { run, env } ) ;
232+ } ) ;
189233 }
190234
191235 /**
0 commit comments