Skip to content

Commit 709d2f5

Browse files
committed
feat(redis-worker): migrate mollifier queue from LIST to ZSET (Phase B1)
Per-env queue `mollifier:queue:{envId}` switches from a Redis LIST (LPUSH/RPOP) to a sorted set keyed by `createdAtMicros`. Pop semantics are unchanged (FIFO by creation time, now via ZPOPMIN). Entry hashes carry a new `createdAtMicros` field equal to the score. Requeue keeps the original score — createdAt is immutable across retries, so a retried entry continues to pop next by virtue of being the oldest. `maxAttempts` in the drainer bounds the retry loop. The inverted "FIFO retry" test reflects the new (correct) semantics under the score-equals-createdAt invariant. `listEntriesForEnv` reads via ZREVRANGE (newest-first). Orphan-handling tests that injected via LPUSH now use ZADD; queue-depth assertions switch from LLEN to ZCARD. This is the substrate for the listing pagination work in Phase E and for the snapshot-mutate work in Phase B3.
1 parent 015787c commit 709d2f5

4 files changed

Lines changed: 193 additions & 29 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+
Migrate the mollifier per-env queue from a Redis LIST to a ZSET scored by `createdAtMicros`. Internal change; the public `MollifierBuffer` API is unchanged. Entry hashes now carry a `createdAtMicros` field matching the ZSET score; `accept` uses `ZADD`, `pop` uses `ZPOPMIN`, `requeue` reuses the original score so retries do not advance the entry's creation timestamp. Listing (`listEntriesForEnv`) reads via `ZREVRANGE`. This unlocks O(log N + pageSize) paginated listing of buffered runs without changing FIFO drain semantics.

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

Lines changed: 140 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,14 @@ describe("schemas", () => {
2020
status: "QUEUED",
2121
attempts: "0",
2222
createdAt: "2026-05-11T10:00:00.000Z",
23+
createdAtMicros: "1747044000000000",
2324
};
2425
const parsed = BufferEntrySchema.parse(raw);
2526
expect(parsed.runId).toBe("run_abc");
2627
expect(parsed.status).toBe("QUEUED");
2728
expect(parsed.attempts).toBe(0);
2829
expect(parsed.createdAt).toBeInstanceOf(Date);
30+
expect(parsed.createdAtMicros).toBe(1747044000000000);
2931
});
3032

3133
it("BufferEntrySchema parses a FAILED entry with lastError", () => {
@@ -37,6 +39,7 @@ describe("schemas", () => {
3739
status: "FAILED",
3840
attempts: "3",
3941
createdAt: "2026-05-11T10:00:00.000Z",
42+
createdAtMicros: "1747044000000000",
4043
lastError: JSON.stringify({ code: "P2024", message: "connection lost" }),
4144
};
4245
const parsed = BufferEntrySchema.parse(raw);
@@ -210,7 +213,7 @@ describe("MollifierBuffer.pop orphan handling", () => {
210213

211214
try {
212215
// Simulate a TTL-expired orphan: queue ref exists, entry hash does not.
213-
await buffer["redis"].lpush("mollifier:queue:env_a", "run_orphan");
216+
await buffer["redis"].zadd("mollifier:queue:env_a", 1, "run_orphan");
214217

215218
const popped = await buffer.pop("env_a");
216219
expect(popped).toBeNull();
@@ -220,7 +223,7 @@ describe("MollifierBuffer.pop orphan handling", () => {
220223
expect(Object.keys(raw)).toHaveLength(0);
221224

222225
// Queue is drained — the loop pops orphans until empty.
223-
const qLen = await buffer["redis"].llen("mollifier:queue:env_a");
226+
const qLen = await buffer["redis"].zcard("mollifier:queue:env_a");
224227
expect(qLen).toBe(0);
225228
} finally {
226229
await buffer.close();
@@ -243,20 +246,20 @@ describe("MollifierBuffer.pop orphan handling", () => {
243246
});
244247

245248
try {
246-
// Layout (oldest-first, since RPOP takes from tail): orphan, valid, orphan.
247-
// LPUSH puts items at the head, so to get RPOP order [orphan_a, valid, orphan_b]
248-
// we LPUSH in reverse: orphan_b first, then valid, then orphan_a.
249-
await buffer["redis"].lpush("mollifier:queue:env_a", "orphan_b");
249+
// Layout by score (lowest-first, since ZPOPMIN takes the min):
250+
// orphan_a (score 1) → valid (score = its createdAtMicros, large) → orphan_b (score 1e18).
251+
// First pop skips orphan_a, returns valid; orphan_b remains.
252+
await buffer["redis"].zadd("mollifier:queue:env_a", 1, "orphan_a");
250253
await buffer.accept({ runId: "valid", envId: "env_a", orgId: "org_1", payload: "{}" });
251-
await buffer["redis"].lpush("mollifier:queue:env_a", "orphan_a");
254+
await buffer["redis"].zadd("mollifier:queue:env_a", 1e18, "orphan_b");
252255

253256
const popped = await buffer.pop("env_a");
254257
expect(popped).not.toBeNull();
255258
expect(popped!.runId).toBe("valid");
256259
expect(popped!.status).toBe("DRAINING");
257260

258261
// The trailing orphan_b is still in the queue (single pop call).
259-
const remaining = await buffer["redis"].llen("mollifier:queue:env_a");
262+
const remaining = await buffer["redis"].zcard("mollifier:queue:env_a");
260263
expect(remaining).toBe(1);
261264

262265
// A second pop drains the trailing orphan_b. The queue is now
@@ -458,9 +461,13 @@ describe("MollifierBuffer.requeue on missing entry", () => {
458461

459462
describe("MollifierBuffer.requeue ordering", () => {
460463
redisTest(
461-
"requeued entry is popped AFTER other queued entries on the same env (FIFO retry)",
464+
"requeued entry retains its original createdAt and pops next (oldest-first by createdAt)",
462465
{ timeout: 20_000 },
463466
async ({ redisContainer }) => {
467+
// Score == createdAtMicros; requeue does not bump the score. The
468+
// oldest entry continues to pop first across retries. `maxAttempts`
469+
// in the drainer bounds the retry loop for a persistently failing
470+
// entry (after which it goes to the `fail` path, not requeue).
464471
const buffer = new MollifierBuffer({
465472
redisOptions: {
466473
host: redisContainer.getHost(),
@@ -473,20 +480,23 @@ describe("MollifierBuffer.requeue ordering", () => {
473480

474481
try {
475482
await buffer.accept({ runId: "a", envId: "env_a", orgId: "org_1", payload: "{}" });
483+
await new Promise((r) => setTimeout(r, 2));
476484
await buffer.accept({ runId: "b", envId: "env_a", orgId: "org_1", payload: "{}" });
485+
await new Promise((r) => setTimeout(r, 2));
477486
await buffer.accept({ runId: "c", envId: "env_a", orgId: "org_1", payload: "{}" });
478487

479488
const first = await buffer.pop("env_a");
480489
expect(first!.runId).toBe("a");
481490

482491
await buffer.requeue("a");
483492

493+
// a still has the smallest createdAtMicros → pops next.
484494
const next = await buffer.pop("env_a");
485-
expect(next!.runId).toBe("b");
495+
expect(next!.runId).toBe("a");
486496
const after = await buffer.pop("env_a");
487-
expect(after!.runId).toBe("c");
497+
expect(after!.runId).toBe("b");
488498
const last = await buffer.pop("env_a");
489-
expect(last!.runId).toBe("a");
499+
expect(last!.runId).toBe("c");
490500
} finally {
491501
await buffer.close();
492502
}
@@ -1026,6 +1036,124 @@ describe("MollifierBuffer envs set lifecycle", () => {
10261036
);
10271037
});
10281038

1039+
describe("MollifierBuffer ZSET storage", () => {
1040+
redisTest(
1041+
"queue key is a ZSET scored by entry's createdAtMicros",
1042+
{ timeout: 20_000 },
1043+
async ({ redisContainer }) => {
1044+
const buffer = new MollifierBuffer({
1045+
redisOptions: {
1046+
host: redisContainer.getHost(),
1047+
port: redisContainer.getPort(),
1048+
password: redisContainer.getPassword(),
1049+
},
1050+
entryTtlSeconds: 600,
1051+
logger: new Logger("test", "log"),
1052+
});
1053+
1054+
try {
1055+
await buffer.accept({ runId: "z1", envId: "env_z", orgId: "org_1", payload: "{}" });
1056+
1057+
// ZSET-only commands must succeed against the queue key.
1058+
const card = await buffer["redis"].zcard("mollifier:queue:env_z");
1059+
expect(card).toBe(1);
1060+
1061+
const score = await buffer["redis"].zscore("mollifier:queue:env_z", "z1");
1062+
expect(score).not.toBeNull();
1063+
const scoreNum = Number(score);
1064+
expect(Number.isFinite(scoreNum)).toBe(true);
1065+
1066+
// Score matches the entry hash's createdAtMicros field.
1067+
const micros = await buffer["redis"].hget("mollifier:entries:z1", "createdAtMicros");
1068+
expect(micros).not.toBeNull();
1069+
expect(Number(micros)).toBe(scoreNum);
1070+
1071+
// Score is plausibly recent (within last minute as microseconds).
1072+
const nowMicros = Date.now() * 1000;
1073+
expect(scoreNum).toBeGreaterThan(nowMicros - 60_000_000);
1074+
expect(scoreNum).toBeLessThanOrEqual(nowMicros + 1_000_000);
1075+
} finally {
1076+
await buffer.close();
1077+
}
1078+
},
1079+
);
1080+
1081+
redisTest(
1082+
"pop returns entries in ascending createdAtMicros order (FIFO by time, not by member)",
1083+
{ timeout: 20_000 },
1084+
async ({ redisContainer }) => {
1085+
const buffer = new MollifierBuffer({
1086+
redisOptions: {
1087+
host: redisContainer.getHost(),
1088+
port: redisContainer.getPort(),
1089+
password: redisContainer.getPassword(),
1090+
},
1091+
entryTtlSeconds: 600,
1092+
logger: new Logger("test", "log"),
1093+
});
1094+
1095+
try {
1096+
// Insert runIds in reverse-lex order to prove ordering is by score, not member.
1097+
await buffer.accept({ runId: "zzz", envId: "env_o", orgId: "org_1", payload: "{}" });
1098+
await new Promise((r) => setTimeout(r, 5));
1099+
await buffer.accept({ runId: "mmm", envId: "env_o", orgId: "org_1", payload: "{}" });
1100+
await new Promise((r) => setTimeout(r, 5));
1101+
await buffer.accept({ runId: "aaa", envId: "env_o", orgId: "org_1", payload: "{}" });
1102+
1103+
const first = await buffer.pop("env_o");
1104+
expect(first!.runId).toBe("zzz");
1105+
const second = await buffer.pop("env_o");
1106+
expect(second!.runId).toBe("mmm");
1107+
const third = await buffer.pop("env_o");
1108+
expect(third!.runId).toBe("aaa");
1109+
} finally {
1110+
await buffer.close();
1111+
}
1112+
},
1113+
);
1114+
1115+
redisTest(
1116+
"requeue keeps original score; createdAt is immutable across retries",
1117+
{ timeout: 20_000 },
1118+
async ({ redisContainer }) => {
1119+
const buffer = new MollifierBuffer({
1120+
redisOptions: {
1121+
host: redisContainer.getHost(),
1122+
port: redisContainer.getPort(),
1123+
password: redisContainer.getPassword(),
1124+
},
1125+
entryTtlSeconds: 600,
1126+
logger: new Logger("test", "log"),
1127+
});
1128+
1129+
try {
1130+
await buffer.accept({ runId: "rq", envId: "env_rq", orgId: "org_1", payload: "{}" });
1131+
const originalScore = Number(
1132+
await buffer["redis"].zscore("mollifier:queue:env_rq", "rq"),
1133+
);
1134+
const originalMicros = Number(
1135+
await buffer["redis"].hget("mollifier:entries:rq", "createdAtMicros"),
1136+
);
1137+
1138+
await buffer.pop("env_rq");
1139+
await new Promise((r) => setTimeout(r, 5));
1140+
await buffer.requeue("rq");
1141+
1142+
const newScore = Number(
1143+
await buffer["redis"].zscore("mollifier:queue:env_rq", "rq"),
1144+
);
1145+
const newMicros = Number(
1146+
await buffer["redis"].hget("mollifier:entries:rq", "createdAtMicros"),
1147+
);
1148+
expect(newScore).toBe(originalScore);
1149+
expect(newMicros).toBe(originalMicros);
1150+
} finally {
1151+
await buffer.close();
1152+
}
1153+
},
1154+
);
1155+
});
1156+
10291157
describe("MollifierBuffer.listEntriesForEnv", () => {
10301158
redisTest(
10311159
"returns up to maxCount entries from the queue without consuming them",

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

Lines changed: 45 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,15 @@ export class MollifierBuffer {
5353
const entryKey = `mollifier:entries:${input.runId}`;
5454
const queueKey = `mollifier:queue:${input.envId}`;
5555
const orgsKey = "mollifier:orgs";
56-
const createdAt = new Date().toISOString();
56+
const nowMs = Date.now();
57+
const createdAt = new Date(nowMs).toISOString();
58+
// Microsecond epoch. JS only has millisecond precision, so multiple
59+
// accepts in the same ms share a score; ZSET ties resolve by member
60+
// (runId) lex order, which is deterministic and acceptable for FIFO
61+
// pop. The hash carries the same value as `createdAtMicros` so the
62+
// listing helper (Phase E) can read a stable per-run timestamp
63+
// without re-fetching the score.
64+
const createdAtMicros = nowMs * 1000;
5765
const result = await this.redis.acceptMollifierEntry(
5866
entryKey,
5967
queueKey,
@@ -63,6 +71,7 @@ export class MollifierBuffer {
6371
input.orgId,
6472
input.payload,
6573
createdAt,
74+
String(createdAtMicros),
6675
String(this.entryTtlSeconds),
6776
"mollifier:org-envs:",
6877
);
@@ -129,14 +138,18 @@ export class MollifierBuffer {
129138
}
130139

131140
// Read-only listing of currently-queued entries for a single env. Used by
132-
// the dashboard's "Recently queued" surface — LRANGE is non-destructive,
133-
// so the drainer still pops these entries in order. Returns up to
134-
// `maxCount` entries (the most-recently-queued ones, since accept LPUSHes
135-
// onto the head). Each entry hash is fetched separately; a `null` from
136-
// getEntry (TTL expired between LRANGE and HGETALL) is skipped.
141+
// the dashboard's "Recently queued" surface — non-destructive, so the
142+
// drainer still pops these entries in order. Returns up to `maxCount`
143+
// entries newest-first (highest score, which is `createdAtMicros`).
144+
// Each entry hash is fetched separately; a `null` from getEntry (TTL
145+
// expired between ZREVRANGE and HGETALL) is skipped.
137146
async listEntriesForEnv(envId: string, maxCount: number): Promise<BufferEntry[]> {
138147
if (maxCount <= 0) return [];
139-
const runIds = await this.redis.lrange(`mollifier:queue:${envId}`, 0, maxCount - 1);
148+
const runIds = await this.redis.zrevrange(
149+
`mollifier:queue:${envId}`,
150+
0,
151+
maxCount - 1,
152+
);
140153
const entries: BufferEntry[] = [];
141154
for (const runId of runIds) {
142155
const entry = await this.getEntry(runId);
@@ -207,8 +220,9 @@ export class MollifierBuffer {
207220
local orgId = ARGV[3]
208221
local payload = ARGV[4]
209222
local createdAt = ARGV[5]
210-
local ttlSeconds = tonumber(ARGV[6])
211-
local orgEnvsPrefix = ARGV[7]
223+
local createdAtMicros = ARGV[6]
224+
local ttlSeconds = tonumber(ARGV[7])
225+
local orgEnvsPrefix = ARGV[8]
212226
213227
-- Idempotent: refuse if an entry for this runId already exists in any
214228
-- state. Caller-side dedup is also enforced via API idempotency keys,
@@ -224,9 +238,15 @@ export class MollifierBuffer {
224238
'payload', payload,
225239
'status', 'QUEUED',
226240
'attempts', '0',
227-
'createdAt', createdAt)
241+
'createdAt', createdAt,
242+
'createdAtMicros', createdAtMicros)
228243
redis.call('EXPIRE', entryKey, ttlSeconds)
229-
redis.call('LPUSH', queueKey, runId)
244+
-- ZSET keyed by createdAtMicros: ZPOPMIN drains oldest-first
245+
-- (FIFO); listing pagination uses ZREVRANGEBYSCORE with a
246+
-- (createdAt, runId) cursor anchor. Score is stable across the
247+
-- entry's lifecycle — requeue does not bump it (see Phase 3b /
248+
-- Q1 design).
249+
redis.call('ZADD', queueKey, createdAtMicros, runId)
230250
-- Org-level membership: maintained atomically with the per-env
231251
-- queue so the drainer can walk orgs → envs-for-org and
232252
-- schedule one env per org per tick. SADDs are idempotent if the
@@ -248,15 +268,20 @@ export class MollifierBuffer {
248268
249269
local envId = redis.call('HGET', entryKey, 'envId')
250270
local orgId = redis.call('HGET', entryKey, 'orgId')
251-
if not envId then
271+
local createdAtMicros = redis.call('HGET', entryKey, 'createdAtMicros')
272+
if not envId or not createdAtMicros then
252273
return 0
253274
end
254275
255276
local currentAttempts = redis.call('HGET', entryKey, 'attempts')
256277
local nextAttempts = tonumber(currentAttempts or '0') + 1
257278
258279
redis.call('HSET', entryKey, 'status', 'QUEUED', 'attempts', tostring(nextAttempts))
259-
redis.call('LPUSH', queuePrefix .. envId, runId)
280+
-- Requeue re-adds with the ORIGINAL createdAtMicros score.
281+
-- createdAt is immutable across retries (Phase 3b decision).
282+
-- The drainer's maxAttempts caps the retry loop so a poisoned
283+
-- entry doesn't head-of-line forever.
284+
redis.call('ZADD', queuePrefix .. envId, tonumber(createdAtMicros), runId)
260285
-- Re-track the org/env: pop may have SREM'd them when the queue
261286
-- last emptied. SADDs are idempotent if the values are still
262287
-- present.
@@ -296,7 +321,9 @@ export class MollifierBuffer {
296321
-- hash without a TTL, leaking memory. The loop is bounded by queue
297322
-- length; entire Lua script remains atomic.
298323
while true do
299-
local runId = redis.call('RPOP', queueKey)
324+
-- ZPOPMIN returns {member, score} as a flat array, or {} when empty.
325+
local popped = redis.call('ZPOPMIN', queueKey)
326+
local runId = popped[1]
300327
if not runId then
301328
-- Queue is empty AND we have no entry to read orgId from, so
302329
-- skip org-level cleanup. Stale org-envs entries are bounded
@@ -313,9 +340,9 @@ export class MollifierBuffer {
313340
result[raw[i]] = raw[i + 1]
314341
end
315342
-- Prune org-level membership if this pop drained the queue.
316-
-- Atomic with the RPOP above — a concurrent accept AFTER this
317-
-- script will SADD both back along with its LPUSH.
318-
if redis.call('LLEN', queueKey) == 0 then
343+
-- Atomic with the ZPOPMIN above — a concurrent accept AFTER
344+
-- this script will SADD both back along with its ZADD.
345+
if redis.call('ZCARD', queueKey) == 0 then
319346
pruneOrgMembership(result['orgId'])
320347
end
321348
return cjson.encode(result)
@@ -379,6 +406,7 @@ declare module "@internal/redis" {
379406
orgId: string,
380407
payload: string,
381408
createdAt: string,
409+
createdAtMicros: string,
382410
ttlSeconds: string,
383411
orgEnvsPrefix: string,
384412
callback?: Callback<number>,

packages/redis-worker/src/mollifier/schemas.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ export const BufferEntrySchema = z.object({
4444
status: BufferEntryStatus,
4545
attempts: stringToInt,
4646
createdAt: stringToDate,
47+
// Microsecond epoch matching the ZSET queue score. Stable across
48+
// requeues — the score never moves once set at accept time.
49+
createdAtMicros: stringToInt,
4750
lastError: stringToError.optional(),
4851
});
4952

0 commit comments

Comments
 (0)