experimental-a2 0.0.0 → 0.2.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 +43 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.d.ts +19 -7
- package/dist/ai-server.js +730 -96
- package/dist/ai.d.ts +32 -11
- package/dist/ai.js +253 -75
- package/dist/client.d.ts +1 -1
- package/dist/client.js +4 -4
- package/dist/{contract-B0kAXoaL.js → contract-CG_adnu_.js} +2 -1
- package/dist/{contract-DL8btVd9.d.ts → contract-C_3dIIEU.d.ts} +4 -1
- package/dist/devtools-server.browser.js +2 -2
- package/dist/devtools-server.js +1 -1
- package/dist/http.d.ts +1 -1
- package/dist/http.js +4 -3
- package/dist/idempotent-replay-BMyHrP0L.js +19 -0
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1 -1
- package/dist/{internal-Dm8Ejnud.js → internal-D6wNxTck.js} +3 -3
- package/dist/{log-Dg1I8NRr.d.ts → log-ldf5g8Cx.d.ts} +74 -56
- package/dist/log-memory.d.ts +1 -1
- package/dist/log-memory.js +173 -96
- package/dist/{log-polling-RO7kclzR.js → log-polling-6COoN60V.js} +1 -1
- package/dist/log-postgres.d.ts +1 -1
- package/dist/log-postgres.js +235 -192
- package/dist/log-redis.d.ts +1 -1
- package/dist/log-redis.js +453 -263
- package/dist/log-sqlite.d.ts +1 -1
- package/dist/log-sqlite.js +216 -127
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/recovery-vercel.d.ts +2 -2
- package/dist/recovery-vercel.js +9 -10
- package/dist/server-DJgD2YWP.js +877 -0
- package/dist/server.browser.js +4 -4
- package/dist/server.d.ts +46 -27
- package/dist/server.js +1 -1
- package/dist/{telemetry-C78al20p.d.ts → telemetry-Cso0qyHQ.d.ts} +1 -1
- package/dist/{wire-2QpU1EtJ.js → wire-BVsgR8o9.js} +1 -1
- package/docs/01-quickstart.mdx +7 -7
- package/docs/concepts/01-contracts.mdx +22 -22
- package/docs/concepts/02-handlers.mdx +223 -89
- package/docs/concepts/03-durability.mdx +199 -112
- package/docs/concepts/04-state.mdx +27 -1
- package/docs/guides/01-timers.mdx +4 -4
- package/docs/guides/02-cancellation.mdx +32 -4
- package/docs/guides/05-production.mdx +61 -27
- package/docs/guides/06-ai-agents.mdx +151 -70
- package/docs/guides/07-devtools.mdx +6 -3
- package/docs/guides/08-application-data.mdx +5 -6
- package/docs/index.mdx +30 -14
- package/docs/reference/01-api.mdx +305 -70
- package/package.json +31 -31
- package/dist/server-DYsnKTTy.js +0 -780
package/dist/log-redis.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { t as A2Error } from "./errors-BJRMd-h6.js";
|
|
2
2
|
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
3
|
+
import { t as idempotentReplay } from "./idempotent-replay-BMyHrP0L.js";
|
|
3
4
|
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
|
|
4
5
|
//#region src/log-redis.ts
|
|
5
6
|
/**
|
|
6
|
-
* a2/log-redis — the push-native log backend, on Redis Streams.
|
|
7
|
+
* experimental-a2/log-redis — the push-native log backend, on Redis Streams.
|
|
7
8
|
*
|
|
8
9
|
* The stream IS the log: each event is a stream entry whose ID is its
|
|
9
10
|
* dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read and
|
|
@@ -13,10 +14,8 @@ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
|
|
|
13
14
|
* The conformance suite in test/conformance is the executable
|
|
14
15
|
* contract, run against a real spawned `redis-server`.
|
|
15
16
|
*
|
|
16
|
-
* Timestamps and
|
|
17
|
-
*
|
|
18
|
-
* with a generous real-time `PX` purely as garbage collection — so
|
|
19
|
-
* tests can time-travel against a real server.
|
|
17
|
+
* Timestamps and claim expiry come from the injected clock so tests can
|
|
18
|
+
* time-travel against a real server.
|
|
20
19
|
*
|
|
21
20
|
* Works with any Redis-protocol server on a single instance or a
|
|
22
21
|
* non-cluster provider (Upstash — durable by default — Redis, Valkey).
|
|
@@ -27,17 +26,40 @@ import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./log-yJbXUf72.js";
|
|
|
27
26
|
/** How long each blocking read waits before re-checking for close(). */
|
|
28
27
|
const XREAD_BLOCK_MS = 1e4;
|
|
29
28
|
/**
|
|
30
|
-
* KEYS: log stream, counter, global id hash, session index.
|
|
29
|
+
* KEYS: log stream, counter, global id hash, session index, metadata, pending, ready.
|
|
31
30
|
* ARGV: sessionId, nowMs, n, then per event:
|
|
32
|
-
* id, type, payloadJson, causeJson
|
|
33
|
-
* Returns ['
|
|
34
|
-
* | ['
|
|
31
|
+
* id, type, payloadJson, causeJson, lane, settled.
|
|
32
|
+
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
33
|
+
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
34
|
+
* ['foreign', eventId].
|
|
35
35
|
*/
|
|
36
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
|
|
37
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
|
|
38
60
|
local dups = {}
|
|
39
61
|
for i = 1, n do
|
|
40
|
-
local id = ARGV[3 + (i - 1) *
|
|
62
|
+
local id = ARGV[3 + (i - 1) * 6 + 1]
|
|
41
63
|
local existing = redis.call('HGET', KEYS[3], id)
|
|
42
64
|
if existing then
|
|
43
65
|
local rec = cjson.decode(existing)
|
|
@@ -46,49 +68,60 @@ for i = 1, n do
|
|
|
46
68
|
end
|
|
47
69
|
end
|
|
48
70
|
if #dups == n then
|
|
49
|
-
local res = { 'dup' }
|
|
71
|
+
local res = { 'dup', has_pending }
|
|
50
72
|
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
51
73
|
return res
|
|
52
74
|
end
|
|
53
75
|
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
54
|
-
local base = tonumber(redis.call('GET', KEYS[2]) or '0')
|
|
55
76
|
for i = 1, n do
|
|
56
|
-
local id = ARGV[3 + (i - 1) *
|
|
57
|
-
local typ = ARGV[3 + (i - 1) *
|
|
58
|
-
local payload = ARGV[3 + (i - 1) *
|
|
59
|
-
local cause = ARGV[3 + (i - 1) *
|
|
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]
|
|
60
83
|
local idx = base + i
|
|
61
84
|
redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
|
|
62
85
|
'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
|
|
63
|
-
'cause', cause)
|
|
86
|
+
'cause', cause, 'lane', lane)
|
|
64
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
|
|
65
94
|
end
|
|
66
95
|
redis.call('SET', KEYS[2], tostring(base + n))
|
|
67
96
|
redis.call('ZADD', KEYS[4], 0, ARGV[1])
|
|
68
|
-
|
|
97
|
+
has_pending = redis.call('ZCARD', KEYS[6]) > 0 and '1' or '0'
|
|
98
|
+
return { 'ok', tostring(base + 1), has_pending }
|
|
69
99
|
`;
|
|
70
|
-
/** KEYS: counter, meta. ARGV: idx,
|
|
71
|
-
const
|
|
72
|
-
local
|
|
73
|
-
|
|
74
|
-
if idx < 1 or idx > total then
|
|
75
|
-
return 0
|
|
76
|
-
end
|
|
77
|
-
local target = ARGV[1] .. ':' .. ARGV[3]
|
|
78
|
-
if ARGV[3] ~= 'p' or redis.call('HEXISTS', KEYS[2], target) == 0 then
|
|
79
|
-
redis.call('HSET', KEYS[2], target, ARGV[2])
|
|
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
|
|
80
104
|
end
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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'))
|
|
85
123
|
end
|
|
86
|
-
redis.call('HSET', KEYS[2], '__head', tostring(head))
|
|
87
124
|
end
|
|
88
|
-
return 1
|
|
89
|
-
`;
|
|
90
|
-
/** KEYS: counter, meta. ARGV: idx, attempt, error, maxFailures, nowMs. */
|
|
91
|
-
const FAIL_ATTEMPT_LUA = `
|
|
92
125
|
local idx = tonumber(ARGV[1])
|
|
93
126
|
if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
|
|
94
127
|
return { 'missing', '0' }
|
|
@@ -102,143 +135,225 @@ end
|
|
|
102
135
|
if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
|
|
103
136
|
return { 'dead_lettered', tostring(failures) }
|
|
104
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
|
|
105
146
|
failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
|
|
106
147
|
redis.call('HSET', KEYS[2],
|
|
107
148
|
field .. ':e', ARGV[3],
|
|
108
149
|
field .. ':lf', ARGV[5],
|
|
109
150
|
field .. ':lfa', ARGV[2])
|
|
151
|
+
redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
|
|
110
152
|
if failures >= tonumber(ARGV[4]) then
|
|
111
153
|
redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
|
|
154
|
+
redis.call('ZREM', KEYS[4], field)
|
|
112
155
|
return { 'dead_lettered', tostring(failures) }
|
|
113
156
|
end
|
|
114
157
|
return { 'failed', tostring(failures) }
|
|
115
158
|
`;
|
|
116
159
|
/**
|
|
117
|
-
* KEYS: log, counter, meta,
|
|
118
|
-
* ARGV: holder, nowMs, expiresAtMs,
|
|
160
|
+
* KEYS: log, counter, meta, pending, ready.
|
|
161
|
+
* ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
|
|
119
162
|
*/
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
local
|
|
123
|
-
local
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
|
129
198
|
end
|
|
130
|
-
if
|
|
131
|
-
local
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
return
|
|
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
|
|
136
205
|
end
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
|
143
227
|
end
|
|
144
228
|
end
|
|
145
|
-
|
|
146
|
-
redis.call('SET', KEYS[4], lease, 'PX', ARGV[4])
|
|
147
|
-
|
|
148
|
-
local failures = tonumber(redis.call('HGET', KEYS[3], field .. ':a') or '0')
|
|
149
|
-
local dispatches = tonumber(redis.call('HGET', KEYS[3], field .. ':d') or '0')
|
|
150
|
-
local attempt = dispatches + 1
|
|
151
|
-
redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
|
|
152
|
-
redis.call('HSET', KEYS[3], field .. ':d', tostring(attempt), field .. ':lc', ARGV[2])
|
|
153
|
-
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
154
|
-
if #entries == 0 then return { 'missing' } end
|
|
155
|
-
return {
|
|
156
|
-
'claimed', tostring(attempt), tostring(failures),
|
|
157
|
-
redis.call('HGET', KEYS[3], field .. ':e') or false,
|
|
158
|
-
redis.call('HGET', KEYS[3], field .. ':fc') or false,
|
|
159
|
-
redis.call('HGET', KEYS[3], field .. ':lc') or false,
|
|
160
|
-
redis.call('HGET', KEYS[3], field .. ':lf') or false,
|
|
161
|
-
redis.call('HGET', KEYS[3], field .. ':lfa') or false,
|
|
162
|
-
entries[1]
|
|
163
|
-
}
|
|
229
|
+
return renewed
|
|
164
230
|
`;
|
|
165
231
|
/**
|
|
166
|
-
* KEYS: log, counter, meta,
|
|
167
|
-
* ARGV:
|
|
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.
|
|
168
235
|
*/
|
|
169
|
-
const
|
|
170
|
-
local
|
|
171
|
-
|
|
172
|
-
if completed < 1 or completed > total then return { 'missing' } end
|
|
173
|
-
local completedField = ARGV[3]
|
|
174
|
-
local completedDispatches = tonumber(redis.call('HGET', KEYS[3], completedField .. ':d') or '0')
|
|
175
|
-
if redis.call('HEXISTS', KEYS[3], completedField .. ':p') == 1 or
|
|
176
|
-
completedDispatches ~= tonumber(ARGV[4]) then
|
|
177
|
-
return { 'superseded' }
|
|
236
|
+
const COMPLETE_ATTEMPT_LUA = `
|
|
237
|
+
local function lane_field(lane, suffix)
|
|
238
|
+
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
178
239
|
end
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
186
259
|
end
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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)
|
|
194
275
|
end
|
|
195
|
-
|
|
196
|
-
local
|
|
197
|
-
|
|
198
|
-
local
|
|
199
|
-
if
|
|
200
|
-
|
|
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
|
|
201
314
|
end
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
redis.call('HSET', KEYS[3], field .. ':d', tostring(attempt), field .. ':lc', ARGV[2])
|
|
208
|
-
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
209
|
-
if #entries == 0 then return { 'missing' } end
|
|
210
|
-
return {
|
|
211
|
-
'claimed', tostring(attempt), tostring(failures),
|
|
212
|
-
redis.call('HGET', KEYS[3], field .. ':e') or false,
|
|
213
|
-
redis.call('HGET', KEYS[3], field .. ':fc') or false,
|
|
214
|
-
redis.call('HGET', KEYS[3], field .. ':lc') or false,
|
|
215
|
-
redis.call('HGET', KEYS[3], field .. ':lf') or false,
|
|
216
|
-
redis.call('HGET', KEYS[3], field .. ':lfa') or false,
|
|
217
|
-
entries[1]
|
|
218
|
-
}
|
|
219
|
-
`;
|
|
220
|
-
/**
|
|
221
|
-
* KEYS: lease. ARGV: holder, nowMs, expiresAtMs, gcPxMs. Logical expiry is
|
|
222
|
-
* the stored expiresAt vs now (exclusive: expiresAt <= now is free) —
|
|
223
|
-
* the PX is garbage collection only, never the semantics.
|
|
224
|
-
*/
|
|
225
|
-
const LEASE_ACQUIRE_LUA = `
|
|
226
|
-
local cur = redis.call('GET', KEYS[1])
|
|
227
|
-
if cur then
|
|
228
|
-
local rec = cjson.decode(cur)
|
|
229
|
-
if rec[2] > tonumber(ARGV[2]) and rec[1] ~= ARGV[1] then return 0 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' }
|
|
230
320
|
end
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
return
|
|
234
|
-
`;
|
|
235
|
-
/** KEYS: lease. ARGV: holder. Only the holder may release. */
|
|
236
|
-
const LEASE_RELEASE_LUA = `
|
|
237
|
-
local cur = redis.call('GET', KEYS[1])
|
|
238
|
-
if cur and cjson.decode(cur)[1] == ARGV[1] then
|
|
239
|
-
redis.call('DEL', KEYS[1])
|
|
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
|
|
240
324
|
end
|
|
241
|
-
|
|
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) }
|
|
242
357
|
`;
|
|
243
358
|
/** KEYS: snapshot. ARGV: upToIndex, json. Guarded: never move backward. */
|
|
244
359
|
const PUT_SNAPSHOT_LUA = `
|
|
@@ -282,38 +397,53 @@ const toCause = (raw) => {
|
|
|
282
397
|
if (raw === "" || raw === void 0) return null;
|
|
283
398
|
const value = JSON.parse(raw);
|
|
284
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");
|
|
285
401
|
return {
|
|
286
402
|
index: Number(value.index),
|
|
287
|
-
attempt: Number(value.attempt)
|
|
403
|
+
attempt: Number(value.attempt),
|
|
404
|
+
...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
|
|
288
405
|
};
|
|
289
406
|
};
|
|
290
|
-
const
|
|
407
|
+
const toClaimAvailableResult = (sessionId, reply) => {
|
|
291
408
|
const outcome = String(reply[0]);
|
|
292
409
|
if (outcome === "claimed") {
|
|
293
|
-
const
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
event
|
|
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, {
|
|
299
418
|
cause: toCause(fields["cause"]),
|
|
419
|
+
lane: fields["lane"] === "" ? null : fields["lane"],
|
|
300
420
|
processedAt: null,
|
|
301
421
|
processedByAttempt: null,
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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]),
|
|
309
432
|
failedAt: null
|
|
310
|
-
})
|
|
433
|
+
}));
|
|
434
|
+
}
|
|
435
|
+
return {
|
|
436
|
+
outcome: "claimed",
|
|
437
|
+
events
|
|
311
438
|
};
|
|
312
439
|
}
|
|
313
|
-
if (outcome === "busy"
|
|
440
|
+
if (outcome === "busy") return {
|
|
441
|
+
outcome,
|
|
442
|
+
retryAt: new Date(Number(reply[1]))
|
|
443
|
+
};
|
|
444
|
+
if (outcome === "settled") return { outcome };
|
|
314
445
|
throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
|
|
315
446
|
};
|
|
316
|
-
const toHandoffResult = (sessionId, reply) => String(reply[0]) === "superseded" ? { outcome: "superseded" } : toClaimResult(sessionId, reply);
|
|
317
447
|
const wrap = async (fn) => {
|
|
318
448
|
try {
|
|
319
449
|
return await fn();
|
|
@@ -330,14 +460,15 @@ function redis(options = {}) {
|
|
|
330
460
|
const logKey = (s) => `${prefix}:${s}:log`;
|
|
331
461
|
const countKey = (s) => `${prefix}:${s}:count`;
|
|
332
462
|
const metaKey = (s) => `${prefix}:${s}:meta`;
|
|
333
|
-
const
|
|
463
|
+
const pendingKey = (s) => `${prefix}:${s}:pending`;
|
|
464
|
+
const readyKey = (s) => `${prefix}:${s}:ready`;
|
|
334
465
|
const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
|
|
335
466
|
const idsKey = `${prefix}:ids`;
|
|
336
467
|
const sessionsKey = `${prefix}:sessions`;
|
|
337
468
|
const connection = retryableLazy(async () => {
|
|
338
469
|
if (options.client) return options.client;
|
|
339
470
|
return new (await (import("ioredis").catch(() => {
|
|
340
|
-
throw new A2Error("LOG_NOT_CONFIGURED", "a2/log-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
|
|
471
|
+
throw new A2Error("LOG_NOT_CONFIGURED", "experimental-a2/log-redis with a url needs the 'ioredis' package (optional peer dependency) — install it, or inject a client");
|
|
341
472
|
}))).default(options.url);
|
|
342
473
|
});
|
|
343
474
|
const client = connection.get;
|
|
@@ -346,18 +477,6 @@ function redis(options = {}) {
|
|
|
346
477
|
};
|
|
347
478
|
/** Every live-feed connection, so close() can sever them. */
|
|
348
479
|
const streamConnections = /* @__PURE__ */ new Set();
|
|
349
|
-
const ensureSessionIndex = retryableLazy(() => wrap(async () => {
|
|
350
|
-
const c = await client();
|
|
351
|
-
let cursor = "0";
|
|
352
|
-
const keyStart = `${prefix}:`;
|
|
353
|
-
const keyEnd = ":count";
|
|
354
|
-
do {
|
|
355
|
-
const reply = await c.call("SCAN", cursor, "MATCH", `${escapeGlob(prefix)}:*:count`, "COUNT", 200);
|
|
356
|
-
cursor = reply[0];
|
|
357
|
-
const ids = reply[1].filter((key) => key.startsWith(keyStart) && key.endsWith(keyEnd)).map((key) => key.slice(keyStart.length, -6));
|
|
358
|
-
if (ids.length > 0) await c.call("ZADD", sessionsKey, ...ids.flatMap((id) => [0, id]));
|
|
359
|
-
} while (cursor !== "0");
|
|
360
|
-
})).get;
|
|
361
480
|
/** XRANGE + meta merge — the raw read both `read` and dup-returns use. */
|
|
362
481
|
const readRange = async (sessionId, afterIndex) => {
|
|
363
482
|
const c = await client();
|
|
@@ -370,6 +489,9 @@ function redis(options = {}) {
|
|
|
370
489
|
const fields = fieldMap(entry[1]);
|
|
371
490
|
const processedAt = meta[`${idx}:p`];
|
|
372
491
|
const processedByAttempt = meta[`${idx}:pa`];
|
|
492
|
+
const returnedEventIds = meta[`${idx}:ri`];
|
|
493
|
+
const claimHolder = meta[`${idx}:ch`];
|
|
494
|
+
const claimExpiresAt = meta[`${idx}:ce`];
|
|
373
495
|
const failedAt = meta[`${idx}:f`];
|
|
374
496
|
const firstClaimedAt = meta[`${idx}:fc`];
|
|
375
497
|
const lastClaimedAt = meta[`${idx}:lc`];
|
|
@@ -377,11 +499,15 @@ function redis(options = {}) {
|
|
|
377
499
|
const lastFailedAttempt = meta[`${idx}:lfa`];
|
|
378
500
|
return Object.assign(event, {
|
|
379
501
|
cause: toCause(fields["cause"]),
|
|
502
|
+
lane: fields["lane"] === "" ? null : fields["lane"],
|
|
380
503
|
processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
|
|
381
504
|
processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
|
|
505
|
+
returnedEventIds: returnedEventIds === void 0 ? null : JSON.parse(returnedEventIds),
|
|
382
506
|
firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
|
|
383
507
|
lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
|
|
384
508
|
attemptCount: Number(meta[`${idx}:d`] ?? 0),
|
|
509
|
+
claimHolder: claimHolder ?? null,
|
|
510
|
+
claimExpiresAt: claimExpiresAt === void 0 ? null : new Date(Number(claimExpiresAt)),
|
|
385
511
|
failureCount: Number(meta[`${idx}:a`] ?? 0),
|
|
386
512
|
lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
|
|
387
513
|
lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
|
|
@@ -392,7 +518,6 @@ function redis(options = {}) {
|
|
|
392
518
|
};
|
|
393
519
|
return {
|
|
394
520
|
async append(sessionId, events) {
|
|
395
|
-
if (events.length === 0) return [];
|
|
396
521
|
return wrap(async () => {
|
|
397
522
|
const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
|
|
398
523
|
if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
@@ -401,7 +526,9 @@ function redis(options = {}) {
|
|
|
401
526
|
id: e.id ?? generateId(),
|
|
402
527
|
type: e.type,
|
|
403
528
|
payload: e.payload,
|
|
404
|
-
cause: e.cause ? { ...e.cause } : null
|
|
529
|
+
cause: e.cause ? { ...e.cause } : null,
|
|
530
|
+
lane: e.lane ?? null,
|
|
531
|
+
settled: e.settled === true
|
|
405
532
|
}));
|
|
406
533
|
const args = [
|
|
407
534
|
sessionId,
|
|
@@ -411,101 +538,195 @@ function redis(options = {}) {
|
|
|
411
538
|
e.id,
|
|
412
539
|
e.type,
|
|
413
540
|
json(e.payload),
|
|
414
|
-
e.cause ? json(e.cause) : ""
|
|
541
|
+
e.cause ? json(e.cause) : "",
|
|
542
|
+
e.lane ?? "",
|
|
543
|
+
e.settled ? "1" : "0"
|
|
415
544
|
])
|
|
416
545
|
];
|
|
417
546
|
const reply = await evalScript(APPEND_LUA, [
|
|
418
547
|
logKey(sessionId),
|
|
419
548
|
countKey(sessionId),
|
|
420
549
|
idsKey,
|
|
421
|
-
sessionsKey
|
|
550
|
+
sessionsKey,
|
|
551
|
+
metaKey(sessionId),
|
|
552
|
+
pendingKey(sessionId),
|
|
553
|
+
readyKey(sessionId)
|
|
422
554
|
], args);
|
|
423
555
|
const [verdict] = reply;
|
|
556
|
+
if (verdict === "empty") return {
|
|
557
|
+
events: [],
|
|
558
|
+
hasPending: reply[1] === "1"
|
|
559
|
+
};
|
|
424
560
|
if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
|
|
425
561
|
if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
|
|
426
562
|
if (verdict === "dup") {
|
|
427
|
-
const
|
|
563
|
+
const hasPending = reply[1] === "1";
|
|
564
|
+
const wanted = new Set(reply.slice(2).map(Number));
|
|
428
565
|
const min = Math.min(...wanted);
|
|
429
|
-
|
|
566
|
+
const existing = (await readRange(sessionId, min - 1)).filter((row) => wanted.has(row.index));
|
|
567
|
+
return {
|
|
568
|
+
events: idempotentReplay(events, existing),
|
|
569
|
+
hasPending
|
|
570
|
+
};
|
|
430
571
|
}
|
|
431
572
|
const base = Number(reply[1]) - 1;
|
|
432
|
-
return
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
+
};
|
|
451
599
|
});
|
|
452
600
|
},
|
|
453
601
|
async read(sessionId, opts) {
|
|
454
602
|
return wrap(async () => {
|
|
455
|
-
|
|
456
|
-
return opts?.unprocessedOnly ? rows.filter((r) => r.processedAt === null) : rows;
|
|
603
|
+
return await readRange(sessionId, opts?.afterIndex ?? 0);
|
|
457
604
|
});
|
|
458
605
|
},
|
|
459
|
-
async
|
|
606
|
+
async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
|
|
460
607
|
return wrap(async () => {
|
|
461
608
|
const now = clock.now().getTime();
|
|
462
609
|
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
463
|
-
const
|
|
464
|
-
const
|
|
465
|
-
const reply = await evalScript(CLAIM_NEXT_LUA, [
|
|
610
|
+
const excluded = excludeIndexes ?? [];
|
|
611
|
+
const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
|
|
466
612
|
logKey(sessionId),
|
|
467
613
|
countKey(sessionId),
|
|
468
614
|
metaKey(sessionId),
|
|
469
|
-
|
|
615
|
+
pendingKey(sessionId),
|
|
616
|
+
readyKey(sessionId)
|
|
470
617
|
], [
|
|
471
618
|
holder,
|
|
472
619
|
now,
|
|
473
620
|
expiresAt,
|
|
474
|
-
|
|
475
|
-
|
|
621
|
+
excluded.length,
|
|
622
|
+
...excluded
|
|
476
623
|
]);
|
|
477
|
-
return
|
|
624
|
+
return toClaimAvailableResult(sessionId, reply);
|
|
478
625
|
});
|
|
479
626
|
},
|
|
480
|
-
async
|
|
627
|
+
async renewClaims({ sessionId, holder, indexes, ttlMs, expiresAtMs }) {
|
|
481
628
|
return wrap(async () => {
|
|
482
|
-
const
|
|
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, [
|
|
483
646
|
logKey(sessionId),
|
|
484
647
|
countKey(sessionId),
|
|
648
|
+
idsKey,
|
|
649
|
+
sessionsKey,
|
|
485
650
|
metaKey(sessionId),
|
|
486
|
-
|
|
651
|
+
pendingKey(sessionId),
|
|
652
|
+
readyKey(sessionId)
|
|
487
653
|
], [
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
654
|
+
sessionId,
|
|
655
|
+
now,
|
|
656
|
+
index,
|
|
491
657
|
attempt,
|
|
492
|
-
|
|
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
|
+
])
|
|
493
666
|
]);
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
if (await evalScript(MARK_LUA, [countKey(sessionId), metaKey(sessionId)], [
|
|
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 = {
|
|
500
672
|
index,
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
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}'`);
|
|
504
720
|
});
|
|
505
721
|
},
|
|
506
722
|
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
507
723
|
return wrap(async () => {
|
|
508
|
-
const reply = await evalScript(FAIL_ATTEMPT_LUA, [
|
|
724
|
+
const reply = await evalScript(FAIL_ATTEMPT_LUA, [
|
|
725
|
+
countKey(sessionId),
|
|
726
|
+
metaKey(sessionId),
|
|
727
|
+
pendingKey(sessionId),
|
|
728
|
+
readyKey(sessionId)
|
|
729
|
+
], [
|
|
509
730
|
index,
|
|
510
731
|
attempt,
|
|
511
732
|
error,
|
|
@@ -520,15 +741,6 @@ function redis(options = {}) {
|
|
|
520
741
|
};
|
|
521
742
|
});
|
|
522
743
|
},
|
|
523
|
-
async markFailed(sessionId, index) {
|
|
524
|
-
await wrap(async () => {
|
|
525
|
-
if (await evalScript(MARK_LUA, [countKey(sessionId), metaKey(sessionId)], [
|
|
526
|
-
index,
|
|
527
|
-
clock.now().getTime(),
|
|
528
|
-
"f"
|
|
529
|
-
]) === 0) throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
530
|
-
});
|
|
531
|
-
},
|
|
532
744
|
async readState(sessionId, reducerName) {
|
|
533
745
|
return wrap(async () => {
|
|
534
746
|
const reply = await evalScript(READ_STATE_LUA, [snapKey(sessionId, reducerName), logKey(sessionId)], []);
|
|
@@ -555,7 +767,6 @@ function redis(options = {}) {
|
|
|
555
767
|
inspect: {
|
|
556
768
|
async listSessions(inspectionOptions) {
|
|
557
769
|
return wrap(async () => {
|
|
558
|
-
await ensureSessionIndex();
|
|
559
770
|
const c = await client();
|
|
560
771
|
const minimum = inspectionOptions.cursor === void 0 ? `[${inspectionOptions.prefix}` : `(${inspectionOptions.cursor}`;
|
|
561
772
|
const candidates = await c.call("ZRANGEBYLEX", sessionsKey, minimum, "+", "LIMIT", 0, inspectionOptions.limit + 1);
|
|
@@ -677,27 +888,6 @@ function redis(options = {}) {
|
|
|
677
888
|
};
|
|
678
889
|
} };
|
|
679
890
|
},
|
|
680
|
-
lease: {
|
|
681
|
-
async acquire({ sessionId, holder, ttlMs, expiresAtMs }) {
|
|
682
|
-
return wrap(async () => {
|
|
683
|
-
const now = clock.now().getTime();
|
|
684
|
-
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
685
|
-
const effectiveTtlMs = Math.max(1, expiresAt - now);
|
|
686
|
-
const gcPx = Math.max(effectiveTtlMs * 10, 6e4);
|
|
687
|
-
return await evalScript(LEASE_ACQUIRE_LUA, [leaseKey(sessionId)], [
|
|
688
|
-
holder,
|
|
689
|
-
now,
|
|
690
|
-
expiresAt,
|
|
691
|
-
gcPx
|
|
692
|
-
]) === 1;
|
|
693
|
-
});
|
|
694
|
-
},
|
|
695
|
-
async release({ sessionId, holder }) {
|
|
696
|
-
await wrap(async () => {
|
|
697
|
-
await evalScript(LEASE_RELEASE_LUA, [leaseKey(sessionId)], [holder]);
|
|
698
|
-
});
|
|
699
|
-
}
|
|
700
|
-
},
|
|
701
891
|
async close() {
|
|
702
892
|
for (const conn of streamConnections) conn.disconnect();
|
|
703
893
|
streamConnections.clear();
|