Skip to content

Commit f347f0e

Browse files
d-csTrigger.dev RepoOps
authored andcommitted
feat(run-engine, run-store): durable execution-snapshot store protocol
No user-facing change. This adds dormant execution-snapshot storage primitives that are not connected to a production execution path. Mono-RevId: 131c951f78bca8818c3f7c9f11ba75f7a7f2db3e
1 parent a02f033 commit f347f0e

68 files changed

Lines changed: 6378 additions & 7294 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
#!/bin/sh
2+
# Brings up a six-node Redis cluster (3 masters, 3 replicas) inside ONE container.
3+
#
4+
# One container, not six, for two reasons that both come from Docker on macOS:
5+
#
6+
# 1. Cluster nodes gossip with each other using the address they ADVERTISE. Six separate
7+
# containers each advertising 127.0.0.1 would each be talking to themselves, and the cluster
8+
# never forms. Sharing one network namespace makes 127.0.0.1 mean the same thing to all six.
9+
# 2. A client on the host follows MOVED redirects to that same advertised address, so the ports
10+
# published below resolve correctly from outside.
11+
#
12+
# Each node gets its own directory: six nodes sharing one working directory fight over nodes.conf
13+
# and all but the first fail to start, silently, because they are daemonised.
14+
set -e
15+
16+
NODES="1 2 3 4 5 6"
17+
18+
for i in $NODES; do
19+
PORT=$((7000 + i))
20+
BUS=$((17000 + i))
21+
mkdir -p "/data/$PORT"
22+
redis-server \
23+
--port "$PORT" \
24+
--cluster-enabled yes \
25+
--cluster-node-timeout 5000 \
26+
--cluster-announce-ip 127.0.0.1 \
27+
--cluster-announce-port "$PORT" \
28+
--cluster-announce-bus-port "$BUS" \
29+
--cluster-config-file "nodes-$PORT.conf" \
30+
--dir "/data/$PORT" \
31+
--protected-mode no \
32+
--appendonly no \
33+
--save '' \
34+
--logfile "/data/$PORT/redis.log" \
35+
--daemonize yes
36+
done
37+
38+
# Daemonised servers report failures only to their own log, so check before forming the cluster.
39+
sleep 5
40+
for i in $NODES; do
41+
PORT=$((7000 + i))
42+
if ! redis-cli -p "$PORT" ping >/dev/null 2>&1; then
43+
echo "node $PORT failed to start:"
44+
tail -20 "/data/$PORT/redis.log"
45+
exit 1
46+
fi
47+
done
48+
49+
# Idempotent: a restart with a populated /data already has slots assigned, so skip the create.
50+
if redis-cli -p 7001 cluster info | grep -q "cluster_state:ok"; then
51+
echo "cluster already formed"
52+
else
53+
redis-cli --cluster create \
54+
127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 \
55+
127.0.0.1:7004 127.0.0.1:7005 127.0.0.1:7006 \
56+
--cluster-replicas 1 --cluster-yes
57+
fi
58+
59+
redis-cli -p 7001 cluster info | grep -E "cluster_state|cluster_size"
60+
exec tail -f /dev/null

docker/docker-compose.extras.yml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
name: triggerdotdev-docker
1515

1616
volumes:
17+
redis-cluster-data:
1718
prometheus-data:
1819
grafana-data:
1920

@@ -95,6 +96,7 @@ services:
9596
restart: always
9697
volumes:
9798
- ./config/prometheus.yml:/etc/prometheus/prometheus.yml:ro
99+
- ./config/alerts:/etc/prometheus/alerts:ro
98100
- prometheus-data:/prometheus
99101
ports:
100102
- "${PROMETHEUS_HOST_PORT:-9090}:9090"
@@ -122,3 +124,39 @@ services:
122124
- app_network
123125
depends_on:
124126
- prometheus
127+
128+
# Six-node Redis cluster (3 masters, 3 replicas) for validating the execution-snapshot store.
129+
# The store's single-slot guarantee only means anything against a cluster-mode endpoint, and the
130+
# recovery worker's per-master fan-out is unreachable on a single node.
131+
#
132+
# RUN_ENGINE_SNAPSHOT_STORE_REDIS_HOST=127.0.0.1
133+
# RUN_ENGINE_SNAPSHOT_STORE_REDIS_PORT=7001
134+
# RUN_ENGINE_SNAPSHOT_STORE_REDIS_CLUSTER_MODE_ENABLED=1
135+
#
136+
# ioredis discovers the other five nodes from that one seed.
137+
redis-cluster:
138+
container_name: ${CONTAINER_PREFIX:-}redis-cluster
139+
image: redis:7.2@sha256:74566c6910d13ae61e7ce73ebd3127438a1fe805b309b097c323142719ec8a5b
140+
restart: always
141+
volumes:
142+
- ./config/redis-cluster-init.sh:/init.sh:ro
143+
- redis-cluster-data:/data
144+
command: sh /init.sh
145+
ports:
146+
- "${REDIS_CLUSTER_PORT_1:-7001}:7001"
147+
- "${REDIS_CLUSTER_PORT_2:-7002}:7002"
148+
- "${REDIS_CLUSTER_PORT_3:-7003}:7003"
149+
- "${REDIS_CLUSTER_PORT_4:-7004}:7004"
150+
- "${REDIS_CLUSTER_PORT_5:-7005}:7005"
151+
- "${REDIS_CLUSTER_PORT_6:-7006}:7006"
152+
- "17001:17001"
153+
- "17002:17002"
154+
- "17003:17003"
155+
- "17004:17004"
156+
- "17005:17005"
157+
- "17006:17006"
158+
healthcheck:
159+
test: ["CMD-SHELL", "redis-cli -p 7001 cluster info | grep -q cluster_state:ok"]
160+
interval: 5s
161+
timeout: 3s
162+
retries: 20

internal-packages/redis/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@
1010
"@trigger.dev/core": "workspace:*"
1111
},
1212
"scripts": {
13-
"typecheck": "tsc --noEmit"
13+
"typecheck": "tsc --noEmit",
14+
"test": "vitest",
15+
"test:watch": "vitest"
1416
}
1517
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { describe, expect, it } from "vitest";
2+
import { Cluster, createRedisClusterClient, defaultReconnectOnError } from "./index.js";
3+
4+
// Port 6399 is deliberately closed: these assertions are about the client the factory builds, not
5+
// about a connection.
6+
const NODES = [{ host: "127.0.0.1", port: 6399 }];
7+
8+
type InnerOptions = {
9+
options: {
10+
redisOptions?: {
11+
reconnectOnError?: unknown;
12+
maxRetriesPerRequest?: number;
13+
retryStrategy?: unknown;
14+
keyPrefix?: string;
15+
};
16+
};
17+
};
18+
19+
function innerOptionsOf(client: Cluster) {
20+
return (client as unknown as InnerOptions).options.redisOptions;
21+
}
22+
23+
describe("createRedisClusterClient", () => {
24+
it("returns a Cluster instance", async () => {
25+
const client = createRedisClusterClient({ nodes: NODES });
26+
try {
27+
expect(client).toBeInstanceOf(Cluster);
28+
} finally {
29+
await client.quit().catch(() => undefined);
30+
}
31+
});
32+
33+
it("installs defaultReconnectOnError on the inner per-node options", async () => {
34+
const client = createRedisClusterClient({ nodes: NODES });
35+
try {
36+
expect(innerOptionsOf(client)?.reconnectOnError).toBe(defaultReconnectOnError);
37+
} finally {
38+
await client.quit().catch(() => undefined);
39+
}
40+
});
41+
42+
it("carries the retry defaults onto the inner options", async () => {
43+
const client = createRedisClusterClient({ nodes: NODES });
44+
try {
45+
const inner = innerOptionsOf(client);
46+
expect(inner?.maxRetriesPerRequest).toBeTypeOf("number");
47+
expect(inner?.retryStrategy).toBeTypeOf("function");
48+
} finally {
49+
await client.quit().catch(() => undefined);
50+
}
51+
});
52+
53+
it("lets caller redisOptions override the defaults", async () => {
54+
const client = createRedisClusterClient({
55+
nodes: NODES,
56+
redisOptions: { keyPrefix: "engine:", maxRetriesPerRequest: 3 },
57+
});
58+
try {
59+
const inner = innerOptionsOf(client);
60+
expect(inner?.keyPrefix).toBe("engine:");
61+
expect(inner?.maxRetriesPerRequest).toBe(3);
62+
} finally {
63+
await client.quit().catch(() => undefined);
64+
}
65+
});
66+
67+
it("keeps mapping READONLY, LOADING and UNBLOCKED to a reconnect-and-retry", () => {
68+
expect(defaultReconnectOnError(new Error("READONLY against a read only replica"))).toBe(2);
69+
expect(defaultReconnectOnError(new Error("LOADING Redis is loading the dataset"))).toBe(2);
70+
expect(defaultReconnectOnError(new Error("UNBLOCKED force unblock"))).toBe(2);
71+
expect(defaultReconnectOnError(new Error("ERR unknown command"))).toBe(false);
72+
});
73+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createRedisClusterClient } from "./index.js";
3+
4+
// The offline queue on a Cluster client is a CLUSTER-level option. Setting it on the inner
5+
// per-node redisOptions leaves the cluster queueing commands while it cannot refresh its slot
6+
// cache, so a command issued during an outage waits instead of failing and the caller hangs.
7+
describe("cluster client against an unreachable cluster", () => {
8+
it("rejects a command rather than queueing it", async () => {
9+
const client = createRedisClusterClient({
10+
// Nothing listens here.
11+
nodes: [{ host: "127.0.0.1", port: 6391 }],
12+
redisOptions: { commandTimeout: 300 },
13+
failFast: true,
14+
});
15+
16+
const started = Date.now();
17+
await expect(client.get("anything")).rejects.toThrow();
18+
const elapsed = Date.now() - started;
19+
20+
expect(elapsed).toBeLessThan(5000);
21+
22+
client.disconnect();
23+
});
24+
});
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { describe, expect, it } from "vitest";
2+
import { createRedisClient } from "./index.js";
3+
4+
// A snapshot-store append sits on a request path. With the offline queue enabled and no command
5+
// timeout, a command issued while the endpoint is unreachable waits for a reconnect instead of
6+
// failing, so the request hangs rather than falling back to Postgres.
7+
describe("fail-fast options against an unreachable endpoint", () => {
8+
it("rejects rather than hanging when the offline queue is off and a timeout is set", async () => {
9+
const client = createRedisClient({
10+
// Nothing listens here.
11+
host: "127.0.0.1",
12+
port: 6390,
13+
enableOfflineQueue: false,
14+
commandTimeout: 300,
15+
lazyConnect: true,
16+
retryStrategy: () => null,
17+
});
18+
19+
const started = Date.now();
20+
await expect(client.get("anything")).rejects.toThrow();
21+
const elapsed = Date.now() - started;
22+
23+
// Generous, but far below the indefinite wait the offline queue produces.
24+
expect(elapsed).toBeLessThan(3000);
25+
26+
client.disconnect();
27+
});
28+
});

internal-packages/redis/src/index.ts

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
1-
import { type Cluster, Redis, type RedisOptions } from "ioredis";
1+
import {
2+
Redis,
3+
type Cluster,
4+
type ClusterNode,
5+
type ClusterOptions,
6+
type RedisOptions,
7+
} from "ioredis";
28
import { Logger } from "@trigger.dev/core/logger";
39

410
export {
@@ -85,3 +91,55 @@ export function createRedisClient(
8591

8692
return client;
8793
}
94+
95+
export type RedisClusterClientOptions = {
96+
nodes: ClusterNode[];
97+
clusterOptions?: Omit<ClusterOptions, "redisOptions">;
98+
redisOptions?: RedisOptions;
99+
/**
100+
* Fail a command while the cluster is unreachable instead of queueing it. For a caller on a
101+
* request path, where a queued command means a hung request rather than a slow one.
102+
*/
103+
failFast?: boolean;
104+
};
105+
106+
/**
107+
* Cluster-mode client. `defaultOptions` go on the INNER per-node options, so a role swap gets the
108+
* same reconnect-and-retry treatment a single-node client already gets.
109+
*/
110+
export function createRedisClusterClient(
111+
options: RedisClusterClientOptions,
112+
handlers?: { onError?: (err: Error) => void }
113+
): Cluster {
114+
const client = new Redis.Cluster(options.nodes, {
115+
// The offline queue is a CLUSTER-level setting, separate from the per-node one below. While a
116+
// cluster cannot refresh its slot cache it queues commands here, so a caller that wants a
117+
// failure during an outage rather than a wait has to turn THIS one off. Default stays `true`,
118+
// matching ioredis, so only a caller that asks for it changes behaviour.
119+
...(options.failFast && { enableOfflineQueue: false }),
120+
...options.clusterOptions,
121+
redisOptions: {
122+
...defaultOptions,
123+
...(options.failFast && { enableOfflineQueue: false }),
124+
...options.redisOptions,
125+
},
126+
});
127+
128+
if (process.env.VITEST) {
129+
client.on("error", () => {});
130+
return client;
131+
}
132+
133+
client.on("error", (error) => {
134+
if (handlers?.onError) {
135+
handlers.onError(error);
136+
} else {
137+
logger.error(`Redis cluster client error:`, {
138+
error,
139+
keyPrefix: options.redisOptions?.keyPrefix,
140+
});
141+
}
142+
});
143+
144+
return client;
145+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { defineConfig } from "vitest/config";
2+
3+
export default defineConfig({
4+
test: {
5+
include: ["**/*.test.ts"],
6+
globals: true,
7+
isolate: true,
8+
testTimeout: 10_000,
9+
},
10+
});

internal-packages/run-engine/src/engine/tests/finalRunStatusParity.test.ts

Lines changed: 0 additions & 20 deletions
This file was deleted.

0 commit comments

Comments
 (0)