Skip to content

Commit 6c6c939

Browse files
committed
fix(webapp): retry the snapshot sweep lock release so a transient Redis fault does not stall sweeps
The sweep lock release is a write, so the same Redis brownout that fails a sweep pass also fails the release, and a single best-effort attempt left the lock held for its full TTL (sweep budget plus two hours). One transient blip could pause orphan sweeping across the fleet for hours. The release now retries over a short bounded window, so a Redis that recovers within seconds frees the lock promptly; the TTL remains only as the last-resort backstop.
1 parent ec4bf3f commit 6c6c939

2 files changed

Lines changed: 94 additions & 3 deletions

File tree

‎apps/webapp/app/v3/snapshotSweepRunner.server.ts‎

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,47 @@ end
1313
return 0
1414
`;
1515

16+
// The release is a WRITE, so the fault that fails a pass fails the release too; a single attempt
17+
// then leaks the lock to its full TTL (budget + 2h), stalling every sweep for hours after a blip.
18+
// Retry over a short window so a Redis that recovers frees the lock promptly; the TTL is the backstop.
19+
const DEFAULT_RELEASE_RETRY_DELAYS_MS = [250, 500, 1000, 2000, 4000, 8000];
20+
21+
const sleep = (ms: number) =>
22+
ms > 0 ? new Promise<void>((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
23+
1624
type SweepCounts = Record<string, number | boolean>;
1725

26+
async function releaseLock(
27+
client: RedisClient,
28+
fence: string,
29+
retryDelaysMs: number[]
30+
): Promise<void> {
31+
// attempts = retryDelaysMs.length + 1. A successful eval ends it, whether it deleted our lock
32+
// (returned 1) or found the fence no longer ours (returned 0) — both mean nothing more to do.
33+
for (let attempt = 0; ; attempt++) {
34+
try {
35+
await client.eval(RELEASE_LUA, 1, LOCK_KEY, fence);
36+
return;
37+
} catch (error) {
38+
if (attempt >= retryDelaysMs.length) {
39+
logger.warn(
40+
"snapshot sweep lock release failed after retries; lock will clear on its TTL",
41+
{ error }
42+
);
43+
return;
44+
}
45+
await sleep(retryDelaysMs[attempt]);
46+
}
47+
}
48+
}
49+
1850
export function buildSnapshotSweepRunner(deps: {
1951
client: RedisClient;
2052
sweep: (opts: { deadline: number; signal: AbortSignal }) => Promise<SweepCounts>;
2153
lockTtlMs: number;
2254
fence?: () => string;
55+
/** Backoff between release attempts. Defaults to a bounded ~16s ramp; tests pass short delays. */
56+
releaseRetryDelaysMs?: number[];
2357
}): SweepRunner {
2458
return async ({ deadline, signal }): Promise<SweepPassOutcome> => {
2559
// enqueueOnce gives no overlap protection: its dedup record IS the queue item and the ack
@@ -50,9 +84,11 @@ export function buildSnapshotSweepRunner(deps: {
5084
logger.error("snapshot orphan sweep pass failed", { error });
5185
return { outcome: "failed" };
5286
} finally {
53-
await deps.client
54-
.eval(RELEASE_LUA, 1, LOCK_KEY, fence)
55-
.catch((error) => logger.warn("snapshot sweep lock release failed", { error }));
87+
await releaseLock(
88+
deps.client,
89+
fence,
90+
deps.releaseRetryDelaysMs ?? DEFAULT_RELEASE_RETRY_DELAYS_MS
91+
);
5692
}
5793
};
5894
}

‎apps/webapp/test/snapshotSweepRunner.test.ts‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,3 +128,58 @@ redisTest("reports aborted when shutdown cancels the pass", async ({ redisOption
128128
await client.quit();
129129
}
130130
});
131+
132+
function flakyEvalClient(real: RedisClient, failEvalTimes: number): RedisClient {
133+
let fails = 0;
134+
return new Proxy(real, {
135+
get(target, prop, receiver) {
136+
if (prop === "eval") {
137+
return (...args: unknown[]) => {
138+
if (fails < failEvalTimes) {
139+
fails += 1;
140+
return Promise.reject(new Error("simulated release eval failure"));
141+
}
142+
return (target.eval as (...a: unknown[]) => unknown)(...args);
143+
};
144+
}
145+
const value = Reflect.get(target, prop, receiver);
146+
return typeof value === "function" ? value.bind(target) : value;
147+
},
148+
}) as unknown as RedisClient;
149+
}
150+
151+
redisTest("retries a transiently-failing release and frees the lock", async ({ redisOptions }) => {
152+
const real = createRedisClient(redisOptions);
153+
const client = flakyEvalClient(real, 2); // first two release attempts fail, third succeeds
154+
try {
155+
const runner = buildSnapshotSweepRunner({
156+
client,
157+
sweep: async () => CLEAN,
158+
lockTtlMs: 60_000,
159+
releaseRetryDelaysMs: [0, 0, 0],
160+
});
161+
162+
expect((await runner(opts())).outcome).toBe("completed");
163+
expect(await real.get(LOCK_KEY)).toBeNull(); // released after the retries, not left to TTL
164+
} finally {
165+
await real.quit();
166+
}
167+
});
168+
169+
redisTest("leaves the lock to its TTL only when every release attempt fails", async ({ redisOptions }) => {
170+
const real = createRedisClient(redisOptions);
171+
const client = flakyEvalClient(real, 999); // release can never succeed
172+
try {
173+
const runner = buildSnapshotSweepRunner({
174+
client,
175+
sweep: async () => CLEAN,
176+
lockTtlMs: 60_000,
177+
releaseRetryDelaysMs: [0],
178+
});
179+
180+
expect((await runner(opts())).outcome).toBe("completed"); // runner never throws on a release failure
181+
expect(await real.get(LOCK_KEY)).not.toBeNull(); // still held; the TTL is the backstop
182+
} finally {
183+
await real.quit();
184+
}
185+
});

0 commit comments

Comments
 (0)