Skip to content

Commit 02e6157

Browse files
authored
feat(run-store): add an execution-snapshot store decorator behind an off-by-default dial (#4765)
## Summary Adds a `RunStore` decorator that mirrors execution snapshots into Redis alongside Postgres, plus the orphan-key sweep and the fault-injection suite that prove the write protocol converges after a crash. Nothing constructs it, so merging this changes no behaviour: the configuration, the production wiring and the Redis client all arrive in later work. The execution-state log is the hottest table in the run graph, and moving it out of Postgres has to happen without a big-bang cutover. This is the attachment point for that: a decorator that wraps the existing storage interface and intercepts only the methods that touch snapshots, so none of the many callers change. ## Design Write order is the correctness property, and the two orders differ on purpose. A transition writes Postgres first and Redis second. A crash in the gap leaves a run whose latest snapshot is stale, which is the state the heartbeat stall watchdog already heals in production today. A birth writes Redis first and Postgres second. A crash there leaves an unreachable key for a run that does not exist. Postgres first would instead leave a run with no snapshot at all, which the engine treats as a hard error, so the run would be stuck. Each order is chosen so the state a crash leaves behind is the harmless one. A lost cross-store write is never recovered by a transaction or an outbox; recovery is always the existing stall and repair job. A failed append retries, then hands the run to that job, and never rethrows, because Postgres has already committed and a throw would turn a healable gap into a caller-visible error. Inside a transaction the Redis half is staged and flushed only after the commit, so a rollback cannot leave Redis holding a transition that never happened. Reads are shape matched. Two of the snapshot reads take arbitrary Prisma arguments, and a key-value store cannot answer an arbitrary query, so the decorator recognises exactly the shapes the engine sends and delegates everything else. A miss falls back to Postgres, which is also how runs created before any cutover keep working. The sweep reaps under two rules, because neither can see what the other leaves behind. A finished run whose keyspace never received its completion expiry gets one applied. A keyspace with no run row at all, past an age threshold, is deleted; that is a crashed birth, which is non-terminal so it carries no expiry and has no run row, so the first rule can never match it. ## Inertness Three independent reasons this is a no-op if merged alone: - Nothing constructs the decorator or the Redis store outside tests. - No configuration reaches it, so the dial stays at its off position, which is a pass-through that makes no Redis call. - The existing Postgres store gains an off-by-default flag and two optional input fields. Both default to today's behaviour, and only the decorator would ever supply them. ## Notes for review The snapshot id and the creation instant are both minted by the decorator and written into both stores, so one snapshot has one identity and one timestamp wherever it is read. Without that, the two stores disagree on values that later tooling has to compare, and the cursor for a snapshot window resolved from one store misfilters the window walked in the other. Three defects in this work passed the full existing test suites before being found by review rather than by a test: the decorator wrote no wait cycle at all, the snapshot window dropped the ordering used to give each completed waitpoint its position in a batch, and the two stores stamped different creation times. The common cause was that no test drove a snapshot that actually carried waitpoints, and that the parity suite compared a timestamp against a value it had just read back from the row it was checking. Both gaps now have tests.
1 parent 1801b0e commit 02e6157

37 files changed

Lines changed: 9918 additions & 143 deletions

internal-packages/redis/src/index.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,24 @@
1-
import { Redis, type RedisOptions } from "ioredis";
1+
import { type Cluster, Redis, type RedisOptions } from "ioredis";
22
import { Logger } from "@trigger.dev/core/logger";
33

4-
export { Redis, type Callback, type RedisOptions, type Result, type RedisCommander } from "ioredis";
4+
export {
5+
Redis,
6+
Cluster,
7+
type Callback,
8+
type RedisOptions,
9+
type ClusterNode,
10+
type ClusterOptions,
11+
type Result,
12+
type RedisCommander,
13+
} from "ioredis";
14+
15+
/**
16+
* Either endpoint shape. A component that only issues key-addressed commands works against both, so
17+
* it should accept this rather than pin itself to a standalone connection. Commands with no key —
18+
* SCAN above all — do NOT fan out across a cluster, so anything that issues one must iterate
19+
* `cluster.nodes("master")` itself.
20+
*/
21+
export type RedisClient = Redis | Cluster;
522

623
/**
724
* Reply-error -> reconnect mapping. Without this hook, an ElastiCache
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// The snapshot sweeper needs to know which run statuses are terminal, and it cannot import that
2+
// list: run-engine depends on run-store, not the other way round. So the list is duplicated, and
3+
// this is the only thing that keeps the copy honest.
4+
//
5+
// Without it, a status added here and not there makes the sweeper treat a finished run as live and
6+
// never apply its completion expiry. A status removed here and not there makes it treat a live run
7+
// as finished. The second one reaps state a run is still using.
8+
import { describe, expect, it } from "vitest";
9+
import { FINAL_RUN_STATUSES } from "@internal/run-store";
10+
import { getFinalRunStatuses } from "../statuses.js";
11+
12+
describe("terminal run statuses", () => {
13+
it("match between the engine and the snapshot sweeper", () => {
14+
expect([...FINAL_RUN_STATUSES].sort()).toEqual([...getFinalRunStatuses()].sort());
15+
});
16+
17+
it("are not empty, so the comparison cannot pass vacuously", () => {
18+
expect(FINAL_RUN_STATUSES.length).toBeGreaterThan(0);
19+
});
20+
});
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Builds the snapshot-store decorator over a real PostgresRunStore, for injection through the
2+
// engine's `store` option — the seam runStoreInjectability.test.ts already proves.
3+
//
4+
// The point of injecting it is that the engine suites keep their own assertions: the same flows,
5+
// the same expectations, a different store underneath.
6+
import {
7+
PostgresRunStore,
8+
RedisSnapshotStore,
9+
TaskRunExecutionSnapshotStore,
10+
type SnapshotFaultInjector,
11+
type SnapshotRepairEnqueuer,
12+
type SnapshotStoreMode,
13+
} from "@internal/run-store";
14+
import type { PrismaClient } from "@trigger.dev/database";
15+
import type { RedisOptions } from "@internal/redis";
16+
17+
const COMPLETED_TTL_MS = 72 * 60 * 60 * 1000;
18+
19+
export type DecoratedStoreHarness = {
20+
store: TaskRunExecutionSnapshotStore;
21+
redis: RedisSnapshotStore;
22+
/** Every read the decorator served, and which store answered it. */
23+
reads: { method: string; source: "redis" | "postgres" }[];
24+
/** Every append outcome, keyed by the write site that produced it. */
25+
writes: { site: string; outcome: string }[];
26+
/** Runs handed to the repair job because their append was lost. */
27+
repairs: { runId: string; snapshotId: string; executionStatus: string }[];
28+
quit(): Promise<void>;
29+
};
30+
31+
export function buildDecoratedStore(opts: {
32+
prisma: PrismaClient;
33+
redisOptions: RedisOptions;
34+
mode: SnapshotStoreMode;
35+
readPercent?: number;
36+
faults?: SnapshotFaultInjector;
37+
onAppendFailure?: SnapshotRepairEnqueuer;
38+
}): DecoratedStoreHarness {
39+
const redis = new RedisSnapshotStore({
40+
redisOptions: opts.redisOptions,
41+
completedTtlMs: COMPLETED_TTL_MS,
42+
});
43+
44+
const reads: DecoratedStoreHarness["reads"] = [];
45+
const writes: DecoratedStoreHarness["writes"] = [];
46+
const repairs: DecoratedStoreHarness["repairs"] = [];
47+
48+
const store = new TaskRunExecutionSnapshotStore(
49+
new PostgresRunStore({ prisma: opts.prisma as never, readOnlyPrisma: opts.prisma as never }),
50+
{
51+
store: redis,
52+
mode: opts.mode,
53+
readPercent: opts.readPercent ?? 100,
54+
...(opts.faults && { faults: opts.faults }),
55+
onAppendFailure: async (args) => {
56+
repairs.push(args);
57+
await opts.onAppendFailure?.(args);
58+
},
59+
metrics: {
60+
recordWrite: (site, outcome) => writes.push({ site, outcome }),
61+
recordAppendFailed: () => {},
62+
recordRead: (method, source) => reads.push({ method, source }),
63+
},
64+
}
65+
);
66+
67+
return {
68+
store,
69+
redis,
70+
reads,
71+
writes,
72+
repairs,
73+
quit: () => redis.quit(),
74+
};
75+
}

0 commit comments

Comments
 (0)