Skip to content

Commit 08f20c6

Browse files
committed
feat(redis-worker): add MollifierBuffer.mutateSnapshot (Phase B3)
Atomic Lua-driven snapshot mutation for the burst-buffer entry hash. Returns one of three result codes per Q3: - applied_to_snapshot: entry was QUEUED + not materialised; the drainer will see the patched payload on its next pop. - not_found: no entry hash for this runId. - busy: entry is DRAINING / FAILED / materialised — the caller wait-and-bounces through PG (helper lands in B5). Four patch types: - append_tags: union-merges into payload.tags, dedupes against existing values. - set_metadata: replaces metadata + metadataType (last-write-wins). - set_delay: replaces payload.delayUntil. - mark_cancelled: stamps cancelledAt + cancelReason; the drainer bifurcation in Q4 reads these on next pop. 10 new tests cover: not_found, all four patch types (success + absent-field handling), each busy state (DRAINING, FAILED, materialised), and per-runId atomicity under 50-way concurrent appends.
1 parent d727e0f commit 08f20c6

3 files changed

Lines changed: 451 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@trigger.dev/redis-worker": patch
3+
---
4+
5+
Add `MollifierBuffer.mutateSnapshot(runId, patch)` — atomic Lua-driven snapshot mutation for the burst-buffer entry hash. Supports four patch types: `append_tags` (with dedup), `set_metadata`, `set_delay`, `mark_cancelled`. Returns one of three result codes: `applied_to_snapshot` (entry was QUEUED and not materialised), `not_found` (no entry hash), or `busy` (DRAINING / FAILED / materialised — caller wait-and-bounces through PG per Q3 design).

packages/redis-worker/src/mollifier/buffer.test.ts

Lines changed: 351 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1078,6 +1078,357 @@ describe("MollifierBuffer envs set lifecycle", () => {
10781078
);
10791079
});
10801080

1081+
describe("MollifierBuffer.mutateSnapshot", () => {
1082+
redisTest(
1083+
"returns not_found when no entry exists for the runId",
1084+
{ timeout: 20_000 },
1085+
async ({ redisContainer }) => {
1086+
const buffer = new MollifierBuffer({
1087+
redisOptions: {
1088+
host: redisContainer.getHost(),
1089+
port: redisContainer.getPort(),
1090+
password: redisContainer.getPassword(),
1091+
},
1092+
entryTtlSeconds: 600,
1093+
logger: new Logger("test", "log"),
1094+
});
1095+
try {
1096+
const result = await buffer.mutateSnapshot("nope", {
1097+
type: "append_tags",
1098+
tags: ["x"],
1099+
});
1100+
expect(result).toBe("not_found");
1101+
} finally {
1102+
await buffer.close();
1103+
}
1104+
},
1105+
);
1106+
1107+
redisTest(
1108+
"append_tags on QUEUED entry appends and dedupes",
1109+
{ timeout: 20_000 },
1110+
async ({ redisContainer }) => {
1111+
const buffer = new MollifierBuffer({
1112+
redisOptions: {
1113+
host: redisContainer.getHost(),
1114+
port: redisContainer.getPort(),
1115+
password: redisContainer.getPassword(),
1116+
},
1117+
entryTtlSeconds: 600,
1118+
logger: new Logger("test", "log"),
1119+
});
1120+
try {
1121+
await buffer.accept({
1122+
runId: "r1",
1123+
envId: "env_m",
1124+
orgId: "org_1",
1125+
payload: serialiseSnapshot({ tags: ["existing"] }),
1126+
});
1127+
const first = await buffer.mutateSnapshot("r1", {
1128+
type: "append_tags",
1129+
tags: ["existing", "new"],
1130+
});
1131+
expect(first).toBe("applied_to_snapshot");
1132+
1133+
const entry = await buffer.getEntry("r1");
1134+
const payload = JSON.parse(entry!.payload) as { tags: string[] };
1135+
expect(payload.tags).toEqual(["existing", "new"]);
1136+
1137+
// Second mutation appends without duplicating
1138+
const second = await buffer.mutateSnapshot("r1", {
1139+
type: "append_tags",
1140+
tags: ["new", "third"],
1141+
});
1142+
expect(second).toBe("applied_to_snapshot");
1143+
const e2 = await buffer.getEntry("r1");
1144+
const p2 = JSON.parse(e2!.payload) as { tags: string[] };
1145+
expect(p2.tags).toEqual(["existing", "new", "third"]);
1146+
} finally {
1147+
await buffer.close();
1148+
}
1149+
},
1150+
);
1151+
1152+
redisTest(
1153+
"append_tags creates payload.tags when absent",
1154+
{ timeout: 20_000 },
1155+
async ({ redisContainer }) => {
1156+
const buffer = new MollifierBuffer({
1157+
redisOptions: {
1158+
host: redisContainer.getHost(),
1159+
port: redisContainer.getPort(),
1160+
password: redisContainer.getPassword(),
1161+
},
1162+
entryTtlSeconds: 600,
1163+
logger: new Logger("test", "log"),
1164+
});
1165+
try {
1166+
await buffer.accept({
1167+
runId: "r2",
1168+
envId: "env_m",
1169+
orgId: "org_1",
1170+
payload: serialiseSnapshot({ taskId: "t" }),
1171+
});
1172+
const result = await buffer.mutateSnapshot("r2", {
1173+
type: "append_tags",
1174+
tags: ["a", "b"],
1175+
});
1176+
expect(result).toBe("applied_to_snapshot");
1177+
const entry = await buffer.getEntry("r2");
1178+
const payload = JSON.parse(entry!.payload) as { tags: string[] };
1179+
expect(payload.tags).toEqual(["a", "b"]);
1180+
} finally {
1181+
await buffer.close();
1182+
}
1183+
},
1184+
);
1185+
1186+
redisTest(
1187+
"set_metadata replaces metadata + metadataType (last-write-wins)",
1188+
{ timeout: 20_000 },
1189+
async ({ redisContainer }) => {
1190+
const buffer = new MollifierBuffer({
1191+
redisOptions: {
1192+
host: redisContainer.getHost(),
1193+
port: redisContainer.getPort(),
1194+
password: redisContainer.getPassword(),
1195+
},
1196+
entryTtlSeconds: 600,
1197+
logger: new Logger("test", "log"),
1198+
});
1199+
try {
1200+
await buffer.accept({
1201+
runId: "r3",
1202+
envId: "env_m",
1203+
orgId: "org_1",
1204+
payload: serialiseSnapshot({ metadata: '{"v":1}', metadataType: "application/json" }),
1205+
});
1206+
const result = await buffer.mutateSnapshot("r3", {
1207+
type: "set_metadata",
1208+
metadata: '{"v":2}',
1209+
metadataType: "application/json",
1210+
});
1211+
expect(result).toBe("applied_to_snapshot");
1212+
const entry = await buffer.getEntry("r3");
1213+
const payload = JSON.parse(entry!.payload) as {
1214+
metadata: string;
1215+
metadataType: string;
1216+
};
1217+
expect(payload.metadata).toBe('{"v":2}');
1218+
expect(payload.metadataType).toBe("application/json");
1219+
} finally {
1220+
await buffer.close();
1221+
}
1222+
},
1223+
);
1224+
1225+
redisTest(
1226+
"set_delay sets payload.delayUntil",
1227+
{ timeout: 20_000 },
1228+
async ({ redisContainer }) => {
1229+
const buffer = new MollifierBuffer({
1230+
redisOptions: {
1231+
host: redisContainer.getHost(),
1232+
port: redisContainer.getPort(),
1233+
password: redisContainer.getPassword(),
1234+
},
1235+
entryTtlSeconds: 600,
1236+
logger: new Logger("test", "log"),
1237+
});
1238+
try {
1239+
await buffer.accept({
1240+
runId: "r4",
1241+
envId: "env_m",
1242+
orgId: "org_1",
1243+
payload: serialiseSnapshot({ taskId: "t" }),
1244+
});
1245+
const result = await buffer.mutateSnapshot("r4", {
1246+
type: "set_delay",
1247+
delayUntil: "2026-06-01T00:00:00.000Z",
1248+
});
1249+
expect(result).toBe("applied_to_snapshot");
1250+
const entry = await buffer.getEntry("r4");
1251+
const payload = JSON.parse(entry!.payload) as { delayUntil: string };
1252+
expect(payload.delayUntil).toBe("2026-06-01T00:00:00.000Z");
1253+
} finally {
1254+
await buffer.close();
1255+
}
1256+
},
1257+
);
1258+
1259+
redisTest(
1260+
"mark_cancelled stamps cancelledAt + cancelReason",
1261+
{ timeout: 20_000 },
1262+
async ({ redisContainer }) => {
1263+
const buffer = new MollifierBuffer({
1264+
redisOptions: {
1265+
host: redisContainer.getHost(),
1266+
port: redisContainer.getPort(),
1267+
password: redisContainer.getPassword(),
1268+
},
1269+
entryTtlSeconds: 600,
1270+
logger: new Logger("test", "log"),
1271+
});
1272+
try {
1273+
await buffer.accept({
1274+
runId: "r5",
1275+
envId: "env_m",
1276+
orgId: "org_1",
1277+
payload: serialiseSnapshot({ taskId: "t" }),
1278+
});
1279+
const result = await buffer.mutateSnapshot("r5", {
1280+
type: "mark_cancelled",
1281+
cancelledAt: "2026-05-19T12:00:00.000Z",
1282+
cancelReason: "user-initiated",
1283+
});
1284+
expect(result).toBe("applied_to_snapshot");
1285+
const entry = await buffer.getEntry("r5");
1286+
const payload = JSON.parse(entry!.payload) as {
1287+
cancelledAt: string;
1288+
cancelReason: string;
1289+
};
1290+
expect(payload.cancelledAt).toBe("2026-05-19T12:00:00.000Z");
1291+
expect(payload.cancelReason).toBe("user-initiated");
1292+
} finally {
1293+
await buffer.close();
1294+
}
1295+
},
1296+
);
1297+
1298+
redisTest(
1299+
"returns busy when entry is DRAINING",
1300+
{ timeout: 20_000 },
1301+
async ({ redisContainer }) => {
1302+
const buffer = new MollifierBuffer({
1303+
redisOptions: {
1304+
host: redisContainer.getHost(),
1305+
port: redisContainer.getPort(),
1306+
password: redisContainer.getPassword(),
1307+
},
1308+
entryTtlSeconds: 600,
1309+
logger: new Logger("test", "log"),
1310+
});
1311+
try {
1312+
await buffer.accept({
1313+
runId: "rd",
1314+
envId: "env_m",
1315+
orgId: "org_1",
1316+
payload: serialiseSnapshot({ tags: [] }),
1317+
});
1318+
await buffer.pop("env_m");
1319+
const result = await buffer.mutateSnapshot("rd", {
1320+
type: "append_tags",
1321+
tags: ["x"],
1322+
});
1323+
expect(result).toBe("busy");
1324+
} finally {
1325+
await buffer.close();
1326+
}
1327+
},
1328+
);
1329+
1330+
redisTest(
1331+
"returns busy when entry is FAILED",
1332+
{ timeout: 20_000 },
1333+
async ({ redisContainer }) => {
1334+
const buffer = new MollifierBuffer({
1335+
redisOptions: {
1336+
host: redisContainer.getHost(),
1337+
port: redisContainer.getPort(),
1338+
password: redisContainer.getPassword(),
1339+
},
1340+
entryTtlSeconds: 600,
1341+
logger: new Logger("test", "log"),
1342+
});
1343+
try {
1344+
await buffer.accept({
1345+
runId: "rf",
1346+
envId: "env_m",
1347+
orgId: "org_1",
1348+
payload: serialiseSnapshot({ tags: [] }),
1349+
});
1350+
await buffer.pop("env_m");
1351+
await buffer.fail("rf", { code: "X", message: "boom" });
1352+
const result = await buffer.mutateSnapshot("rf", {
1353+
type: "append_tags",
1354+
tags: ["x"],
1355+
});
1356+
expect(result).toBe("busy");
1357+
} finally {
1358+
await buffer.close();
1359+
}
1360+
},
1361+
);
1362+
1363+
redisTest(
1364+
"returns busy when entry is materialised (post-ack grace window)",
1365+
{ timeout: 20_000 },
1366+
async ({ redisContainer }) => {
1367+
const buffer = new MollifierBuffer({
1368+
redisOptions: {
1369+
host: redisContainer.getHost(),
1370+
port: redisContainer.getPort(),
1371+
password: redisContainer.getPassword(),
1372+
},
1373+
entryTtlSeconds: 600,
1374+
logger: new Logger("test", "log"),
1375+
});
1376+
try {
1377+
await buffer.accept({
1378+
runId: "rm",
1379+
envId: "env_m",
1380+
orgId: "org_1",
1381+
payload: serialiseSnapshot({ tags: [] }),
1382+
});
1383+
await buffer.pop("env_m");
1384+
await buffer.ack("rm");
1385+
const result = await buffer.mutateSnapshot("rm", {
1386+
type: "append_tags",
1387+
tags: ["x"],
1388+
});
1389+
expect(result).toBe("busy");
1390+
} finally {
1391+
await buffer.close();
1392+
}
1393+
},
1394+
);
1395+
1396+
redisTest(
1397+
"Lua atomicity serialises concurrent mutations per-runId",
1398+
{ timeout: 20_000 },
1399+
async ({ redisContainer }) => {
1400+
const buffer = new MollifierBuffer({
1401+
redisOptions: {
1402+
host: redisContainer.getHost(),
1403+
port: redisContainer.getPort(),
1404+
password: redisContainer.getPassword(),
1405+
},
1406+
entryTtlSeconds: 600,
1407+
logger: new Logger("test", "log"),
1408+
});
1409+
try {
1410+
await buffer.accept({
1411+
runId: "rcc",
1412+
envId: "env_m",
1413+
orgId: "org_1",
1414+
payload: serialiseSnapshot({ tags: [] }),
1415+
});
1416+
1417+
const tagsToAdd = Array.from({ length: 50 }, (_, i) => `t${i}`);
1418+
await Promise.all(
1419+
tagsToAdd.map((t) => buffer.mutateSnapshot("rcc", { type: "append_tags", tags: [t] })),
1420+
);
1421+
1422+
const entry = await buffer.getEntry("rcc");
1423+
const payload = JSON.parse(entry!.payload) as { tags: string[] };
1424+
expect(payload.tags.sort()).toEqual(tagsToAdd.sort());
1425+
} finally {
1426+
await buffer.close();
1427+
}
1428+
},
1429+
);
1430+
});
1431+
10811432
describe("MollifierBuffer ZSET storage", () => {
10821433
redisTest(
10831434
"queue key is a ZSET scored by entry's createdAtMicros",

0 commit comments

Comments
 (0)