Skip to content

Commit d56408e

Browse files
committed
fix(run-store): hydrate a snapshot's checkpoint by id at redis-only
At redis-only the TaskRunExecutionSnapshot row is not written to Postgres, but the checkpoint hydration read it back through that row, so a suspended run resumed with a null checkpoint and restarted with no state to restore from. The TaskRunCheckpoint row itself is never suppressed and the Redis entry carries the checkpointId, so read the checkpoint directly by id instead. Adds a residency-aware findTaskRunCheckpointById to the run store (routed to the run's co-located store) and points the decorator's hydration at it. Covered by a new redis-only checkpoint test that fails on the old path and passes now.
1 parent a4628d8 commit d56408e

7 files changed

Lines changed: 122 additions & 8 deletions

File tree

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

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ export interface RunOpsCapableClient {
5757
taskRunAttempt: RunOpsDelegate<"create" | "findFirst" | "findMany" | "update">;
5858
taskRunExecutionSnapshot: RunOpsDelegate<"create" | "findFirst" | "findMany">;
5959
taskRunWaitpoint: RunOpsDelegate<"deleteMany" | "findMany">;
60-
taskRunCheckpoint: RunOpsDelegate<"create">;
60+
taskRunCheckpoint: RunOpsDelegate<"create" | "findFirst">;
6161
checkpoint: RunOpsDelegate<"create" | "findFirst">;
6262
checkpointRestoreEvent: RunOpsDelegate<"create" | "findFirst">;
6363
taskRunDependency: RunOpsDelegate<"create" | "findFirst" | "findMany">;
@@ -2616,6 +2616,19 @@ export class PostgresRunStore implements RunStore {
26162616
return prisma.taskRunCheckpoint.create(args) as Promise<Prisma.TaskRunCheckpointGetPayload<T>>;
26172617
}
26182618

2619+
async findTaskRunCheckpointById(
2620+
checkpointId: string,
2621+
// `ownerRunId` selects residency at the router; a single store has one client and ignores it.
2622+
_ownerRunId: string,
2623+
client?: ReadClient
2624+
): Promise<Prisma.TaskRunCheckpointGetPayload<{}> | null> {
2625+
const prisma = client ?? this.readOnlyPrisma;
2626+
2627+
return prisma.taskRunCheckpoint.findFirst({
2628+
where: { id: checkpointId },
2629+
}) as Promise<Prisma.TaskRunCheckpointGetPayload<{}> | null>;
2630+
}
2631+
26192632
// --- BatchTaskRun (run-ops) ---
26202633

26212634
async createBatchTaskRun(

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -623,6 +623,14 @@ export class DelegatingRunStore implements RunStore {
623623
return this.delegate.createTaskRunCheckpoint(args, ownerRunId, tx);
624624
}
625625

626+
findTaskRunCheckpointById(
627+
checkpointId: string,
628+
ownerRunId: string,
629+
client?: ReadClient
630+
): Promise<Prisma.TaskRunCheckpointGetPayload<{}> | null> {
631+
return this.delegate.findTaskRunCheckpointById(checkpointId, ownerRunId, client);
632+
}
633+
626634
createBatchTaskRun(
627635
data: CreateBatchTaskRunData,
628636
tx?: PrismaClientOrTransaction

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2083,6 +2083,20 @@ export class RoutingRunStore implements RunStore {
20832083
return store.createTaskRunCheckpoint(args, ownerRunId, undefined);
20842084
}
20852085

2086+
async findTaskRunCheckpointById(
2087+
checkpointId: string,
2088+
ownerRunId: string,
2089+
client?: ReadClient
2090+
): Promise<Prisma.TaskRunCheckpointGetPayload<{}> | null> {
2091+
// Co-located with its owner run, so route by ownerRunId and read that store's own primary.
2092+
const store = this.#route(ownerRunId);
2093+
return store.findTaskRunCheckpointById(
2094+
checkpointId,
2095+
ownerRunId,
2096+
RoutingRunStore.#ownPrimary(store, client)
2097+
);
2098+
}
2099+
20862100
// ---------------------------------------------------------------------------
20872101
// BatchTaskRun (run-ops). Route by id-shape: run-ops id→NEW, cuid→LEGACY.
20882102
// ---------------------------------------------------------------------------

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ export const RUN_STORE_METHOD_NAMES = [
6868
"deleteManyTaskRunWaitpoints",
6969
"findTaskRunAttempt",
7070
"createTaskRunCheckpoint",
71+
"findTaskRunCheckpointById",
7172
"createBatchTaskRun",
7273
"updateBatchTaskRun",
7374
"findBatchTaskRunById",

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

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,76 @@ describe("a checkpoint on a snapshot served from Redis", () => {
107107
}
108108
);
109109

110+
// The shipping redis-only PAIR: decorator at redis-only OVER a store with snapshotWrites:false, so
111+
// the TaskRunExecutionSnapshot row is NOT written to Postgres. The checkpoint ROW still lives in
112+
// Postgres (only snapshot rows are suppressed), and the Redis entry carries checkpointId. A resume
113+
// MUST still re-attach the checkpoint row. If hydration reads it through the (suppressed) snapshot
114+
// row, the resumed run gets a null checkpoint and restarts with no state to restore from.
115+
containerTest(
116+
"at redis-only, a suspended run's checkpoint still hydrates (snapshot row suppressed)",
117+
async ({ prisma, redisOptions }) => {
118+
const redis = new RedisSnapshotStore({ redisOptions, completedTtlMs: COMPLETED_TTL_MS });
119+
// snapshotWrites:false is redis-only: run mutations land, snapshot rows do not.
120+
const store = new PostgresRunStore({ prisma, readOnlyPrisma: prisma, snapshotWrites: false });
121+
const decorated = new TaskRunExecutionSnapshotStore(store as unknown as RunStore, {
122+
store: redis,
123+
mode: "redis-only",
124+
readPercent: 100,
125+
});
126+
try {
127+
const env = await seedSnapshotEnvironment(prisma);
128+
const runId = generateInternalId();
129+
130+
await decorated.createRun({
131+
data: buildCreateRunData(runId, env),
132+
snapshot: birthSnapshot(env),
133+
});
134+
135+
const checkpoint = await prisma.taskRunCheckpoint.create({
136+
data: {
137+
friendlyId: `checkpoint_${generateInternalId()}`,
138+
type: "DOCKER",
139+
location: "s3://bucket/redis-only-checkpoint.tar",
140+
imageRef: "registry/image@sha256:def",
141+
reason: "suspend at redis-only",
142+
projectId: env.projectId,
143+
runtimeEnvironmentId: env.id,
144+
},
145+
});
146+
147+
// Suspend transition naming the checkpoint. At redis-only the snapshot row is suppressed in PG.
148+
await decorated.createExecutionSnapshot({
149+
run: { id: runId, status: "WAITING_TO_RESUME" },
150+
snapshot: { executionStatus: "SUSPENDED", description: "Run was suspended" },
151+
checkpointId: checkpoint.id,
152+
environmentId: env.id,
153+
environmentType: env.type,
154+
projectId: env.projectId,
155+
organizationId: env.organizationId,
156+
} as never);
157+
158+
// Confirm the redis-only premise: NO snapshot row for this run exists in Postgres.
159+
const pgSnapshotCount = await prisma.taskRunExecutionSnapshot.count({ where: { runId } });
160+
expect(pgSnapshotCount).toBe(0);
161+
162+
// The resume read is served from Redis. It MUST still carry the checkpoint id AND the row.
163+
const fromRedis = await decorated.findLatestExecutionSnapshot(runId);
164+
expect(fromRedis).not.toBeNull();
165+
expect(fromRedis!.executionStatus).toBe("SUSPENDED");
166+
expect(fromRedis!.checkpointId).toBe(checkpoint.id);
167+
168+
// The load-bearing assertion: the checkpoint ROW is re-attached, hydrated directly from the
169+
// TaskRunCheckpoint table (not via the suppressed snapshot row), so the run can restore.
170+
expect(fromRedis!.checkpoint).not.toBeNull();
171+
expect(fromRedis!.checkpoint!.id).toBe(checkpoint.id);
172+
expect(fromRedis!.checkpoint!.location).toBe("s3://bucket/redis-only-checkpoint.tar");
173+
expect(fromRedis!.checkpoint!.imageRef).toBe("registry/image@sha256:def");
174+
} finally {
175+
await redis.quit();
176+
}
177+
}
178+
);
179+
110180
containerTest(
111181
"costs no Postgres read when the snapshot has no checkpoint",
112182
async ({ prisma, redisOptions }) => {

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

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1463,7 +1463,7 @@ export class TaskRunExecutionSnapshotStore extends DelegatingRunStore {
14631463
const entry = read.entry as Record<string, unknown>;
14641464

14651465
const checkpoint = entry.checkpointId
1466-
? await this.#hydrateCheckpoint(runId, read.id, client)
1466+
? await this.#hydrateCheckpoint(runId, entry.checkpointId as string, client)
14671467
: null;
14681468

14691469
// `completedWaitpointOrder` is a scalar column, NOT the join. The engine reads it off the head
@@ -1555,14 +1555,13 @@ export class TaskRunExecutionSnapshotStore extends DelegatingRunStore {
15551555
*/
15561556
async #hydrateCheckpoint(
15571557
runId: string,
1558-
snapshotId: string,
1558+
checkpointId: string,
15591559
client?: ReadClient
15601560
): Promise<unknown> {
1561-
const row = await this.delegate.findExecutionSnapshot(
1562-
{ where: { id: snapshotId, runId }, include: { checkpoint: true } },
1563-
client
1564-
);
1565-
return (row as { checkpoint?: unknown } | null)?.checkpoint ?? null;
1561+
// Read the checkpoint ROW directly by id, NOT via the snapshot row: at redis-only the snapshot
1562+
// row is suppressed, so hydrating through it returns null and a resumed run loses its checkpoint.
1563+
// The TaskRunCheckpoint row is never suppressed. runId routes to the run's co-located store.
1564+
return this.delegate.findTaskRunCheckpointById(checkpointId, runId, client);
15661565
}
15671566

15681567
async #enqueueRepair(entry: SnapshotEntryInput): Promise<void> {

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -882,6 +882,15 @@ export interface RunStore {
882882
tx?: PrismaClientOrTransaction
883883
): Promise<Prisma.TaskRunCheckpointGetPayload<T>>;
884884

885+
// Residency-aware direct checkpoint read by id. A snapshot served from Redis carries only the
886+
// checkpointId; the checkpoint hydrates by reading TaskRunCheckpoint directly, NOT via the snapshot
887+
// row, which at redis-only is suppressed. `ownerRunId` routes to the run's co-located store.
888+
findTaskRunCheckpointById(
889+
checkpointId: string,
890+
ownerRunId: string,
891+
client?: ReadClient
892+
): Promise<Prisma.TaskRunCheckpointGetPayload<{}> | null>;
893+
885894
// --- BatchTaskRun (run-ops) ---
886895
// Batch row is born on the run-ops store at create. `findBatchTaskRunById`
887896
// reads the primary by default (worker reads the just-written row; replica lag).

0 commit comments

Comments
 (0)