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