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/CHANGELOG.md +13 -0
- package/dist/ai-server.js +1 -1
- package/dist/client.js +1 -1
- package/dist/http.js +1 -1
- package/dist/{internal-D6wNxTck.js → internal-gCd5qMry.js} +9 -1
- package/dist/{log-polling-6COoN60V.js → log-polling-DZ1MiKLg.js} +3 -2
- package/dist/log-postgres.js +1 -1
- package/dist/log-redis-core-CyJ5L8yR.js +836 -0
- package/dist/log-redis-http.d.ts +21 -0
- package/dist/log-redis-http.js +62 -0
- package/dist/log-redis.d.ts +10 -4
- package/dist/log-redis.js +165 -828
- package/dist/log-sqlite.js +1 -1
- package/dist/recovery-vercel.js +1 -1
- package/dist/{server-DJgD2YWP.js → server-BcLa4RFL.js} +1 -1
- package/dist/server.js +1 -1
- package/docs/01-quickstart.mdx +1 -2
- package/docs/concepts/01-contracts.mdx +3 -4
- package/docs/concepts/03-durability.mdx +6 -9
- package/docs/guides/01-timers.mdx +4 -9
- package/docs/guides/05-production.mdx +14 -2
- package/docs/index.mdx +7 -35
- package/docs/reference/01-api.mdx +1 -0
- package/package.json +2 -1
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
|
|
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
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
|
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)
|
|
847
|
-
|
|
848
|
-
|
|
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 (
|
|
859
|
-
|
|
860
|
-
|
|
177
|
+
if (severed) {
|
|
178
|
+
release();
|
|
179
|
+
throw new A2Error("LOG_UNAVAILABLE", "the log was closed");
|
|
861
180
|
}
|
|
862
|
-
|
|
181
|
+
const waker = armWaker(channel);
|
|
182
|
+
interrupt = waker.wake;
|
|
863
183
|
try {
|
|
864
|
-
|
|
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
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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
|
-
|
|
893
|
-
|
|
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)
|
|
233
|
+
(await current.catch(() => null))?.disconnect();
|
|
897
234
|
}
|
|
898
235
|
};
|
|
899
236
|
}
|