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.
This commit is contained in:
Dan Sutton
2026-05-20 14:52:41 +01:00
parent 015787cf62
commit 709d2f5afb
4 changed files with 193 additions and 29 deletions
@@ -0,0 +1,5 @@
---
"@trigger.dev/redis-worker": patch
---
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.
@@ -20,12 +20,14 @@ describe("schemas", () => {
status: "QUEUED", status: "QUEUED",
attempts: "0", attempts: "0",
createdAt: "2026-05-11T10:00:00.000Z", createdAt: "2026-05-11T10:00:00.000Z",
createdAtMicros: "1747044000000000",
}; };
const parsed = BufferEntrySchema.parse(raw); const parsed = BufferEntrySchema.parse(raw);
expect(parsed.runId).toBe("run_abc"); expect(parsed.runId).toBe("run_abc");
expect(parsed.status).toBe("QUEUED"); expect(parsed.status).toBe("QUEUED");
expect(parsed.attempts).toBe(0); expect(parsed.attempts).toBe(0);
expect(parsed.createdAt).toBeInstanceOf(Date); expect(parsed.createdAt).toBeInstanceOf(Date);
expect(parsed.createdAtMicros).toBe(1747044000000000);
}); });
it("BufferEntrySchema parses a FAILED entry with lastError", () => { it("BufferEntrySchema parses a FAILED entry with lastError", () => {
@@ -37,6 +39,7 @@ describe("schemas", () => {
status: "FAILED", status: "FAILED",
attempts: "3", attempts: "3",
createdAt: "2026-05-11T10:00:00.000Z", createdAt: "2026-05-11T10:00:00.000Z",
createdAtMicros: "1747044000000000",
lastError: JSON.stringify({ code: "P2024", message: "connection lost" }), lastError: JSON.stringify({ code: "P2024", message: "connection lost" }),
}; };
const parsed = BufferEntrySchema.parse(raw); const parsed = BufferEntrySchema.parse(raw);
@@ -210,7 +213,7 @@ describe("MollifierBuffer.pop orphan handling", () => {
try { try {
// Simulate a TTL-expired orphan: queue ref exists, entry hash does not. // Simulate a TTL-expired orphan: queue ref exists, entry hash does not.
await buffer["redis"].lpush("mollifier:queue:env_a", "run_orphan"); await buffer["redis"].zadd("mollifier:queue:env_a", 1, "run_orphan");
const popped = await buffer.pop("env_a"); const popped = await buffer.pop("env_a");
expect(popped).toBeNull(); expect(popped).toBeNull();
@@ -220,7 +223,7 @@ describe("MollifierBuffer.pop orphan handling", () => {
expect(Object.keys(raw)).toHaveLength(0); expect(Object.keys(raw)).toHaveLength(0);
// Queue is drained — the loop pops orphans until empty. // Queue is drained — the loop pops orphans until empty.
const qLen = await buffer["redis"].llen("mollifier:queue:env_a"); const qLen = await buffer["redis"].zcard("mollifier:queue:env_a");
expect(qLen).toBe(0); expect(qLen).toBe(0);
} finally { } finally {
await buffer.close(); await buffer.close();
@@ -243,12 +246,12 @@ describe("MollifierBuffer.pop orphan handling", () => {
}); });
try { try {
// Layout (oldest-first, since RPOP takes from tail): orphan, valid, orphan. // Layout by score (lowest-first, since ZPOPMIN takes the min):
// LPUSH puts items at the head, so to get RPOP order [orphan_a, valid, orphan_b] // orphan_a (score 1) → valid (score = its createdAtMicros, large) → orphan_b (score 1e18).
// we LPUSH in reverse: orphan_b first, then valid, then orphan_a. // First pop skips orphan_a, returns valid; orphan_b remains.
await buffer["redis"].lpush("mollifier:queue:env_a", "orphan_b"); await buffer["redis"].zadd("mollifier:queue:env_a", 1, "orphan_a");
await buffer.accept({ runId: "valid", envId: "env_a", orgId: "org_1", payload: "{}" }); await buffer.accept({ runId: "valid", envId: "env_a", orgId: "org_1", payload: "{}" });
await buffer["redis"].lpush("mollifier:queue:env_a", "orphan_a"); await buffer["redis"].zadd("mollifier:queue:env_a", 1e18, "orphan_b");
const popped = await buffer.pop("env_a"); const popped = await buffer.pop("env_a");
expect(popped).not.toBeNull(); expect(popped).not.toBeNull();
@@ -256,7 +259,7 @@ describe("MollifierBuffer.pop orphan handling", () => {
expect(popped!.status).toBe("DRAINING"); expect(popped!.status).toBe("DRAINING");
// The trailing orphan_b is still in the queue (single pop call). // The trailing orphan_b is still in the queue (single pop call).
const remaining = await buffer["redis"].llen("mollifier:queue:env_a"); const remaining = await buffer["redis"].zcard("mollifier:queue:env_a");
expect(remaining).toBe(1); expect(remaining).toBe(1);
// A second pop drains the trailing orphan_b. The queue is now // A second pop drains the trailing orphan_b. The queue is now
@@ -458,9 +461,13 @@ describe("MollifierBuffer.requeue on missing entry", () => {
describe("MollifierBuffer.requeue ordering", () => { describe("MollifierBuffer.requeue ordering", () => {
redisTest( redisTest(
"requeued entry is popped AFTER other queued entries on the same env (FIFO retry)", "requeued entry retains its original createdAt and pops next (oldest-first by createdAt)",
{ timeout: 20_000 }, { timeout: 20_000 },
async ({ redisContainer }) => { async ({ redisContainer }) => {
// Score == createdAtMicros; requeue does not bump the score. The
// oldest entry continues to pop first across retries. `maxAttempts`
// in the drainer bounds the retry loop for a persistently failing
// entry (after which it goes to the `fail` path, not requeue).
const buffer = new MollifierBuffer({ const buffer = new MollifierBuffer({
redisOptions: { redisOptions: {
host: redisContainer.getHost(), host: redisContainer.getHost(),
@@ -473,7 +480,9 @@ describe("MollifierBuffer.requeue ordering", () => {
try { try {
await buffer.accept({ runId: "a", envId: "env_a", orgId: "org_1", payload: "{}" }); await buffer.accept({ runId: "a", envId: "env_a", orgId: "org_1", payload: "{}" });
await new Promise((r) => setTimeout(r, 2));
await buffer.accept({ runId: "b", envId: "env_a", orgId: "org_1", payload: "{}" }); await buffer.accept({ runId: "b", envId: "env_a", orgId: "org_1", payload: "{}" });
await new Promise((r) => setTimeout(r, 2));
await buffer.accept({ runId: "c", envId: "env_a", orgId: "org_1", payload: "{}" }); await buffer.accept({ runId: "c", envId: "env_a", orgId: "org_1", payload: "{}" });
const first = await buffer.pop("env_a"); const first = await buffer.pop("env_a");
@@ -481,12 +490,13 @@ describe("MollifierBuffer.requeue ordering", () => {
await buffer.requeue("a"); await buffer.requeue("a");
// a still has the smallest createdAtMicros → pops next.
const next = await buffer.pop("env_a"); const next = await buffer.pop("env_a");
expect(next!.runId).toBe("b"); expect(next!.runId).toBe("a");
const after = await buffer.pop("env_a"); const after = await buffer.pop("env_a");
expect(after!.runId).toBe("c"); expect(after!.runId).toBe("b");
const last = await buffer.pop("env_a"); const last = await buffer.pop("env_a");
expect(last!.runId).toBe("a"); expect(last!.runId).toBe("c");
} finally { } finally {
await buffer.close(); await buffer.close();
} }
@@ -1026,6 +1036,124 @@ describe("MollifierBuffer envs set lifecycle", () => {
); );
}); });
describe("MollifierBuffer ZSET storage", () => {
redisTest(
"queue key is a ZSET scored by entry's createdAtMicros",
{ timeout: 20_000 },
async ({ redisContainer }) => {
const buffer = new MollifierBuffer({
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
entryTtlSeconds: 600,
logger: new Logger("test", "log"),
});
try {
await buffer.accept({ runId: "z1", envId: "env_z", orgId: "org_1", payload: "{}" });
// ZSET-only commands must succeed against the queue key.
const card = await buffer["redis"].zcard("mollifier:queue:env_z");
expect(card).toBe(1);
const score = await buffer["redis"].zscore("mollifier:queue:env_z", "z1");
expect(score).not.toBeNull();
const scoreNum = Number(score);
expect(Number.isFinite(scoreNum)).toBe(true);
// Score matches the entry hash's createdAtMicros field.
const micros = await buffer["redis"].hget("mollifier:entries:z1", "createdAtMicros");
expect(micros).not.toBeNull();
expect(Number(micros)).toBe(scoreNum);
// Score is plausibly recent (within last minute as microseconds).
const nowMicros = Date.now() * 1000;
expect(scoreNum).toBeGreaterThan(nowMicros - 60_000_000);
expect(scoreNum).toBeLessThanOrEqual(nowMicros + 1_000_000);
} finally {
await buffer.close();
}
},
);
redisTest(
"pop returns entries in ascending createdAtMicros order (FIFO by time, not by member)",
{ timeout: 20_000 },
async ({ redisContainer }) => {
const buffer = new MollifierBuffer({
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
entryTtlSeconds: 600,
logger: new Logger("test", "log"),
});
try {
// Insert runIds in reverse-lex order to prove ordering is by score, not member.
await buffer.accept({ runId: "zzz", envId: "env_o", orgId: "org_1", payload: "{}" });
await new Promise((r) => setTimeout(r, 5));
await buffer.accept({ runId: "mmm", envId: "env_o", orgId: "org_1", payload: "{}" });
await new Promise((r) => setTimeout(r, 5));
await buffer.accept({ runId: "aaa", envId: "env_o", orgId: "org_1", payload: "{}" });
const first = await buffer.pop("env_o");
expect(first!.runId).toBe("zzz");
const second = await buffer.pop("env_o");
expect(second!.runId).toBe("mmm");
const third = await buffer.pop("env_o");
expect(third!.runId).toBe("aaa");
} finally {
await buffer.close();
}
},
);
redisTest(
"requeue keeps original score; createdAt is immutable across retries",
{ timeout: 20_000 },
async ({ redisContainer }) => {
const buffer = new MollifierBuffer({
redisOptions: {
host: redisContainer.getHost(),
port: redisContainer.getPort(),
password: redisContainer.getPassword(),
},
entryTtlSeconds: 600,
logger: new Logger("test", "log"),
});
try {
await buffer.accept({ runId: "rq", envId: "env_rq", orgId: "org_1", payload: "{}" });
const originalScore = Number(
await buffer["redis"].zscore("mollifier:queue:env_rq", "rq"),
);
const originalMicros = Number(
await buffer["redis"].hget("mollifier:entries:rq", "createdAtMicros"),
);
await buffer.pop("env_rq");
await new Promise((r) => setTimeout(r, 5));
await buffer.requeue("rq");
const newScore = Number(
await buffer["redis"].zscore("mollifier:queue:env_rq", "rq"),
);
const newMicros = Number(
await buffer["redis"].hget("mollifier:entries:rq", "createdAtMicros"),
);
expect(newScore).toBe(originalScore);
expect(newMicros).toBe(originalMicros);
} finally {
await buffer.close();
}
},
);
});
describe("MollifierBuffer.listEntriesForEnv", () => { describe("MollifierBuffer.listEntriesForEnv", () => {
redisTest( redisTest(
"returns up to maxCount entries from the queue without consuming them", "returns up to maxCount entries from the queue without consuming them",
+45 -17
View File
@@ -53,7 +53,15 @@ export class MollifierBuffer {
const entryKey = `mollifier:entries:${input.runId}`; const entryKey = `mollifier:entries:${input.runId}`;
const queueKey = `mollifier:queue:${input.envId}`; const queueKey = `mollifier:queue:${input.envId}`;
const orgsKey = "mollifier:orgs"; const orgsKey = "mollifier:orgs";
const createdAt = new Date().toISOString(); const nowMs = Date.now();
const createdAt = new Date(nowMs).toISOString();
// Microsecond epoch. JS only has millisecond precision, so multiple
// accepts in the same ms share a score; ZSET ties resolve by member
// (runId) lex order, which is deterministic and acceptable for FIFO
// pop. The hash carries the same value as `createdAtMicros` so the
// listing helper (Phase E) can read a stable per-run timestamp
// without re-fetching the score.
const createdAtMicros = nowMs * 1000;
const result = await this.redis.acceptMollifierEntry( const result = await this.redis.acceptMollifierEntry(
entryKey, entryKey,
queueKey, queueKey,
@@ -63,6 +71,7 @@ export class MollifierBuffer {
input.orgId, input.orgId,
input.payload, input.payload,
createdAt, createdAt,
String(createdAtMicros),
String(this.entryTtlSeconds), String(this.entryTtlSeconds),
"mollifier:org-envs:", "mollifier:org-envs:",
); );
@@ -129,14 +138,18 @@ export class MollifierBuffer {
} }
// Read-only listing of currently-queued entries for a single env. Used by // Read-only listing of currently-queued entries for a single env. Used by
// the dashboard's "Recently queued" surface — LRANGE is non-destructive, // the dashboard's "Recently queued" surface — non-destructive, so the
// so the drainer still pops these entries in order. Returns up to // drainer still pops these entries in order. Returns up to `maxCount`
// `maxCount` entries (the most-recently-queued ones, since accept LPUSHes // entries newest-first (highest score, which is `createdAtMicros`).
// onto the head). Each entry hash is fetched separately; a `null` from // Each entry hash is fetched separately; a `null` from getEntry (TTL
// getEntry (TTL expired between LRANGE and HGETALL) is skipped. // expired between ZREVRANGE and HGETALL) is skipped.
async listEntriesForEnv(envId: string, maxCount: number): Promise<BufferEntry[]> { async listEntriesForEnv(envId: string, maxCount: number): Promise<BufferEntry[]> {
if (maxCount <= 0) return []; if (maxCount <= 0) return [];
const runIds = await this.redis.lrange(`mollifier:queue:${envId}`, 0, maxCount - 1); const runIds = await this.redis.zrevrange(
`mollifier:queue:${envId}`,
0,
maxCount - 1,
);
const entries: BufferEntry[] = []; const entries: BufferEntry[] = [];
for (const runId of runIds) { for (const runId of runIds) {
const entry = await this.getEntry(runId); const entry = await this.getEntry(runId);
@@ -207,8 +220,9 @@ export class MollifierBuffer {
local orgId = ARGV[3] local orgId = ARGV[3]
local payload = ARGV[4] local payload = ARGV[4]
local createdAt = ARGV[5] local createdAt = ARGV[5]
local ttlSeconds = tonumber(ARGV[6]) local createdAtMicros = ARGV[6]
local orgEnvsPrefix = ARGV[7] local ttlSeconds = tonumber(ARGV[7])
local orgEnvsPrefix = ARGV[8]
-- Idempotent: refuse if an entry for this runId already exists in any -- Idempotent: refuse if an entry for this runId already exists in any
-- state. Caller-side dedup is also enforced via API idempotency keys, -- state. Caller-side dedup is also enforced via API idempotency keys,
@@ -224,9 +238,15 @@ export class MollifierBuffer {
'payload', payload, 'payload', payload,
'status', 'QUEUED', 'status', 'QUEUED',
'attempts', '0', 'attempts', '0',
'createdAt', createdAt) 'createdAt', createdAt,
'createdAtMicros', createdAtMicros)
redis.call('EXPIRE', entryKey, ttlSeconds) redis.call('EXPIRE', entryKey, ttlSeconds)
redis.call('LPUSH', queueKey, runId) -- ZSET keyed by createdAtMicros: ZPOPMIN drains oldest-first
-- (FIFO); listing pagination uses ZREVRANGEBYSCORE with a
-- (createdAt, runId) cursor anchor. Score is stable across the
-- entry's lifecycle — requeue does not bump it (see Phase 3b /
-- Q1 design).
redis.call('ZADD', queueKey, createdAtMicros, runId)
-- Org-level membership: maintained atomically with the per-env -- Org-level membership: maintained atomically with the per-env
-- queue so the drainer can walk orgs → envs-for-org and -- queue so the drainer can walk orgs → envs-for-org and
-- schedule one env per org per tick. SADDs are idempotent if the -- schedule one env per org per tick. SADDs are idempotent if the
@@ -248,7 +268,8 @@ export class MollifierBuffer {
local envId = redis.call('HGET', entryKey, 'envId') local envId = redis.call('HGET', entryKey, 'envId')
local orgId = redis.call('HGET', entryKey, 'orgId') local orgId = redis.call('HGET', entryKey, 'orgId')
if not envId then local createdAtMicros = redis.call('HGET', entryKey, 'createdAtMicros')
if not envId or not createdAtMicros then
return 0 return 0
end end
@@ -256,7 +277,11 @@ export class MollifierBuffer {
local nextAttempts = tonumber(currentAttempts or '0') + 1 local nextAttempts = tonumber(currentAttempts or '0') + 1
redis.call('HSET', entryKey, 'status', 'QUEUED', 'attempts', tostring(nextAttempts)) redis.call('HSET', entryKey, 'status', 'QUEUED', 'attempts', tostring(nextAttempts))
redis.call('LPUSH', queuePrefix .. envId, runId) -- Requeue re-adds with the ORIGINAL createdAtMicros score.
-- createdAt is immutable across retries (Phase 3b decision).
-- The drainer's maxAttempts caps the retry loop so a poisoned
-- entry doesn't head-of-line forever.
redis.call('ZADD', queuePrefix .. envId, tonumber(createdAtMicros), runId)
-- Re-track the org/env: pop may have SREM'd them when the queue -- Re-track the org/env: pop may have SREM'd them when the queue
-- last emptied. SADDs are idempotent if the values are still -- last emptied. SADDs are idempotent if the values are still
-- present. -- present.
@@ -296,7 +321,9 @@ export class MollifierBuffer {
-- hash without a TTL, leaking memory. The loop is bounded by queue -- hash without a TTL, leaking memory. The loop is bounded by queue
-- length; entire Lua script remains atomic. -- length; entire Lua script remains atomic.
while true do while true do
local runId = redis.call('RPOP', queueKey) -- ZPOPMIN returns {member, score} as a flat array, or {} when empty.
local popped = redis.call('ZPOPMIN', queueKey)
local runId = popped[1]
if not runId then if not runId then
-- Queue is empty AND we have no entry to read orgId from, so -- Queue is empty AND we have no entry to read orgId from, so
-- skip org-level cleanup. Stale org-envs entries are bounded -- skip org-level cleanup. Stale org-envs entries are bounded
@@ -313,9 +340,9 @@ export class MollifierBuffer {
result[raw[i]] = raw[i + 1] result[raw[i]] = raw[i + 1]
end end
-- Prune org-level membership if this pop drained the queue. -- Prune org-level membership if this pop drained the queue.
-- Atomic with the RPOP above — a concurrent accept AFTER this -- Atomic with the ZPOPMIN above — a concurrent accept AFTER
-- script will SADD both back along with its LPUSH. -- this script will SADD both back along with its ZADD.
if redis.call('LLEN', queueKey) == 0 then if redis.call('ZCARD', queueKey) == 0 then
pruneOrgMembership(result['orgId']) pruneOrgMembership(result['orgId'])
end end
return cjson.encode(result) return cjson.encode(result)
@@ -379,6 +406,7 @@ declare module "@internal/redis" {
orgId: string, orgId: string,
payload: string, payload: string,
createdAt: string, createdAt: string,
createdAtMicros: string,
ttlSeconds: string, ttlSeconds: string,
orgEnvsPrefix: string, orgEnvsPrefix: string,
callback?: Callback<number>, callback?: Callback<number>,
@@ -44,6 +44,9 @@ export const BufferEntrySchema = z.object({
status: BufferEntryStatus, status: BufferEntryStatus,
attempts: stringToInt, attempts: stringToInt,
createdAt: stringToDate, createdAt: stringToDate,
// Microsecond epoch matching the ZSET queue score. Stable across
// requeues — the score never moves once set at accept time.
createdAtMicros: stringToInt,
lastError: stringToError.optional(), lastError: stringToError.optional(),
}); });