experimental-a2 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/log-redis.js CHANGED
@@ -1,470 +1,37 @@
1
1
  import { t as A2Error } from "./errors-BJRMd-h6.js";
2
+ import { n as NOTIFY_TIMINGS } from "./internal-gCd5qMry.js";
2
3
  import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
3
- import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
4
4
  import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
5
+ import { t as defaultSleep } from "./log-polling-DZ1MiKLg.js";
6
+ import { t as createRedisLogCore } from "./log-redis-core-CyJ5L8yR.js";
5
7
  //#region src/log-redis.ts
6
8
  /**
7
- * experimental-a2/log-redis — the push-native log backend, on Redis Streams.
9
+ * experimental-a2/log-redis — the Redis-protocol log backend, on Redis
10
+ * Streams. The storage semantics live in log-redis-core.ts (shared
11
+ * with experimental-a2/log-redis-http); this module owns the connection and
12
+ * the live feed.
8
13
  *
9
- * The stream IS the log: each event is a stream entry whose ID is its
10
- * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read and
11
- * `XREAD BLOCK` is a true blocking live feed — no polling anywhere.
12
- * Mutable drain/failure bookkeeping lives in a side hash (stream
13
- * entries are immutable); every write path is one atomic Lua script.
14
- * The conformance suite in test/conformance is the executable
15
- * contract, run against a real spawned `redis-server`.
16
- *
17
- * Timestamps and claim expiry come from the injected clock so tests can
18
- * time-travel against a real server.
14
+ * `stream()` is notify-driven: writes to watched sessions fire a
15
+ * disposable PUBLISH wake-up (feeds hold a TTL'd presence marker the
16
+ * write script checks, so unwatched sessions cost no wake-up), one
17
+ * shared subscriber connection per backend serves every local feed,
18
+ * and each wake triggers an `XRANGE` catch-up read. The notification
19
+ * only decides when to read, never what — a lost one is healed by a
20
+ * safety re-read (NOTIFY_TIMINGS), so delivery never depends on
21
+ * pub/sub. Connections scale with processes (one command client plus
22
+ * one subscriber), not with concurrent viewers.
19
23
  *
20
24
  * Works with any Redis-protocol server on a single instance or a
21
25
  * non-cluster provider (Upstash — durable by default — Redis, Valkey).
22
26
  * Cluster mode is out: the atomic scripts span keys. `ioredis` is an
23
27
  * optional peer dependency; pass `url`, or inject any client exposing
24
- * `call`/`duplicate`/`disconnect`.
25
- */
26
- /** How long each blocking read waits before re-checking for close(). */
27
- const XREAD_BLOCK_MS = 1e4;
28
- /**
29
- * KEYS: log stream, counter, global id hash, session index, metadata, pending, ready.
30
- * ARGV: sessionId, nowMs, n, then per event:
31
- * id, type, payloadJson, causeJson, lane, settled.
32
- * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
33
- * ['dup', hasPending, ...indexes] | ['partial', count] |
34
- * ['foreign', eventId].
35
- */
36
- const APPEND_LUA = `
37
- local function lane_field(lane, suffix)
38
- return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
39
- end
40
- local function enqueue(field, idx, lane)
41
- redis.call('ZADD', KEYS[6], idx, field)
42
- if lane == '' then
43
- redis.call('ZADD', KEYS[7], idx, field)
44
- return
45
- end
46
- local tail_key = lane_field(lane, 'tail')
47
- local tail = redis.call('HGET', KEYS[5], tail_key)
48
- if tail then
49
- redis.call('HSET', KEYS[5], tail .. ':next', field)
50
- else
51
- redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
52
- redis.call('ZADD', KEYS[7], idx, field)
53
- end
54
- redis.call('HSET', KEYS[5], tail_key, field)
55
- end
56
- local n = tonumber(ARGV[3])
57
- local base = tonumber(redis.call('GET', KEYS[2]) or '0')
58
- local has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
59
- if n == 0 then return { 'empty', has_pending } end
60
- local dups = {}
61
- for i = 1, n do
62
- local id = ARGV[3 + (i - 1) * 6 + 1]
63
- local existing = redis.call('HGET', KEYS[3], id)
64
- if existing then
65
- local rec = cjson.decode(existing)
66
- if rec[1] ~= ARGV[1] then return { 'foreign', id } end
67
- dups[#dups + 1] = tostring(rec[2])
68
- end
69
- end
70
- if #dups == n then
71
- local res = { 'dup', has_pending }
72
- for i = 1, #dups do res[#res + 1] = dups[i] end
73
- return res
74
- end
75
- if #dups > 0 then return { 'partial', tostring(#dups) } end
76
- for i = 1, n do
77
- local id = ARGV[3 + (i - 1) * 6 + 1]
78
- local typ = ARGV[3 + (i - 1) * 6 + 2]
79
- local payload = ARGV[3 + (i - 1) * 6 + 3]
80
- local cause = ARGV[3 + (i - 1) * 6 + 4]
81
- local lane = ARGV[3 + (i - 1) * 6 + 5]
82
- local settled = ARGV[3 + (i - 1) * 6 + 6]
83
- local idx = base + i
84
- redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
85
- 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
86
- 'cause', cause, 'lane', lane)
87
- redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
88
- redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
89
- if settled == '1' then
90
- redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
91
- else
92
- enqueue(tostring(idx), idx, lane)
93
- end
94
- end
95
- redis.call('SET', KEYS[2], tostring(base + n))
96
- redis.call('ZADD', KEYS[4], 0, ARGV[1])
97
- has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
98
- return { 'ok', tostring(base + 1), has_pending }
99
- `;
100
- /** KEYS: counter, meta, pending, ready. ARGV: idx, attempt, error, maxFailures, nowMs. */
101
- const FAIL_ATTEMPT_LUA = `
102
- local function lane_field(lane, suffix)
103
- return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
104
- end
105
- local function settle(field)
106
- redis.call('ZREM', KEYS[3], field)
107
- redis.call('ZREM', KEYS[4], field)
108
- local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
109
- if lane == '' then return end
110
- local head_key = lane_field(lane, 'head')
111
- if redis.call('HGET', KEYS[2], head_key) ~= field then return end
112
- local next_field = redis.call('HGET', KEYS[2], field .. ':next')
113
- while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
114
- next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
115
- end
116
- if next_field then
117
- redis.call('HSET', KEYS[2], head_key, next_field)
118
- if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
119
- redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
120
- end
121
- else
122
- redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
123
- end
124
- end
125
- local idx = tonumber(ARGV[1])
126
- if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
127
- return { 'missing', '0' }
128
- end
129
- local field = ARGV[1]
130
- local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
131
- local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
132
- if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
133
- return { 'superseded', tostring(failures) }
134
- end
135
- if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
136
- return { 'dead_lettered', tostring(failures) }
137
- end
138
- if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
139
- tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
140
- redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
141
- return { 'failed', tostring(failures) }
142
- end
143
- if redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
144
- return { 'superseded', tostring(failures) }
145
- end
146
- failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
147
- redis.call('HSET', KEYS[2],
148
- field .. ':e', ARGV[3],
149
- field .. ':lf', ARGV[5],
150
- field .. ':lfa', ARGV[2])
151
- redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
152
- if failures >= tonumber(ARGV[4]) then
153
- redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
154
- redis.call('ZREM', KEYS[4], field)
155
- return { 'dead_lettered', tostring(failures) }
156
- end
157
- return { 'failed', tostring(failures) }
158
- `;
159
- /**
160
- * KEYS: log, counter, meta, pending, ready.
161
- * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
162
- */
163
- const CLAIM_AVAILABLE_LUA = `
164
- if redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end
165
- local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
166
- local excluded = {}
167
- for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
168
- local claimed = {}
169
- local retry_at = nil
170
- for _, field in ipairs(ready) do
171
- if not excluded[field] then
172
- local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')
173
- if expires > tonumber(ARGV[2]) then
174
- if not retry_at or expires < retry_at then retry_at = expires end
175
- else
176
- local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
177
- if #entries == 0 then return { 'missing', field } end
178
- local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)
179
- redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
180
- redis.call('HSET', KEYS[3],
181
- field .. ':lc', ARGV[2],
182
- field .. ':ch', ARGV[1],
183
- field .. ':ce', ARGV[3])
184
- claimed[#claimed + 1] = {
185
- tostring(attempt),
186
- redis.call('HGET', KEYS[3], field .. ':a') or '0',
187
- redis.call('HGET', KEYS[3], field .. ':e') or false,
188
- redis.call('HGET', KEYS[3], field .. ':fc') or false,
189
- ARGV[2],
190
- redis.call('HGET', KEYS[3], field .. ':lf') or false,
191
- redis.call('HGET', KEYS[3], field .. ':lfa') or false,
192
- ARGV[1],
193
- ARGV[3],
194
- entries[1]
195
- }
196
- end
197
- end
198
- end
199
- if #claimed > 0 then
200
- local result = { 'claimed', tostring(#claimed) }
201
- for _, claim in ipairs(claimed) do
202
- for _, value in ipairs(claim) do result[#result + 1] = value end
203
- end
204
- return result
205
- end
206
- if retry_at then return { 'busy', tostring(retry_at) } end
207
- return { 'settled' }
208
- `;
209
- /**
210
- * KEYS: counter, meta. ARGV: holder, nowMs, expiresAtMs, n, indexes.
211
- */
212
- const RENEW_CLAIMS_LUA = `
213
- local total = tonumber(redis.call('GET', KEYS[1]) or '0')
214
- local renewed = {}
215
- for i = 1, tonumber(ARGV[4]) do
216
- local field = ARGV[4 + i]
217
- local idx = tonumber(field)
218
- local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
219
- if idx >= 1 and idx <= total and
220
- tonumber(ARGV[3]) > tonumber(ARGV[2]) and
221
- redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
222
- redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
223
- redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
224
- expires > tonumber(ARGV[2]) then
225
- redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
226
- renewed[#renewed + 1] = field
227
- end
228
- end
229
- return renewed
230
- `;
231
- /**
232
- * KEYS: log, counter, ids, session index, meta, pending, ready.
233
- * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
234
- * id, type, payloadJson, lane, settled.
28
+ * `call`/`duplicate`/`on`/`disconnect`.
235
29
  */
236
- const COMPLETE_ATTEMPT_LUA = `
237
- local function lane_field(lane, suffix)
238
- return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
239
- end
240
- local function settle(field)
241
- redis.call('ZREM', KEYS[6], field)
242
- redis.call('ZREM', KEYS[7], field)
243
- local lane = redis.call('HGET', KEYS[5], field .. ':l') or ''
244
- if lane == '' then return end
245
- local head_key = lane_field(lane, 'head')
246
- if redis.call('HGET', KEYS[5], head_key) ~= field then return end
247
- local next_field = redis.call('HGET', KEYS[5], field .. ':next')
248
- while next_field and redis.call('HEXISTS', KEYS[5], next_field .. ':p') == 1 do
249
- next_field = redis.call('HGET', KEYS[5], next_field .. ':next')
250
- end
251
- if next_field then
252
- redis.call('HSET', KEYS[5], head_key, next_field)
253
- if redis.call('HEXISTS', KEYS[5], next_field .. ':f') == 0 then
254
- redis.call('ZADD', KEYS[7], tonumber(next_field), next_field)
255
- end
256
- else
257
- redis.call('HDEL', KEYS[5], head_key, lane_field(lane, 'tail'))
258
- end
259
- end
260
- local function enqueue(field, idx, lane)
261
- redis.call('ZADD', KEYS[6], idx, field)
262
- if lane == '' then
263
- redis.call('ZADD', KEYS[7], idx, field)
264
- return
265
- end
266
- local tail_key = lane_field(lane, 'tail')
267
- local tail = redis.call('HGET', KEYS[5], tail_key)
268
- if tail then
269
- redis.call('HSET', KEYS[5], tail .. ':next', field)
270
- else
271
- redis.call('HSET', KEYS[5], lane_field(lane, 'head'), field)
272
- redis.call('ZADD', KEYS[7], idx, field)
273
- end
274
- redis.call('HSET', KEYS[5], tail_key, field)
275
- end
276
- local parent = ARGV[3]
277
- local attempt = tonumber(ARGV[4])
278
- local n = tonumber(ARGV[5])
279
- local total = tonumber(redis.call('GET', KEYS[2]) or '0')
280
- if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
281
- local dispatches = tonumber(redis.call('HGET', KEYS[5], parent .. ':d') or '0')
282
- local processed = redis.call('HGET', KEYS[5], parent .. ':p')
283
- local processed_attempt = tonumber(redis.call('HGET', KEYS[5], parent .. ':pa') or '0')
284
- if processed then
285
- if processed_attempt ~= attempt then return { 'superseded' } end
286
- local raw_recorded_ids = redis.call('HGET', KEYS[5], parent .. ':ri')
287
- if not raw_recorded_ids then return { 'partial' } end
288
- local recorded_ids = cjson.decode(raw_recorded_ids)
289
- if #recorded_ids ~= n then return { 'partial' } end
290
- local indexes = {}
291
- for i = 1, n do
292
- local id = ARGV[5 + (i - 1) * 5 + 1]
293
- if recorded_ids[i] ~= id then return { 'partial' } end
294
- local existing = redis.call('HGET', KEYS[3], id)
295
- if not existing then return { 'partial' } end
296
- local rec = cjson.decode(existing)
297
- if rec[1] ~= ARGV[1] then return { 'partial' } end
298
- local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
299
- if #entries == 0 then return { 'partial' } end
300
- local values = entries[1][2]
301
- local cause = nil
302
- for j = 1, #values, 2 do
303
- if values[j] == 'cause' then cause = values[j + 1] break end
304
- end
305
- if not cause or cause == '' then return { 'partial' } end
306
- local decoded = cjson.decode(cause)
307
- if tonumber(decoded['index']) ~= tonumber(parent) or
308
- tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end
309
- indexes[#indexes + 1] = tostring(rec[2])
310
- end
311
- local result = { 'duplicate' }
312
- for _, idx in ipairs(indexes) do result[#result + 1] = idx end
313
- return result
314
- end
315
- if dispatches ~= attempt then return { 'superseded' } end
316
- if redis.call('HEXISTS', KEYS[5], parent .. ':ch') == 0 or
317
- redis.call('HEXISTS', KEYS[5], parent .. ':f') == 1 or
318
- tonumber(redis.call('HGET', KEYS[5], parent .. ':lfa') or '0') == attempt then
319
- return { 'superseded' }
320
- end
321
- for i = 1, n do
322
- local id = ARGV[5 + (i - 1) * 5 + 1]
323
- if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
324
- end
325
- local returned_ids = {}
326
- for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
327
- local returned_json = '[]'
328
- if n > 0 then returned_json = cjson.encode(returned_ids) end
329
- redis.call('HSET', KEYS[5],
330
- parent .. ':p', ARGV[2],
331
- parent .. ':pa', ARGV[4],
332
- parent .. ':ri', returned_json)
333
- redis.call('HDEL', KEYS[5], parent .. ':ch', parent .. ':ce')
334
- settle(parent)
335
- local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
336
- for i = 1, n do
337
- local id = ARGV[5 + (i - 1) * 5 + 1]
338
- local typ = ARGV[5 + (i - 1) * 5 + 2]
339
- local payload = ARGV[5 + (i - 1) * 5 + 3]
340
- local lane = ARGV[5 + (i - 1) * 5 + 4]
341
- local settled = ARGV[5 + (i - 1) * 5 + 5]
342
- local idx = total + i
343
- redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
344
- 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
345
- 'cause', cause, 'lane', lane)
346
- redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
347
- redis.call('HSET', KEYS[5], tostring(idx) .. ':l', lane)
348
- if settled == '1' then
349
- redis.call('HSET', KEYS[5], tostring(idx) .. ':p', ARGV[2])
350
- else
351
- enqueue(tostring(idx), idx, lane)
352
- end
353
- end
354
- redis.call('SET', KEYS[2], tostring(total + n))
355
- redis.call('ZADD', KEYS[4], 0, ARGV[1])
356
- return { 'ok', tostring(total + 1) }
357
- `;
358
- /** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
359
- const PUT_SNAPSHOT_LUA = `
360
- local cur = redis.call('GET', KEYS[1])
361
- if cur and tonumber(cjson.decode(cur)['index']) >= tonumber(ARGV[1]) then
362
- return 0
363
- end
364
- redis.call('SET', KEYS[1], ARGV[2])
365
- return 1
366
- `;
367
- /** KEYS: snapshot, log stream. Returns one atomic cache-plus-tail read. */
368
- const READ_STATE_LUA = `
369
- local snapshot = redis.call('GET', KEYS[1])
370
- local after = 0
371
- if snapshot then
372
- after = tonumber(cjson.decode(snapshot)['index'])
373
- end
374
- local events = redis.call('XRANGE', KEYS[2], tostring(after + 1) .. '-0', '+')
375
- return { snapshot or '', events }
376
- `;
377
- const fieldMap = (fields) => {
378
- const map = {};
379
- for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
380
- return map;
381
- };
382
- const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
383
- const toEvent = (sessionId, entry) => {
384
- const fields = fieldMap(entry[1]);
385
- return {
386
- id: fields["id"],
387
- type: fields["type"],
388
- payload: JSON.parse(fields["payload"]),
389
- index: entryIndex(entry[0]),
390
- sessionId,
391
- createdAt: new Date(Number(fields["created_at"]))
392
- };
393
- };
394
- const json = (value) => JSON.stringify(value) ?? "null";
395
- const escapeGlob = (value) => value.replaceAll("\\", "\\\\").replaceAll("*", "\\*").replaceAll("?", "\\?").replaceAll("[", "\\[").replaceAll("]", "\\]");
396
- const toCause = (raw) => {
397
- if (raw === "" || raw === void 0) return null;
398
- const value = JSON.parse(raw);
399
- if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
400
- if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
401
- return {
402
- index: Number(value.index),
403
- attempt: Number(value.attempt),
404
- ...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
405
- };
406
- };
407
- const toClaimAvailableResult = (sessionId, reply) => {
408
- const outcome = String(reply[0]);
409
- if (outcome === "claimed") {
410
- const count = Number(reply[1]);
411
- const events = [];
412
- for (let i = 0; i < count; i++) {
413
- const offset = 2 + i * 10;
414
- const entry = reply[offset + 9];
415
- const event = toEvent(sessionId, entry);
416
- const fields = fieldMap(entry[1]);
417
- events.push(Object.assign(event, {
418
- cause: toCause(fields["cause"]),
419
- lane: fields["lane"] === "" ? null : fields["lane"],
420
- processedAt: null,
421
- processedByAttempt: null,
422
- returnedEventIds: null,
423
- firstClaimedAt: reply[offset + 3] === null ? null : new Date(Number(reply[offset + 3])),
424
- lastClaimedAt: new Date(Number(reply[offset + 4])),
425
- attemptCount: Number(reply[offset]),
426
- claimHolder: String(reply[offset + 7]),
427
- claimExpiresAt: new Date(Number(reply[offset + 8])),
428
- failureCount: Number(reply[offset + 1]),
429
- lastFailedAt: reply[offset + 5] === null ? null : new Date(Number(reply[offset + 5])),
430
- lastFailedAttempt: reply[offset + 6] === null ? null : Number(reply[offset + 6]),
431
- lastError: reply[offset + 2] === null ? null : String(reply[offset + 2]),
432
- failedAt: null
433
- }));
434
- }
435
- return {
436
- outcome: "claimed",
437
- events
438
- };
439
- }
440
- if (outcome === "busy") return {
441
- outcome,
442
- retryAt: new Date(Number(reply[1]))
443
- };
444
- if (outcome === "settled") return { outcome };
445
- throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
446
- };
447
- const wrap = async (fn) => {
448
- try {
449
- return await fn();
450
- } catch (err) {
451
- if (err instanceof A2Error || err instanceof TypeError) throw err;
452
- throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
453
- }
454
- };
455
30
  function redis(options = {}) {
456
31
  const clock = options.clock ?? SYSTEM_CLOCK;
457
- const generateId = options.ids ?? RANDOM_IDS;
32
+ const ids = options.ids ?? RANDOM_IDS;
458
33
  const prefix = options.keyPrefix ?? "a2";
459
34
  if (!options.client && !options.url) throw new TypeError("redis() needs a url or an injected client");
460
- const logKey = (s) => `${prefix}:${s}:log`;
461
- const countKey = (s) => `${prefix}:${s}:count`;
462
- const metaKey = (s) => `${prefix}:${s}:meta`;
463
- const pendingKey = (s) => `${prefix}:${s}:pending`;
464
- const readyKey = (s) => `${prefix}:${s}:ready`;
465
- const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
466
- const idsKey = `${prefix}:ids`;
467
- const sessionsKey = `${prefix}:sessions`;
468
35
  const connection = retryableLazy(async () => {
469
36
  if (options.client) return options.client;
470
37
  return new (await (import("ioredis").catch(() => {
@@ -472,381 +39,133 @@ function redis(options = {}) {
472
39
  }))).default(options.url);
473
40
  });
474
41
  const client = connection.get;
475
- const evalScript = async (script, keys, args) => {
476
- return (await client()).call("EVAL", script, keys.length, ...keys, ...args);
477
- };
478
- /** Every live-feed connection, so close() can sever them. */
479
- const streamConnections = /* @__PURE__ */ new Set();
480
- /** XRANGE + meta merge — the raw read both `read` and dup-returns use. */
481
- const readRange = async (sessionId, afterIndex) => {
482
- const c = await client();
483
- const entries = await c.call("XRANGE", logKey(sessionId), `${afterIndex + 1}-0`, "+");
484
- if (entries.length === 0) return [];
485
- const meta = fieldMap(await c.call("HGETALL", metaKey(sessionId)) ?? []);
486
- return entries.map((entry) => {
487
- const event = toEvent(sessionId, entry);
488
- const idx = event.index;
489
- const fields = fieldMap(entry[1]);
490
- const processedAt = meta[`${idx}:p`];
491
- const processedByAttempt = meta[`${idx}:pa`];
492
- const returnedEventIds = meta[`${idx}:ri`];
493
- const claimHolder = meta[`${idx}:ch`];
494
- const claimExpiresAt = meta[`${idx}:ce`];
495
- const failedAt = meta[`${idx}:f`];
496
- const firstClaimedAt = meta[`${idx}:fc`];
497
- const lastClaimedAt = meta[`${idx}:lc`];
498
- const lastFailedAt = meta[`${idx}:lf`];
499
- const lastFailedAttempt = meta[`${idx}:lfa`];
500
- return Object.assign(event, {
501
- cause: toCause(fields["cause"]),
502
- lane: fields["lane"] === "" ? null : fields["lane"],
503
- processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
504
- processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
505
- returnedEventIds: returnedEventIds === void 0 ? null : JSON.parse(returnedEventIds),
506
- firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
507
- lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
508
- attemptCount: Number(meta[`${idx}:d`] ?? 0),
509
- claimHolder: claimHolder ?? null,
510
- claimExpiresAt: claimExpiresAt === void 0 ? null : new Date(Number(claimExpiresAt)),
511
- failureCount: Number(meta[`${idx}:a`] ?? 0),
512
- lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
513
- lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
514
- lastError: meta[`${idx}:e`] ?? null,
515
- failedAt: failedAt === void 0 ? null : new Date(Number(failedAt))
42
+ const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
43
+ const core = createRedisLogCore({
44
+ call: async (command, ...args) => {
45
+ return (await client()).call(command, ...args);
46
+ },
47
+ clock,
48
+ ids,
49
+ keyPrefix: prefix,
50
+ notify: (sessionId, count) => {
51
+ client().then((c) => c.call("publish", notifyChannel(sessionId), count)).catch(() => {});
52
+ }
53
+ });
54
+ let severed = false;
55
+ let subscriber = null;
56
+ /** channel parked-feed wake-ups, armed before each catch-up read. */
57
+ const wakers = /* @__PURE__ */ new Map();
58
+ /** channel refcounted subscription held for each live iterator. */
59
+ const subscriptions = /* @__PURE__ */ new Map();
60
+ /**
61
+ * channel last presence-marker refresh, throttling the SET.
62
+ * Deliberately real time (`Date.now()`), not the injected clock:
63
+ * the marker's PX expiry runs on server real time, and the throttle
64
+ * must tick with the TTL it refreshes or time-traveling tests would
65
+ * desync the two. Process-local and never stored, so the injected
66
+ * clock's every-stored-timestamp pledge is untouched.
67
+ */
68
+ const watchedRefreshedAt = /* @__PURE__ */ new Map();
69
+ const getSubscriber = () => {
70
+ subscriber ??= (async () => {
71
+ const conn = (await client()).duplicate();
72
+ conn.on("message", (channel) => {
73
+ const set = wakers.get(channel);
74
+ if (!set) return;
75
+ for (const wake of set) wake();
516
76
  });
77
+ return conn;
78
+ })();
79
+ return subscriber;
80
+ };
81
+ const acquireChannel = async (channel) => {
82
+ const existing = subscriptions.get(channel);
83
+ if (existing) {
84
+ existing.refs += 1;
85
+ try {
86
+ await existing.ready;
87
+ } catch (err) {
88
+ existing.refs -= 1;
89
+ throw err;
90
+ }
91
+ return;
92
+ }
93
+ const lease = {
94
+ refs: 1,
95
+ ready: getSubscriber().then((conn) => conn.call("subscribe", channel))
96
+ };
97
+ subscriptions.set(channel, lease);
98
+ try {
99
+ await lease.ready;
100
+ } catch (err) {
101
+ if (subscriptions.get(channel) === lease) subscriptions.delete(channel);
102
+ throw err;
103
+ }
104
+ };
105
+ const releaseChannel = (channel) => {
106
+ const lease = subscriptions.get(channel);
107
+ if (!lease) return;
108
+ lease.refs -= 1;
109
+ if (lease.refs > 0) return;
110
+ subscriptions.delete(channel);
111
+ watchedRefreshedAt.delete(channel);
112
+ getSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
113
+ };
114
+ const armWaker = (channel) => {
115
+ let wake;
116
+ const wakeup = new Promise((resolve) => {
117
+ wake = resolve;
517
118
  });
119
+ let set = wakers.get(channel);
120
+ if (!set) {
121
+ set = /* @__PURE__ */ new Set();
122
+ wakers.set(channel, set);
123
+ }
124
+ set.add(wake);
125
+ return {
126
+ wakeup,
127
+ wake,
128
+ disarm: () => {
129
+ set.delete(wake);
130
+ if (set.size === 0) wakers.delete(channel);
131
+ }
132
+ };
518
133
  };
519
134
  return {
520
- async append(sessionId, events) {
521
- return wrap(async () => {
522
- const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
523
- if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
524
- const now = clock.now().getTime();
525
- const withIds = events.map((e) => ({
526
- id: e.id ?? generateId(),
527
- type: e.type,
528
- payload: e.payload,
529
- cause: e.cause ? { ...e.cause } : null,
530
- lane: e.lane ?? null,
531
- settled: e.settled === true
532
- }));
533
- const args = [
534
- sessionId,
535
- now,
536
- withIds.length,
537
- ...withIds.flatMap((e) => [
538
- e.id,
539
- e.type,
540
- json(e.payload),
541
- e.cause ? json(e.cause) : "",
542
- e.lane ?? "",
543
- e.settled ? "1" : "0"
544
- ])
545
- ];
546
- const reply = await evalScript(APPEND_LUA, [
547
- logKey(sessionId),
548
- countKey(sessionId),
549
- idsKey,
550
- sessionsKey,
551
- metaKey(sessionId),
552
- pendingKey(sessionId),
553
- readyKey(sessionId)
554
- ], args);
555
- const [verdict] = reply;
556
- if (verdict === "empty") return {
557
- events: [],
558
- hasPending: reply[1] === "1"
559
- };
560
- if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
561
- if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
562
- if (verdict === "dup") {
563
- const hasPending = reply[1] === "1";
564
- const wanted = new Set(reply.slice(2).map(Number));
565
- const min = Math.min(...wanted);
566
- const existing = (await readRange(sessionId, min - 1)).filter((row) => wanted.has(row.index));
567
- return {
568
- events: idempotentReplay(events, existing),
569
- hasPending
570
- };
571
- }
572
- const base = Number(reply[1]) - 1;
573
- return {
574
- events: withIds.map((e, i) => ({
575
- id: e.id,
576
- type: e.type,
577
- payload: structuredClone(e.payload),
578
- index: base + 1 + i,
579
- sessionId,
580
- createdAt: new Date(now),
581
- cause: e.cause,
582
- lane: e.lane,
583
- processedAt: e.settled ? new Date(now) : null,
584
- processedByAttempt: null,
585
- returnedEventIds: null,
586
- firstClaimedAt: null,
587
- lastClaimedAt: null,
588
- attemptCount: 0,
589
- claimHolder: null,
590
- claimExpiresAt: null,
591
- failureCount: 0,
592
- lastFailedAt: null,
593
- lastFailedAttempt: null,
594
- lastError: null,
595
- failedAt: null
596
- })),
597
- hasPending: reply[2] === "1"
598
- };
599
- });
600
- },
601
- async read(sessionId, opts) {
602
- return wrap(async () => {
603
- return await readRange(sessionId, opts?.afterIndex ?? 0);
604
- });
605
- },
606
- async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
607
- return wrap(async () => {
608
- const now = clock.now().getTime();
609
- const expiresAt = expiresAtMs ?? now + ttlMs;
610
- const excluded = excludeIndexes ?? [];
611
- const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
612
- logKey(sessionId),
613
- countKey(sessionId),
614
- metaKey(sessionId),
615
- pendingKey(sessionId),
616
- readyKey(sessionId)
617
- ], [
618
- holder,
619
- now,
620
- expiresAt,
621
- excluded.length,
622
- ...excluded
623
- ]);
624
- return toClaimAvailableResult(sessionId, reply);
625
- });
626
- },
627
- async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
628
- return wrap(async () => {
629
- const now = clock.now().getTime();
630
- const expiresAt = expiresAtMs ?? now + ttlMs;
631
- return (await evalScript(RENEW_CLAIMS_LUA, [countKey(sessionId), metaKey(sessionId)], [
632
- holder,
633
- now,
634
- expiresAt,
635
- indexes.length,
636
- ...indexes
637
- ])).map(Number);
638
- });
639
- },
640
- async completeAttempt({ sessionId, index, attempt, events }) {
641
- return wrap(async () => {
642
- const ids = events.map((event) => event.id);
643
- if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
644
- const now = clock.now().getTime();
645
- const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
646
- logKey(sessionId),
647
- countKey(sessionId),
648
- idsKey,
649
- sessionsKey,
650
- metaKey(sessionId),
651
- pendingKey(sessionId),
652
- readyKey(sessionId)
653
- ], [
654
- sessionId,
655
- now,
656
- index,
657
- attempt,
658
- events.length,
659
- ...events.flatMap((event) => [
660
- event.id,
661
- event.type,
662
- json(event.payload),
663
- event.lane ?? "",
664
- event.settled === true ? "1" : "0"
665
- ])
666
- ]);
667
- const outcome = reply[0];
668
- if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
669
- if (outcome === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event ids do not match the completed attempt");
670
- if (outcome === "superseded") return { outcome };
671
- const cause = {
672
- index,
673
- attempt
674
- };
675
- if (outcome === "ok") {
676
- const base = Number(reply[1]) - 1;
677
- return {
678
- outcome: "completed",
679
- events: events.map((event, i) => ({
680
- id: event.id,
681
- type: event.type,
682
- payload: structuredClone(event.payload),
683
- index: base + 1 + i,
684
- sessionId,
685
- createdAt: new Date(now),
686
- cause,
687
- lane: event.lane ?? null,
688
- processedAt: event.settled === true ? new Date(now) : null,
689
- processedByAttempt: null,
690
- returnedEventIds: null,
691
- firstClaimedAt: null,
692
- lastClaimedAt: null,
693
- attemptCount: 0,
694
- claimHolder: null,
695
- claimExpiresAt: null,
696
- failureCount: 0,
697
- lastFailedAt: null,
698
- lastFailedAttempt: null,
699
- lastError: null,
700
- failedAt: null
701
- }))
702
- };
703
- }
704
- if (outcome === "duplicate") {
705
- if (events.length === 0) return {
706
- outcome: "completed",
707
- events: []
708
- };
709
- const wanted = new Set(reply.slice(1).map(Number));
710
- const min = Math.min(...wanted);
711
- const rows = await readRange(sessionId, min - 1);
712
- const byIndex = new Map(rows.map((row) => [row.index, row]));
713
- const existing = reply.slice(1).map((value) => byIndex.get(Number(value)));
714
- return {
715
- outcome: "completed",
716
- events: idempotentReplay(events, existing)
717
- };
718
- }
719
- throw new TypeError(`unexpected completion outcome '${outcome}'`);
720
- });
721
- },
722
- async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
723
- return wrap(async () => {
724
- const reply = await evalScript(FAIL_ATTEMPT_LUA, [
725
- countKey(sessionId),
726
- metaKey(sessionId),
727
- pendingKey(sessionId),
728
- readyKey(sessionId)
729
- ], [
730
- index,
731
- attempt,
732
- error,
733
- maxFailures,
734
- clock.now().getTime()
735
- ]);
736
- const outcome = reply[0];
737
- if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
738
- return {
739
- outcome,
740
- failureCount: Number(reply[1])
741
- };
742
- });
743
- },
744
- async readState(sessionId, reducerName) {
745
- return wrap(async () => {
746
- const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), logKey(sessionId)], []);
747
- const raw = reply[0];
748
- const parsed = raw === "" ? null : JSON.parse(raw);
749
- return {
750
- snapshot: parsed ? {
751
- index: parsed.index,
752
- state: parsed.state
753
- } : null,
754
- events: reply[1].map((entry) => toEvent(sessionId, entry))
755
- };
756
- });
757
- },
758
- async putSnapshot(sessionId, reducerName, index, state) {
759
- await wrap(async () => {
760
- await evalScript(PUT_SNAPSHOT_LUA, [snapKey(sessionId, reducerName)], [index, json({
761
- index,
762
- state,
763
- updatedAt: clock.now().getTime()
764
- })]);
765
- });
766
- },
767
- inspect: {
768
- async listSessions(inspectionOptions) {
769
- return wrap(async () => {
770
- const c = await client();
771
- const minimum = inspectionOptions.cursor === void 0 ? `[${inspectionOptions.prefix}` : `(${inspectionOptions.cursor}`;
772
- const candidates = await c.call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
773
- const sessionIds = candidates.filter((id) => id.startsWith(inspectionOptions.prefix)).slice(0, inspectionOptions.limit);
774
- return {
775
- sessions: (await Promise.all(sessionIds.map(async (sessionId) => {
776
- const rows = await readRange(sessionId, 0);
777
- const first = rows[0];
778
- let updatedAt = first.createdAt;
779
- let pendingCount = 0;
780
- let failedCount = 0;
781
- let attemptCount = 0;
782
- let failureCount = 0;
783
- for (const row of rows) {
784
- attemptCount += row.attemptCount;
785
- failureCount += row.failureCount;
786
- if (row.processedAt === null && row.failedAt === null) pendingCount += 1;
787
- if (row.failedAt !== null) failedCount += 1;
788
- for (const timestamp of [
789
- row.createdAt,
790
- row.firstClaimedAt,
791
- row.lastClaimedAt,
792
- row.lastFailedAt,
793
- row.processedAt,
794
- row.failedAt
795
- ]) if (timestamp && timestamp > updatedAt) updatedAt = timestamp;
796
- }
797
- return {
798
- sessionId,
799
- eventCount: rows.length,
800
- pendingCount,
801
- failedCount,
802
- attemptCount,
803
- failureCount,
804
- firstEventAt: first.createdAt,
805
- updatedAt
806
- };
807
- }))).toSorted((a, b) => a.sessionId.localeCompare(b.sessionId)),
808
- cursor: candidates.length > inspectionOptions.limit && candidates[inspectionOptions.limit]?.startsWith(inspectionOptions.prefix) ? sessionIds.at(-1) ?? null : null
809
- };
810
- });
811
- },
812
- async listSnapshots(sessionId) {
813
- return wrap(async () => {
814
- const c = await client();
815
- const start = `${prefix}:${sessionId}:snap:`;
816
- let cursor = "0";
817
- const keys = [];
818
- do {
819
- const reply = await c.call("SCAN", cursor, "MATCH", `${escapeGlob(start)}*`, "COUNT", 100);
820
- cursor = reply[0];
821
- keys.push(...reply[1].filter((key) => key.startsWith(start)));
822
- } while (cursor !== "0");
823
- return (await Promise.all(keys.map(async (key) => {
824
- const raw = await c.call("GET", key);
825
- if (raw === null) return null;
826
- const parsed = JSON.parse(raw);
827
- return {
828
- reducerName: key.slice(start.length),
829
- index: parsed.index,
830
- updatedAt: new Date(parsed.updatedAt ?? 0)
831
- };
832
- }))).filter((snapshot) => snapshot !== null).toSorted((a, b) => a.reducerName.localeCompare(b.reducerName));
833
- });
834
- }
835
- },
135
+ append: core.append,
136
+ read: core.read,
137
+ claimAvailable: core.claimAvailable,
138
+ renewClaims: core.renewClaims,
139
+ completeAttempt: core.completeAttempt,
140
+ failAttempt: core.failAttempt,
141
+ readState: core.readState,
142
+ putSnapshot: core.putSnapshot,
143
+ inspect: core.inspect,
836
144
  stream(sessionId, opts) {
837
145
  const startAt = opts?.startAt ?? 0;
146
+ const channel = notifyChannel(sessionId);
838
147
  return { [Symbol.asyncIterator]() {
839
148
  let last = startAt;
840
149
  let buffer = [];
841
150
  let closed = false;
842
- let conn = null;
151
+ let subscribed = false;
152
+ let pending = null;
153
+ let interrupt = null;
154
+ const release = () => {
155
+ if (!subscribed) return;
156
+ subscribed = false;
157
+ releaseChannel(channel);
158
+ };
843
159
  return {
844
160
  async next() {
845
161
  for (;;) {
846
- if (closed) return {
847
- value: void 0,
848
- done: true
849
- };
162
+ if (closed) {
163
+ release();
164
+ return {
165
+ value: void 0,
166
+ done: true
167
+ };
168
+ }
850
169
  const row = buffer.shift();
851
170
  if (row) {
852
171
  last = row.index;
@@ -855,31 +174,48 @@ function redis(options = {}) {
855
174
  done: false
856
175
  };
857
176
  }
858
- if (!conn) {
859
- conn = (await client()).duplicate();
860
- streamConnections.add(conn);
177
+ if (severed) {
178
+ release();
179
+ throw new A2Error("LOG_UNAVAILABLE", "the log was closed");
861
180
  }
862
- let reply;
181
+ const waker = armWaker(channel);
182
+ interrupt = waker.wake;
863
183
  try {
864
- reply = await conn.call("XREAD", "BLOCK", XREAD_BLOCK_MS, "STREAMS", logKey(sessionId), `${last}-0`);
184
+ if (!subscribed) {
185
+ await acquireChannel(channel);
186
+ subscribed = true;
187
+ }
188
+ const markedAt = watchedRefreshedAt.get(channel) ?? 0;
189
+ if (Date.now() - markedAt >= NOTIFY_TIMINGS.safetyReadMs) {
190
+ watchedRefreshedAt.set(channel, Date.now());
191
+ await core.markWatched(sessionId, NOTIFY_TIMINGS.safetyReadMs * 3);
192
+ }
193
+ buffer = await core.readEvents(sessionId, last);
194
+ if (buffer.length === 0 && !closed && !severed) {
195
+ pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
196
+ await Promise.race([waker.wakeup, pending.promise]);
197
+ pending.cancel();
198
+ pending = null;
199
+ }
865
200
  } catch (err) {
201
+ release();
866
202
  if (closed) return {
867
203
  value: void 0,
868
204
  done: true
869
205
  };
206
+ if (err instanceof A2Error) throw err;
870
207
  throw new A2Error("LOG_UNAVAILABLE", "redis log operation failed", { cause: err });
208
+ } finally {
209
+ waker.disarm();
210
+ interrupt = null;
871
211
  }
872
- if (reply === null) continue;
873
- const [[, entries]] = reply;
874
- buffer = entries.map((entry) => toEvent(sessionId, entry));
875
212
  }
876
213
  },
877
214
  async return() {
878
215
  closed = true;
879
- if (conn) {
880
- streamConnections.delete(conn);
881
- conn.disconnect();
882
- }
216
+ pending?.cancel();
217
+ interrupt?.();
218
+ release();
883
219
  return {
884
220
  value: void 0,
885
221
  done: true
@@ -889,11 +225,12 @@ function redis(options = {}) {
889
225
  } };
890
226
  },
891
227
  async close() {
892
- for (const conn of streamConnections) conn.disconnect();
893
- streamConnections.clear();
228
+ severed = true;
229
+ for (const set of wakers.values()) for (const wake of set) wake();
230
+ if (subscriber) (await subscriber.catch(() => null))?.disconnect();
894
231
  const current = connection.peek();
895
232
  if (!current) return;
896
- (await current).disconnect();
233
+ (await current.catch(() => null))?.disconnect();
897
234
  }
898
235
  };
899
236
  }