Skip to content

Commit 4d61ca6

Browse files
committed
fix(run-engine,webapp): harden waitpoint blip-resilience guards after review
Make the write-ahead guards for waitpoint completion safe across every queue stage. - The publish guard re-publishes a run only when it is genuinely absent from the queue (checking the sorted set and the run's concurrency claim), so it can no longer duplicate a run already dispatched to a worker or strip a live run's concurrency. Its decide-then-publish runs under the same run lock as the original enqueue, so the guard and the resume publisher can never both publish across a dispatch. - Interrupted-resume recovery now clears leaked blocking edges and re-installs the run's heartbeat, so a resume that committed but died before finishing can no longer strand the run or leave it without a stall watchdog. - The publish guard is scoped to the waitpoint resume path. - The blip-retry flag is read synchronously from an in-memory poller warmed at startup, so the completion path does no database work and adds no latency when the flag is off.
1 parent 383f3fa commit 4d61ca6

10 files changed

Lines changed: 559 additions & 121 deletions

File tree

apps/webapp/app/entry.server.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { initMollifierStaleSweepWorker } from "~/v3/mollifierStaleSweepWorker.se
1212
import { initBillingLimitWorker } from "~/v3/billingLimitWorker.server";
1313
import { initLogsSearchProjectorWorker } from "~/v3/logsSearchProjectorWorker.server";
1414
import { initQueueMetricsConsumer, initQueueMetricsEmitter } from "~/v3/queueMetrics.server";
15+
import { startRunStoreInfraRetryFlagPoller } from "~/v3/runStoreInfraRetryFlag.server";
1516
import { bootstrap } from "./bootstrap";
1617
import { LocaleContextProvider } from "./components/primitives/LocaleProvider";
1718
import type { OperatingSystemPlatform } from "./components/primitives/OperatingSystemProvider";
@@ -282,6 +283,9 @@ initLogsSearchProjectorWorker();
282283
initQueueMetricsEmitter();
283284
initQueueMetricsConsumer();
284285

286+
// Warm the blip-retry flag poller at startup so the operation-path getter stays purely synchronous.
287+
startRunStoreInfraRetryFlagPoller();
288+
285289
bootstrap().catch((error) => {
286290
logError(error);
287291
});

apps/webapp/app/v3/featureFlags.server.ts

Lines changed: 3 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -65,20 +65,12 @@ export function makeFlag(_prisma: PrismaClientOrTransaction = prisma) {
6565
}
6666

6767
const cachedFlagStore = new Map<string, { value: unknown; expiresAt: number }>();
68-
// In-flight refreshes, keyed like cachedFlagStore, so concurrent callers on a cold/expired entry
69-
// share ONE database read instead of stampeding it (every ttl window, or repeatedly while it fails).
70-
const cachedFlagInFlight = new Map<string, Promise<unknown>>();
7168

7269
/**
7370
* flag() behind a short process-level TTL cache, for global flags read on hot
7471
* paths (e.g. the root loader) where a database round-trip per request is too
7572
* expensive. Flips propagate within ttlMs per process. Overrides are rejected
7673
* by the type: a scoped resolution must never be reused across scopes.
77-
*
78-
* Failure-safe: if the underlying read throws (e.g. the control-plane DB is
79-
* blipping), it never throws to the caller — it serves the last cached value if
80-
* there is one, otherwise `defaultValue`. A read on hot paths must not turn a DB
81-
* blip into a 5xx, and a caller that gates behavior on this must degrade safely.
8274
*/
8375
export async function cachedFlag<T extends FeatureFlagKey>(
8476
opts: Omit<FlagsOptions<T>, "overrides"> & {
@@ -93,27 +85,9 @@ export async function cachedFlag<T extends FeatureFlagKey>(
9385
return hit.value as z.infer<(typeof FeatureFlagCatalog)[T]>;
9486
}
9587

96-
const existing = cachedFlagInFlight.get(cacheKey);
97-
if (existing) {
98-
return existing as Promise<z.infer<(typeof FeatureFlagCatalog)[T]>>;
99-
}
100-
101-
const refresh = (async () => {
102-
try {
103-
const value = await flag(opts);
104-
cachedFlagStore.set(cacheKey, { value, expiresAt: Date.now() + ttlMs });
105-
return value;
106-
} catch {
107-
// Serve the stale value if we have one, otherwise the default. Do not cache the failure, so
108-
// the next call retries the refresh (single-flighted).
109-
return hit ? (hit.value as z.infer<(typeof FeatureFlagCatalog)[T]>) : opts.defaultValue;
110-
} finally {
111-
cachedFlagInFlight.delete(cacheKey);
112-
}
113-
})();
114-
115-
cachedFlagInFlight.set(cacheKey, refresh);
116-
return refresh as Promise<z.infer<(typeof FeatureFlagCatalog)[T]>>;
88+
const value = await flag(opts);
89+
cachedFlagStore.set(cacheKey, { value, expiresAt: Date.now() + ttlMs });
90+
return value;
11791
}
11892

11993
export function makeSetFlag(_prisma: PrismaClientOrTransaction = prisma) {

apps/webapp/app/v3/runStoreInfraRetryFlag.server.ts

Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,48 +3,69 @@ import { logger } from "~/services/logger.server";
33
import { flag } from "./featureFlags.server";
44

55
// The run-store blip-retry gate, resolved from the global `runStoreInfraRetryEnabled` flag on a
6-
// background timer and cached in memory. The store consults this synchronously per operation, so:
7-
// - it does NO database read on the hot path — a connection blip can never turn the gate itself
8-
// into a failure or disable resilience mid-blip (the last value is held in memory), and
9-
// - a failing flag read never stampedes the control-plane pool (one timer, not one query per op).
10-
// A failed refresh keeps the last known value; the only cold window is a brand-new process whose
11-
// first refresh has not completed, which resolves to the safe `false`.
6+
// background timer and held in memory. The store consults it SYNCHRONOUSLY per operation, so:
7+
// - the operation path does ZERO database work and never awaits — a blip can neither add latency
8+
// nor turn the gate itself into a failure or disable resilience mid-blip (the last value is held),
9+
// - a failing refresh keeps the last known value, and
10+
// - a single background timer (not one query per op) never stampedes the control-plane pool.
11+
// The poller is warmed at process startup (see startRunStoreInfraRetryFlagPoller). The only cold
12+
// window is a brand-new process before its first read completes, which safely reads `false`.
1213

1314
const REFRESH_INTERVAL_MS = 30_000;
1415

1516
let current = false;
16-
let initPromise: Promise<void> | null = null;
17+
let started = false;
18+
let warmup: Promise<void> | null = null;
1719

18-
async function refresh(): Promise<void> {
20+
async function refresh(): Promise<boolean> {
1921
try {
2022
current = await flag({ key: FEATURE_FLAG.runStoreInfraRetryEnabled, defaultValue: false });
23+
return true;
2124
} catch (error) {
2225
// Keep the last known value: a flag-store blip must not flip resilience on or off.
2326
logger.debug("runStoreInfraRetry flag refresh failed; keeping last value", {
2427
error: error instanceof Error ? error.message : String(error),
2528
});
29+
return false;
2630
}
2731
}
2832

29-
function ensureStarted(): Promise<void> {
30-
if (initPromise) {
31-
return initPromise;
33+
async function initialRead(): Promise<void> {
34+
// Bounded retry of the FIRST read so a brief blip at startup doesn't leave resilience off until the
35+
// next tick. Never on the operation path. If the store is unreachable the whole window, `current`
36+
// stays at the safe default (false) and self-heals on a later tick.
37+
for (let attempt = 0; attempt < 5; attempt++) {
38+
if (await refresh()) {
39+
return;
40+
}
41+
await new Promise((resolve) => setTimeout(resolve, 200));
3242
}
33-
// One initial read, shared by all early callers, so a globally-enabled flag is effective from the
34-
// FIRST operation of a fresh process (not only after the first background tick). Later calls await
35-
// this already-resolved promise and return the in-memory value with no further database work.
36-
initPromise = refresh();
43+
}
44+
45+
/**
46+
* Start the background poller once (idempotent). Call at process startup to warm the gate before
47+
* traffic. It kicks off the initial read and the refresh timer WITHOUT blocking; the returned promise
48+
* resolves when the first read settles, so a caller MAY await readiness at boot, but the operation
49+
* path never does.
50+
*/
51+
export function startRunStoreInfraRetryFlagPoller(): Promise<void> {
52+
if (started) {
53+
return warmup ?? Promise.resolve();
54+
}
55+
started = true;
56+
warmup = initialRead();
3757
const timer = setInterval(() => void refresh(), REFRESH_INTERVAL_MS);
3858
timer.unref?.(); // don't keep the process alive for the poll timer
39-
return initPromise;
59+
return warmup;
4060
}
4161

4262
/**
43-
* The run-store blip-retry gate. The first call awaits a single initial read (so op #1 sees the real
44-
* flag); every later call resolves from the in-memory value with no database work — so the gate does
45-
* no per-op DB read, survives a blip (last value held), and never stampedes the control-plane pool.
63+
* The run-store blip-retry gate: a PURELY SYNCHRONOUS, database-free read of the in-memory value the
64+
* background poller maintains. It never awaits and does no database work, so it adds no latency and
65+
* cannot itself fail or hang during a blip. It only ensures the poller is running (non-blocking) as a
66+
* fallback for entrypoints that skip the explicit startup warm.
4667
*/
47-
export async function isRunStoreInfraRetryEnabled(): Promise<boolean> {
48-
await ensureStarted();
68+
export function isRunStoreInfraRetryEnabled(): boolean {
69+
startRunStoreInfraRetryFlagPoller();
4970
return current;
5071
}

internal-packages/run-engine/src/engine/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ import pMap from "p-map";
107107
export class RunEngine {
108108
private runLockRedis: Redis;
109109
private runLock: RunLocker;
110-
private worker: EngineWorker;
110+
worker: EngineWorker;
111111
private ttlWorker: Worker<ReturnType<typeof createTtlWorkerCatalog>>;
112112
private logger: Logger;
113113
private tracer: Tracer;

internal-packages/run-engine/src/engine/systems/enqueueSystem.ts

Lines changed: 71 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)