Skip to content

Commit 7a6541e

Browse files
committed
fix(run-store): stop serving a short history from a keyspace that lost an append
The repair restores the head but not the entries lost with it, so a keyspace ends up holed with a correct head. At dual-write that is invisible and harmless. At redis-read the window read serves a range straight from Redis, and its guards see a miss and a dangling cycle but cannot see a HOLE, so a window that should hold eight entries returns four with nothing logged. A history that is short rather than wrong is the harder kind to notice. The keyspace now records that its history is untrustworthy and both window commands refuse, which routes the caller through its existing miss path to Postgres. Point reads are left alone, because the repair does guarantee the head converges and refusing those would send every transition of a once-forked run to Postgres for the rest of its life. Backfilling instead would be worse. A late append takes a fresh sequence number, and the window scripts walk the index in sequence order as though it were time order, so an old entry with a high sequence truncates the window harder than the hole does. A fork sets the marker itself, and so do the repair's early exits. The head converging on its own is exactly the case that hid this: four entries against eight, with a matching head, and nothing to say so. The marker is a field on the seq hash, so the keyspace expiry governs it and it is only ever set on a keyspace that already exists.
1 parent 653dd94 commit 7a6541e

4 files changed

Lines changed: 288 additions & 3 deletions

File tree

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
// A5. The repair restores the head but not the entries lost in the fork window, so a keyspace ends up
2+
// with a hole in the middle and a correct head. That is invisible at dual-write, where Postgres is
3+
// authoritative and the engine reads the head. It is not invisible at redis-read: the window read
4+
// serves a since-createdAt range straight from Redis, and its guards (a miss, a dangling cycle)
5+
// cannot see a HOLE, so a window that should hold eight entries returns four with nothing logged. A
6+
// history that is short rather than wrong is the harder kind to notice.
7+
//
8+
// Backfilling the lost entries is NOT the fix and would be worse. A late append takes a fresh seq
9+
// from HINCRBY, and both window scripts walk the index in seq order treating it as time order,
10+
// stopping at the first entry past the cursor. A backfilled old entry with a high seq would truncate
11+
// the window harder than the hole does.
12+
//
13+
// So the keyspace records that its history is untrustworthy and windows refuse, which routes the
14+
// caller through its existing miss path to Postgres. Point reads stay Redis-served, because the
15+
// repair does guarantee the head converges.
16+
import { describe, expect } from "vitest";
17+
import { redisTest } from "@internal/testcontainers";
18+
import { RedisSnapshotStore, snapshotKeys } from "./redisSnapshotStore.js";
19+
import type { SnapshotEntryInput } from "./redisSnapshotStore.js";
20+
import { createRedisClient } from "@internal/redis";
21+
22+
const COMPLETED_TTL_MS = 72 * 60 * 60 * 1000;
23+
24+
function entry(runId: string, id: string, createdAt: string): SnapshotEntryInput {
25+
return {
26+
id,
27+
engine: "V2",
28+
executionStatus: "EXECUTING",
29+
description: "d",
30+
runId,
31+
runStatus: "EXECUTING",
32+
createdAt,
33+
environmentId: "env_1",
34+
environmentType: "DEVELOPMENT",
35+
projectId: "proj_1",
36+
organizationId: "org_1",
37+
};
38+
}
39+
40+
const at = (seconds: number) => new Date(Date.UTC(2026, 0, 1, 0, 0, seconds)).toISOString();
41+
42+
async function seed(store: RedisSnapshotStore, runId: string, count: number): Promise<void> {
43+
for (let n = 0; n < count; n++) {
44+
await store.append({
45+
entry: entry(runId, `snap_${n}`, at(n)),
46+
kind: n === 0 ? "birth" : "transition",
47+
isTerminal: false,
48+
});
49+
}
50+
}
51+
52+
describe("the gaps marker", () => {
53+
redisTest(
54+
"is unset on a healthy keyspace, which still serves its window",
55+
async ({ redisOptions }) => {
56+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS });
57+
try {
58+
const runId = "run_healthy";
59+
await seed(store, runId, 5);
60+
61+
expect(await store.hasGaps(runId)).toBe(false);
62+
expect((await store.getSinceCreatedAt(runId, at(1))).kind).toBe("hit");
63+
expect((await store.getSince(runId, "snap_1")).kind).toBe("hit");
64+
} finally {
65+
await store.quit();
66+
}
67+
}
68+
);
69+
70+
redisTest("makes BOTH window reads refuse, so each falls back", async ({ redisOptions }) => {
71+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS });
72+
try {
73+
const runId = "run_holed";
74+
await seed(store, runId, 5);
75+
76+
// What a repair does when it lands: the head is right, the window is not to be trusted.
77+
await store.markGaps(runId);
78+
expect(await store.hasGaps(runId)).toBe(true);
79+
80+
// Both window commands, because a caller that fell back on one and not the other would still
81+
// serve a short history through the second.
82+
expect((await store.getSinceCreatedAt(runId, at(1))).kind).toBe("miss");
83+
expect((await store.getSince(runId, "snap_1")).kind).toBe("miss");
84+
} finally {
85+
await store.quit();
86+
}
87+
});
88+
89+
redisTest(
90+
"leaves point reads alone, because the repair converges the head",
91+
async ({ redisOptions }) => {
92+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS });
93+
try {
94+
const runId = "run_point";
95+
await seed(store, runId, 5);
96+
await store.markGaps(runId);
97+
98+
// The head is the engine's hot read and the repair guarantees it. Refusing it would send every
99+
// transition of a once-forked run to Postgres for the rest of its life.
100+
const head = await store.getLatest(runId);
101+
expect(head?.entry.id).toBe("snap_4");
102+
103+
const byId = await store.getById(runId, "snap_2");
104+
expect(byId?.entry.id).toBe("snap_2");
105+
} finally {
106+
await store.quit();
107+
}
108+
}
109+
);
110+
111+
redisTest(
112+
"is set by a fork, which is direct evidence of divergence",
113+
async ({ redisOptions }) => {
114+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS });
115+
try {
116+
const runId = "run_forked";
117+
await seed(store, runId, 3);
118+
119+
const result = await store.append({
120+
entry: entry(runId, "snap_late", at(9)),
121+
kind: "transition",
122+
isTerminal: false,
123+
expectedCur: "snap_wrong",
124+
});
125+
126+
expect(result.outcome).toBe("forked");
127+
// A fork means this keyspace and Postgres already disagree about the head, so whatever the
128+
// repair does later, the window between them is not trustworthy now.
129+
expect(await store.hasGaps(runId)).toBe(true);
130+
expect((await store.getSinceCreatedAt(runId, at(1))).kind).toBe("miss");
131+
} finally {
132+
await store.quit();
133+
}
134+
}
135+
);
136+
137+
redisTest(
138+
"dies with the keyspace rather than needing its own expiry",
139+
async ({ redisOptions }) => {
140+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS });
141+
const probe = createRedisClient(redisOptions, { onError: () => {} });
142+
try {
143+
const runId = "run_ttl";
144+
await seed(store, runId, 3);
145+
await store.markGaps(runId);
146+
147+
// The marker is a field on the seq hash, so the completion expiry that governs the keyspace
148+
// governs it too. No second lifetime to get wrong.
149+
expect(await probe.hget(snapshotKeys(runId).seq, "g")).toBe("1");
150+
await store.dropRun(runId);
151+
expect(await probe.exists(snapshotKeys(runId).seq)).toBe(0);
152+
} finally {
153+
await Promise.all([store.quit(), probe.quit().catch(() => {})]);
154+
}
155+
}
156+
);
157+
redisTest(
158+
"a lone seq key is never created for a run that has no keyspace",
159+
async ({ redisOptions }) => {
160+
// markGaps writes a field on the seq hash, and HSET creates the hash if it is absent. For a
161+
// run with no keyspace that would leave a stray seq key holding only the marker: keyspaceAlive
162+
// stays false so no read is affected, but the sweeper scans on the entry hash and would never
163+
// discover it, so it would never be reaped either. An unbounded leak with no reader.
164+
const store = new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS });
165+
const probe = createRedisClient(redisOptions, { onError: () => {} });
166+
try {
167+
const runId = "run_never_born";
168+
expect(await store.markGapsIfResident(runId)).toBe(false);
169+
expect(await probe.exists(snapshotKeys(runId).seq)).toBe(0);
170+
171+
await seed(store, runId, 2);
172+
expect(await store.markGapsIfResident(runId)).toBe(true);
173+
expect(await store.hasGaps(runId)).toBe(true);
174+
} finally {
175+
await Promise.all([store.quit(), probe.quit().catch(() => {})]);
176+
}
177+
}
178+
);
179+
});

internal-packages/run-store/src/redisSnapshotStore.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,36 @@ export class RedisSnapshotStore {
344344
return this.#breaker.state;
345345
}
346346

347+
/**
348+
* Records that this run's Redis history has a hole, so window reads must not serve it. Separate
349+
* from the append path because a repair can conclude the head is already current and still know
350+
* that entries were lost.
351+
*/
352+
async markGaps(runId: string): Promise<void> {
353+
await this.redis.hset(snapshotKeys(runId).seq, "g", "1");
354+
}
355+
356+
/**
357+
* Marks only a keyspace that exists, and reports whether it did.
358+
*
359+
* The unconditional form must not be used on a run whose residency is unknown: HSET creates the
360+
* hash, so a non-resident run would be left holding a lone `seq` key with nothing but the marker.
361+
* `keyspaceAlive` would stay false so no read would be affected, but the sweeper discovers
362+
* keyspaces by scanning for the ENTRY hash, so it would never find that key either. An unbounded
363+
* leak with no reader is the one outcome worse than the hole this marker exists to report.
364+
*/
365+
async markGapsIfResident(runId: string): Promise<boolean> {
366+
const k = snapshotKeys(runId);
367+
return this.#timed("markGapsIfResident", async () => {
368+
const marked = await this.redis.markSnapshotGaps(k.e, k.seq);
369+
return marked === 1;
370+
});
371+
}
372+
373+
async hasGaps(runId: string): Promise<boolean> {
374+
return (await this.redis.hget(snapshotKeys(runId).seq, "g")) === "1";
375+
}
376+
347377
/** Test seam. */
348378
residencyFor(runId: string): "resident" | "non-resident" | undefined {
349379
return this.#residency.get(runId);
@@ -365,6 +395,11 @@ export class RedisSnapshotStore {
365395
kind: "birth" | "transition";
366396
isTerminal: boolean;
367397
expectedCur?: string;
398+
/**
399+
* Marks the keyspace as holed, so window reads refuse and fall back to Postgres. Set by the
400+
* repair, which only runs because an append was lost.
401+
*/
402+
markGaps?: boolean;
368403
cycle?:
369404
| {
370405
kind: "new";
@@ -452,7 +487,8 @@ export class RedisSnapshotStore {
452487
orderCount,
453488
args.expectedCur ?? "",
454489
args.expectedCur !== undefined ? "1" : "0",
455-
distinctJson
490+
distinctJson,
491+
args.markGaps ? "1" : "0"
456492
)) as string[];
457493

458494
return this.#interpretAppend(reply, raw, orderJson, records, args.entry.runId);
@@ -819,6 +855,21 @@ export class RedisSnapshotStore {
819855
end
820856
`;
821857

858+
this.redis.defineCommand("markSnapshotGaps", {
859+
numberOfKeys: 2,
860+
lua: `
861+
local eKey = KEYS[1]
862+
local seqKey = KEYS[2]
863+
-- Both anchors, the same pair keyspaceAlive uses. Marking on the strength of one of them
864+
-- would create the other.
865+
if redis.call('EXISTS', eKey) == 0 or redis.call('EXISTS', seqKey) == 0 then
866+
return 0
867+
end
868+
redis.call('HSET', seqKey, 'g', '1')
869+
return 1
870+
`,
871+
});
872+
822873
this.redis.defineCommand("appendSnapshotEntry", {
823874
numberOfKeys: 4,
824875
lua: `
@@ -839,6 +890,9 @@ export class RedisSnapshotStore {
839890
-- The COMPLETE distinct id set. Not the order deduped: order omits every id with no batch
840891
-- index, and those ids still have to come back on a read.
841892
local distinctJson = ARGV[14]
893+
-- Set by the repair. A repair exists BECAUSE an append was lost, so whatever it manages to
894+
-- put back, the entries between are gone and the window is short.
895+
local markGaps = ARGV[15] == '1'
842896
843897
-- Checking e alone would let a late transition recreate seq with no TTL and restart it at 1
844898
-- beside a surviving idx. A birth always creates both in this same script, so this never
@@ -860,10 +914,19 @@ export class RedisSnapshotStore {
860914
if casEnabled then
861915
local actual = redis.call('GET', curKey)
862916
if (actual or '') ~= expectedCur then
917+
-- The one mutation a refused append makes, and it is not part of the append. A fork means
918+
-- this keyspace and Postgres already disagree about the head, so its history cannot be
919+
-- served as a window until something re-establishes that it can. The entry itself is
920+
-- still not written.
921+
redis.call('HSET', seqKey, 'g', '1')
863922
return { '${FORKED}', actual or '' }
864923
end
865924
end
866925
926+
if markGaps then
927+
redis.call('HSET', seqKey, 'g', '1')
928+
end
929+
867930
local seq = redis.call('HINCRBY', seqKey, 'e', 1)
868931
869932
local cycleSeq = 0
@@ -1019,6 +1082,11 @@ export class RedisSnapshotStore {
10191082
-- on every poll for the rest of the run's life, with Postgres holding the transitions.
10201083
if not keyspaceAlive() or redis.call('EXISTS', idxKey) == 0 then return nil end
10211084
1085+
-- A keyspace that lost an append has a hole, and no guard downstream can see one: a window
1086+
-- that should hold eight entries would return four and look complete. Refuse, and the
1087+
-- caller's existing miss path asks Postgres, which still holds the whole log.
1088+
if redis.call('HGET', seqKey, 'g') == '1' then return nil end
1089+
10221090
-- STRICTLY greater than the cursor, and same-millisecond entries are dropped. Postgres
10231091
-- serves this window with createdAt > cursor and drops them too; a Redis read that is more
10241092
-- correct than the Postgres read shows up as divergence in compare mode.
@@ -1086,6 +1154,10 @@ export class RedisSnapshotStore {
10861154
-- so the caller stops asking Postgres for a window Postgres alone still holds.
10871155
if not keyspaceAlive() or redis.call('EXISTS', idxKey) == 0 then return nil end
10881156
1157+
-- And the same hole gate, for the same reason. A caller that fell back on one window command
1158+
-- and not the other would still serve a short history through the second.
1159+
if redis.call('HGET', seqKey, 'g') == '1' then return nil end
1160+
10891161
-- The index holds valid entries only, so an invalid since id misses ZSCORE. Its seq is still
10901162
-- on its own '#s' field, which keeps the id resolvable without indexing invalid rows.
10911163
local score = redis.call('ZSCORE', idxKey, sinceId)
@@ -1165,6 +1237,11 @@ declare module "@internal/redis" {
11651237
seqKey: string,
11661238
callback?: Callback<number>
11671239
): Result<number, Context>;
1240+
markSnapshotGaps(
1241+
eKey: string,
1242+
seqKey: string,
1243+
callback?: Callback<number>
1244+
): Result<number, Context>;
11681245
appendSnapshotEntry(
11691246
eKey: string,
11701247
idxKey: string,
@@ -1184,6 +1261,7 @@ declare module "@internal/redis" {
11841261
expectedCur: string,
11851262
casEnabled: string,
11861263
distinctJson: string,
1264+
markGaps: string,
11871265
callback?: Callback<string[]>
11881266
): Result<string[], Context>;
11891267
readSnapshotById(

internal-packages/run-store/src/taskRunExecutionSnapshotStore.repair.test.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ class RecordingRedis {
101101
async dropRun(): Promise<void> {
102102
this.calls.push("dropRun");
103103
}
104+
105+
gapsMarked = 0;
106+
107+
async markGapsIfResident(): Promise<boolean> {
108+
this.calls.push("markGapsIfResident");
109+
this.gapsMarked += 1;
110+
return true;
111+
}
104112
}
105113

106114
function redisHead(id: string, createdAt: Date): SnapshotRead {
@@ -284,7 +292,7 @@ describe("repairRedisHead", () => {
284292
expect(redis.appends[0]!.kind).toBe("transition");
285293
});
286294

287-
it("does nothing when the mirror already holds the Postgres head", async () => {
295+
it("appends nothing when the mirror already holds the Postgres head, but still marks the hole", async () => {
288296
const redis = new RecordingRedis(redisHead("snap_lost", new Date("2026-01-01T00:00:10.000Z")));
289297
const store = harness({
290298
pg: pgHead({
@@ -298,9 +306,13 @@ describe("repairRedisHead", () => {
298306

299307
await expect(store.repairRedisHead("run_1", "snap_lost")).resolves.toBe("alreadyCurrent");
300308
expect(redis.appends).toHaveLength(0);
309+
// The head converged on its own, but the repair only ran because an append was lost, so entries
310+
// behind it can still be missing. Observed live: four entries in Redis against eight in
311+
// Postgres, with a matching head. Without the mark, that keyspace serves short windows as whole.
312+
expect(redis.gapsMarked).toBe(1);
301313
});
302314

303-
it("refuses to append behind a mirror head that is newer than the Postgres head", async () => {
315+
it("refuses to append behind a newer mirror head, and marks the hole", async () => {
304316
// Appending an older entry at the tail would leave the chain claiming a state the run has left.
305317
const redis = new RecordingRedis(redisHead("snap_newer", new Date("2026-01-01T00:00:20.000Z")));
306318
const store = harness({
@@ -315,6 +327,8 @@ describe("repairRedisHead", () => {
315327

316328
await expect(store.repairRedisHead("run_1", "snap_lost")).resolves.toBe("redisAhead");
317329
expect(redis.appends).toHaveLength(0);
330+
// Divergence either way round is still divergence.
331+
expect(redis.gapsMarked).toBe(1);
318332
});
319333

320334
it("heals the head even when the run has transitioned past the lost snapshot", async () => {

0 commit comments

Comments
 (0)