experimental-a2 0.21.1 → 0.22.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 +21 -0
- package/dist/{actor-HnGmrOZZ.d.ts → actor-CqibLgjQ.d.ts} +13 -5
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/actor-react.js +1 -1
- package/dist/actor-shared-DFET5q16.js.map +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +3 -3
- package/dist/{ai-CvEnqVfo.d.ts → ai-DAu77CDr.d.ts} +2 -2
- package/dist/{ai-CvEnqVfo.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-CzE672Q3.js → ai-EEfKyc-z.js} +2 -2
- package/dist/{ai-CzE672Q3.js.map → ai-EEfKyc-z.js.map} +1 -1
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +211 -61
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-Bfk-Lycn.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-Bfk-Lycn.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/ai-harness-dQeJaR1t.js.map +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +10 -4
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-9IeTsiAs.js → client-BQS4i_V9.js} +27 -4
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/client-CUMbS_38.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-Bc4PvfHk.js → internal-CvsrprnU.js} +32 -15
- package/dist/{internal-Bc4PvfHk.js.map → internal-CvsrprnU.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/{runtime-consumer-BWmht60z.js → runtime-consumer-CIeL33wj.js} +2 -2
- package/dist/{runtime-consumer-BWmht60z.js.map → runtime-consumer-CIeL33wj.js.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-DvIB8kj5.js → server-DtFrWxi3.js} +30 -8
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-BshFZ0eL.d.ts → server-qgez0Xny.d.ts} +3 -3
- package/dist/{server-BshFZ0eL.d.ts.map → server-qgez0Xny.d.ts.map} +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bk_29eEw.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{store-Z2qjw50n.d.ts → store-D0MhBvg6.d.ts} +15 -6
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +15 -17
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-DYNyxCev.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-DYNyxCev.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +31 -40
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-IiVdlYHc.js → store-redis-notify-CsJw_hfD.js} +538 -397
- package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +3 -4
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +16 -18
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
- package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +5 -2
- package/docs/agents/03-conversations.mdx +9 -8
- package/docs/agents/04-harnesses.mdx +7 -4
- package/docs/reference/01-core.mdx +3 -2
- package/docs/reference/02-server.mdx +5 -1
- package/docs/reference/04-ai.mdx +13 -4
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/server/01-consumers.mdx +4 -1
- package/docs/server/06-guarantees.mdx +7 -4
- package/docs/stores/02-redis.mdx +16 -10
- package/docs/stores/03-redis-http.mdx +6 -4
- package/docs/stores/04-postgres.mdx +2 -2
- package/docs/upgrades/01-overview.mdx +1 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a2slack/db/a2.ts +7 -4
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
- package/examples/playground/app/chat/server.ts +2 -2
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/sql/postgres/drizzle-schema.ts +7 -4
- package/sql/postgres/schema.sql +24 -3
- package/src/actor-shared.ts +10 -2
- package/src/ai-agent.ts +13 -0
- package/src/ai-fx.ts +321 -77
- package/src/ai-harness.ts +7 -0
- package/src/client.ts +19 -2
- package/src/deterministic-id.ts +1 -1
- package/src/internal.ts +42 -11
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +2 -2
- package/src/server.ts +49 -15
- package/src/store-memory.ts +24 -32
- package/src/store-postgres.ts +43 -53
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +595 -439
- package/src/store-redis-notify.ts +74 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +34 -40
- package/src/store.ts +14 -5
- package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
- package/dist/client-9IeTsiAs.js.map +0 -1
- package/dist/server-DvIB8kj5.js.map +0 -1
- package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
- package/dist/store-Z2qjw50n.d.ts.map +0 -1
- package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
- package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
- package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
package/src/store-redis-core.ts
CHANGED
|
@@ -9,7 +9,14 @@
|
|
|
9
9
|
* entries are immutable; dispatch bookkeeping lives on consumer cursors.
|
|
10
10
|
* Every write path is one atomic Lua script.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* Every key of a session is `{p}:{"<session id as JSON>"}:<name>`, one hash
|
|
13
|
+
* tag per session (`redisSessionKey`). Each script declares keys of one
|
|
14
|
+
* session only and derives the rest, so on Upstash a script that sets
|
|
15
|
+
* `allow-key-locking` locks only its session instead of the database. Redis
|
|
16
|
+
* and Valkey reject that flag, so the store asks the server once whether it
|
|
17
|
+
* accepts it.
|
|
18
|
+
*
|
|
19
|
+
* The cursor layout, per session:
|
|
13
20
|
*
|
|
14
21
|
* - `heads:{chunk}` holds one row per (head index, consumer) in hashes of
|
|
15
22
|
* META_CHUNK_SIZE consecutive indexes: a head's attempt and failure
|
|
@@ -31,27 +38,26 @@
|
|
|
31
38
|
* log cursor records are reset lazily against the marker and swept by
|
|
32
39
|
* reclaim.
|
|
33
40
|
*
|
|
34
|
-
* `{p}:schema` records the layout
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
* dispatch keys (`meta`, `meta:{chunk}`, `meta-split`, `pending`, `ready`,
|
|
38
|
-
* `snapshot-pins:{index}`, `snapshot-pin-counts`) share no name with the
|
|
39
|
-
* cursor layout's, so they're never read. Its unfinished work is dropped.
|
|
41
|
+
* `{p}:schema` records the layout. A store claims it once, before its first
|
|
42
|
+
* script, and refuses a prefix holding another. Keys of an unrecorded layout
|
|
43
|
+
* have other names, so they are never read.
|
|
40
44
|
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* executable contract, run against a real spawned
|
|
45
|
+
* Event ids are unique per session: `id:{event id}` holds the index an id
|
|
46
|
+
* names, and leaves with its session. The conformance suite in
|
|
47
|
+
* test/conformance is the executable contract, run against a real spawned
|
|
48
|
+
* `redis-server`, and over REST behind a shim that applies Upstash's
|
|
49
|
+
* key-locking rules.
|
|
44
50
|
*
|
|
45
51
|
* Timestamps and claim expiry come from the injected clock so tests can
|
|
46
|
-
* time-travel against a real server.
|
|
47
|
-
* scripts span keys.
|
|
52
|
+
* time-travel against a real server.
|
|
48
53
|
*/
|
|
49
54
|
|
|
50
55
|
import type { PresencePatch } from './contract.ts'
|
|
51
56
|
import { A2Error, supersededAttemptError } from './errors.ts'
|
|
52
57
|
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
53
58
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
54
|
-
import { nullProtoRecord } from './internal.ts'
|
|
59
|
+
import { NOTIFY_TIMINGS, nullProtoRecord } from './internal.ts'
|
|
60
|
+
import { retryableLazy } from './retryable-lazy.ts'
|
|
55
61
|
import { decodeCause } from './store-codec.ts'
|
|
56
62
|
import type { SerializedEvent } from './stream-buffer.ts'
|
|
57
63
|
import type {
|
|
@@ -102,9 +108,9 @@ export type RedisStreamRead = (options: {
|
|
|
102
108
|
export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
|
|
103
109
|
/**
|
|
104
110
|
* The storage half of the presence capability, shared by both
|
|
105
|
-
* transports. `set` returns the applied patch for local loopback,
|
|
106
|
-
*
|
|
107
|
-
*
|
|
111
|
+
* transports. `set` returns the applied patch for local loopback, with
|
|
112
|
+
* the publish to remote subscribers already under way, or null when
|
|
113
|
+
* every field lost the LWW race.
|
|
108
114
|
*/
|
|
109
115
|
presence: {
|
|
110
116
|
set(
|
|
@@ -112,9 +118,14 @@ export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
|
|
|
112
118
|
participant: string,
|
|
113
119
|
values: Record<string, unknown | null>,
|
|
114
120
|
meta: { seen: number; at: Date; ttlMs: number },
|
|
115
|
-
): Promise<PresencePatch | null>
|
|
121
|
+
): Promise<{ patch: PresencePatch; published: Promise<void> } | null>
|
|
116
122
|
read(ns: string): Promise<PresenceRow[]>
|
|
117
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* The latest clear marker's index, or 0. Entries below it are an old
|
|
126
|
+
* lifetime's until reclaim deletes them, so a feed starts no lower.
|
|
127
|
+
*/
|
|
128
|
+
clearIndex(sessionId: string): Promise<number>
|
|
118
129
|
/** XRANGE pages — the full-row catch-up read. */
|
|
119
130
|
readRange(
|
|
120
131
|
sessionId: string,
|
|
@@ -140,7 +151,8 @@ export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
|
|
|
140
151
|
}) => AsyncGenerator<SerializedEvent[], void>
|
|
141
152
|
/**
|
|
142
153
|
* Events from `fromIndex` through `throughIndex` or the newest entry in
|
|
143
|
-
* one newest-first page, or null when the range doesn't fit in one
|
|
154
|
+
* one newest-first page, or null when the range doesn't fit in one or
|
|
155
|
+
* starts below the latest clear.
|
|
144
156
|
*/
|
|
145
157
|
readTail(
|
|
146
158
|
sessionId: string,
|
|
@@ -154,8 +166,16 @@ export const FEED_PAGE_SIZE = 1000
|
|
|
154
166
|
const READ_CONCURRENCY = 16
|
|
155
167
|
|
|
156
168
|
/** The key layout this core writes; any other is rejected. */
|
|
157
|
-
export const REDIS_LAYOUT = '
|
|
158
|
-
|
|
169
|
+
export const REDIS_LAYOUT = 'tagged-1'
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Upstash runs a script under a database-wide lock unless it opts into
|
|
173
|
+
* locking only its keys' hash tags. Redis and Valkey reject the flag, and
|
|
174
|
+
* have no such lock to opt out of: one thread runs every command.
|
|
175
|
+
*/
|
|
176
|
+
const KEY_LOCKING = '#!lua flags=allow-key-locking'
|
|
177
|
+
const READ_ONLY_KEY_LOCKING = '#!lua flags=no-writes,allow-key-locking'
|
|
178
|
+
const SHEBANG_REJECTED = /shebang|unexpected symbol near '#'/i
|
|
159
179
|
|
|
160
180
|
const META_CHUNK_SIZE = 1000
|
|
161
181
|
|
|
@@ -165,16 +185,11 @@ const HEAD_COLUMNS = `{ 'd', 'fc', 'lc', 'ch', 'ce', 'a', 'lf', 'lfa', 'e', 'f'
|
|
|
165
185
|
// ── Lua scripts (each runs atomically) ───────────────────────────────
|
|
166
186
|
|
|
167
187
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
171
|
-
* buffer hash writes (`put`, `drop`) into one HSET and one HDEL per hash
|
|
172
|
-
* (`flush`). Buffered writes are invisible to reads until flushed, so a
|
|
173
|
-
* script must not read a field it has buffered. Every script binds KEYS[1]
|
|
174
|
-
* to the session's counter and KEYS[2] to the prefix's layout key, and
|
|
175
|
-
* derives the session's other keys.
|
|
188
|
+
* Issues a variadic command in calls of at most 1000 arguments, since Lua's
|
|
189
|
+
* `unpack` is bounded by its stack, and concatenates table replies. An even
|
|
190
|
+
* slice size keeps HSET's field/value pairs together.
|
|
176
191
|
*/
|
|
177
|
-
const
|
|
192
|
+
const SLICED_LUA = `
|
|
178
193
|
local function sliced(command, key, args)
|
|
179
194
|
local results = {}
|
|
180
195
|
for first = 1, #args, 1000 do
|
|
@@ -191,32 +206,56 @@ local function sliced(command, key, args)
|
|
|
191
206
|
end
|
|
192
207
|
return results
|
|
193
208
|
end
|
|
194
|
-
|
|
209
|
+
`
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Upstash bills every call a script makes, so scripts read the counter,
|
|
213
|
+
* clear marker, typed-type index, and event-id owners with one MGET
|
|
214
|
+
* (`read_bounds`), read rows with one HMGET per chunk
|
|
215
|
+
* (`read_rows`), and buffer hash writes (`put`, `drop`) into one HSET and
|
|
216
|
+
* one HDEL per hash (`flush`). Buffered writes are invisible to reads until
|
|
217
|
+
* flushed, so a script must not read a field it has buffered. Every script
|
|
218
|
+
* binds KEYS[1] to the session's counter and derives the session's other
|
|
219
|
+
* keys, which share its hash tag.
|
|
220
|
+
*/
|
|
221
|
+
const BASE_LUA = `${SLICED_LUA}
|
|
222
|
+
local COUNTER = KEYS[1]
|
|
195
223
|
local SESSION = string.sub(COUNTER, 1, -6)
|
|
196
224
|
local LOG, CLEAR = SESSION .. 'log', SESSION .. 'clear'
|
|
197
225
|
local META, READY, CURSORS = SESSION .. 'heads', SESSION .. 'runnable', SESSION .. 'cursors'
|
|
198
226
|
local PIN_COUNTS, TYPED = SESSION .. 'pins', SESSION .. 'typed'
|
|
199
227
|
local CLEAR_TYPE = '${CLEAR_EVENT_TYPE}'
|
|
200
|
-
local total, boundary
|
|
228
|
+
local total, boundary = 0, 0
|
|
201
229
|
-- The event types typed log cursors read: type → { from, last }. The type's
|
|
202
230
|
-- index holds every event of it above \`from\`, and no event of it lies above
|
|
203
231
|
-- \`last\`.
|
|
204
232
|
local typed, typed_dirty = {}, false
|
|
205
|
-
--
|
|
206
|
-
--
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
233
|
+
-- One key per event id holds the index it names in this session. The
|
|
234
|
+
-- \`id:\` namespace keeps a raw id from spelling another session key.
|
|
235
|
+
local function id_key(id) return SESSION .. 'id:' .. id end
|
|
236
|
+
-- Reads the counter, the latest clear marker, the typed types, and the
|
|
237
|
+
-- owners of \`ids\` in one MGET. Returns each id's index, or false.
|
|
238
|
+
local function read_bounds(ids)
|
|
239
|
+
local keys = { COUNTER, CLEAR, TYPED }
|
|
240
|
+
for _, id in ipairs(ids or {}) do keys[#keys + 1] = id_key(id) end
|
|
211
241
|
local values = sliced('MGET', nil, keys)
|
|
212
|
-
|
|
213
|
-
total = tonumber(values[1] or '0')
|
|
242
|
+
total = tonumber(values[1] or values[2] or '0')
|
|
214
243
|
boundary = tonumber(values[2] or '0')
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
244
|
+
if values[3] then typed = cjson.decode(values[3]) end
|
|
245
|
+
local owners = {}
|
|
246
|
+
for i = 4, #keys do owners[i - 3] = values[i] end
|
|
247
|
+
return owners
|
|
248
|
+
end
|
|
249
|
+
-- Event-id owners from read_bounds: an id recorded below the latest clear
|
|
250
|
+
-- is free.
|
|
251
|
+
local function owners_of(values)
|
|
252
|
+
local owners = {}
|
|
253
|
+
for i = 1, #values do
|
|
254
|
+
local owner = values[i]
|
|
255
|
+
if owner and tonumber(owner) < boundary then owner = false end
|
|
256
|
+
owners[i] = owner
|
|
257
|
+
end
|
|
258
|
+
return owners
|
|
220
259
|
end
|
|
221
260
|
local function entry_index(id) return tonumber(string.match(id, '^(%d+)')) end
|
|
222
261
|
local function cid_of(consumer, tag)
|
|
@@ -662,82 +701,28 @@ local function enqueue(batch)
|
|
|
662
701
|
state.dirty = true
|
|
663
702
|
end
|
|
664
703
|
end
|
|
665
|
-
-- Event-id owners for the extra keys read_bounds returned. A record this
|
|
666
|
-
-- session made below its latest clear is free.
|
|
667
|
-
local function owners_of(values, session)
|
|
668
|
-
local owners = {}
|
|
669
|
-
for i = 1, #values do
|
|
670
|
-
local owner = values[i]
|
|
671
|
-
if owner then
|
|
672
|
-
local rec = cjson.decode(owner)
|
|
673
|
-
if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
|
|
674
|
-
end
|
|
675
|
-
owners[i] = owner
|
|
676
|
-
end
|
|
677
|
-
return owners
|
|
678
|
-
end
|
|
679
704
|
`
|
|
680
705
|
|
|
681
|
-
const WAKE_PAYLOAD_LIMIT = 32_768
|
|
682
|
-
|
|
683
706
|
/**
|
|
684
|
-
*
|
|
685
|
-
*
|
|
686
|
-
*
|
|
687
|
-
* are the stored JSON text, inlined verbatim. A batch over
|
|
688
|
-
* WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
|
|
689
|
-
* with XRANGE.
|
|
690
|
-
*/
|
|
691
|
-
const WAKE_LUA = `
|
|
692
|
-
local wake_entries, wake_bytes = {}, 0
|
|
693
|
-
local function wake_add(field, id, typ, payload)
|
|
694
|
-
if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
|
|
695
|
-
wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
|
|
696
|
-
return
|
|
697
|
-
end
|
|
698
|
-
local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
|
|
699
|
-
cjson.encode(typ) .. ',' .. payload .. ']'
|
|
700
|
-
wake_bytes = wake_bytes + #entry + 1
|
|
701
|
-
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
702
|
-
wake_entries[#wake_entries + 1] = entry
|
|
703
|
-
end
|
|
704
|
-
end
|
|
705
|
-
local function wake(channel, frontier, created_at)
|
|
706
|
-
local message = '{"f":' .. frontier .. '}'
|
|
707
|
-
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
708
|
-
message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
|
|
709
|
-
table.concat(wake_entries, ',') .. ']}'
|
|
710
|
-
end
|
|
711
|
-
redis.pcall('PUBLISH', channel, message)
|
|
712
|
-
end
|
|
713
|
-
`
|
|
714
|
-
|
|
715
|
-
/**
|
|
716
|
-
* KEYS: counter, layout, then one id ownership key per event.
|
|
717
|
-
* ARGV: sessionId, nowMs, n, served consumers, notify channel, origin index
|
|
718
|
-
* or '', then per event: id, type, payloadJson, causeJson or '', targets.
|
|
707
|
+
* KEYS: counter.
|
|
708
|
+
* ARGV: sessionId, nowMs, n, served consumers, origin index or '', then per
|
|
709
|
+
* event: id, type, payloadJson, causeJson or '', targets.
|
|
719
710
|
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
720
711
|
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
721
|
-
* ['
|
|
712
|
+
* ['superseded', position] | ['cleared', originIndex] |
|
|
722
713
|
* ['index', type, after].
|
|
723
714
|
*/
|
|
724
|
-
const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
725
|
-
local
|
|
715
|
+
const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
716
|
+
local now, n = ARGV[2], tonumber(ARGV[3])
|
|
726
717
|
use_served(ARGV[4])
|
|
727
718
|
if n == 0 then return { 'empty', has_pending() } end
|
|
728
|
-
local
|
|
729
|
-
for i = 1, n do
|
|
730
|
-
local
|
|
731
|
-
if not values then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
732
|
-
local owners = owners_of(values, session_id)
|
|
719
|
+
local ids = {}
|
|
720
|
+
for i = 1, n do ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
|
|
721
|
+
local owners = owners_of(read_bounds(ids))
|
|
733
722
|
local base = total
|
|
734
723
|
local dups = {}
|
|
735
724
|
for i = 1, n do
|
|
736
|
-
if owners[i] then
|
|
737
|
-
local rec = cjson.decode(owners[i])
|
|
738
|
-
if rec[1] ~= session_id then return { 'foreign', ARGV[6 + (i - 1) * 5 + 1] } end
|
|
739
|
-
dups[#dups + 1] = tostring(rec[2])
|
|
740
|
-
end
|
|
725
|
+
if owners[i] then dups[#dups + 1] = owners[i] end
|
|
741
726
|
end
|
|
742
727
|
if #dups == n then
|
|
743
728
|
local res = { 'dup', has_pending() }
|
|
@@ -745,13 +730,13 @@ if #dups == n then
|
|
|
745
730
|
return res
|
|
746
731
|
end
|
|
747
732
|
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
748
|
-
local origin = ARGV[
|
|
733
|
+
local origin = ARGV[5]
|
|
749
734
|
if origin ~= '' and tonumber(origin) <= boundary then return { 'cleared', origin } end
|
|
750
735
|
-- The attempt-currency fence: a cause's head row must still be its cursor's
|
|
751
736
|
-- head, at that attempt, not dead-lettered.
|
|
752
737
|
local refs, fenced = {}, {}
|
|
753
738
|
for i = 1, n do
|
|
754
|
-
local raw = ARGV[
|
|
739
|
+
local raw = ARGV[5 + (i - 1) * 5 + 4]
|
|
755
740
|
if raw ~= '' then
|
|
756
741
|
local cause = cjson.decode(raw)
|
|
757
742
|
local index = tonumber(cause.index)
|
|
@@ -773,60 +758,55 @@ for k, cause in ipairs(fenced) do
|
|
|
773
758
|
end
|
|
774
759
|
local batch = {}
|
|
775
760
|
for i = 1, n do
|
|
776
|
-
local o =
|
|
761
|
+
local o = 5 + (i - 1) * 5
|
|
777
762
|
batch[i] = { idx = base + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 5]) }
|
|
778
763
|
end
|
|
779
764
|
local planned, missing, after = plan(batch, base)
|
|
780
765
|
if not planned then return { 'index', missing, tostring(after) } end
|
|
781
|
-
local
|
|
766
|
+
local marks = {}
|
|
782
767
|
for i = 1, n do
|
|
783
|
-
local o =
|
|
768
|
+
local o = 5 + (i - 1) * 5
|
|
784
769
|
local field = tostring(base + i)
|
|
785
770
|
redis.call('XADD', LOG, field .. '-0',
|
|
786
771
|
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
787
772
|
'created_at', now, 'cause', ARGV[o + 4])
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
773
|
+
marks[#marks + 1] = id_key(ARGV[o + 1])
|
|
774
|
+
marks[#marks + 1] = field
|
|
791
775
|
end
|
|
792
776
|
index_batch(batch)
|
|
793
777
|
enqueue(batch)
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
if schema_missing then
|
|
797
|
-
owner_writes[#owner_writes + 1] = SCHEMA
|
|
798
|
-
owner_writes[#owner_writes + 1] = '${REDIS_LAYOUT}'
|
|
799
|
-
end
|
|
778
|
+
marks[#marks + 1] = COUNTER
|
|
779
|
+
marks[#marks + 1] = tostring(base + n)
|
|
800
780
|
if typed_dirty then
|
|
801
|
-
|
|
802
|
-
|
|
781
|
+
marks[#marks + 1] = TYPED
|
|
782
|
+
marks[#marks + 1] = cjson.encode(typed)
|
|
803
783
|
end
|
|
804
|
-
sliced('MSET', nil,
|
|
784
|
+
sliced('MSET', nil, marks)
|
|
805
785
|
flush_states()
|
|
806
786
|
flush()
|
|
807
787
|
flush_ready()
|
|
808
|
-
|
|
809
|
-
wake(ARGV[5], tostring(base + n), now)
|
|
810
|
-
return { 'ok', tostring(base + 1), pending }
|
|
788
|
+
return { 'ok', tostring(base + 1), has_pending() }
|
|
811
789
|
`
|
|
812
790
|
|
|
813
791
|
/**
|
|
814
|
-
* KEYS: counter
|
|
792
|
+
* KEYS: counter.
|
|
815
793
|
* ARGV: type, after. Indexes one page of the stored log below the type's
|
|
816
794
|
* index, toward `after`, registering the type first if no cursor read it
|
|
817
795
|
* yet. Returns ['1'] once the index reaches `after`, else ['0'].
|
|
818
796
|
*/
|
|
819
797
|
const INDEX_TYPE_LUA = `${BASE_LUA}
|
|
820
|
-
|
|
798
|
+
read_bounds()
|
|
821
799
|
local name, after = ARGV[1], tonumber(ARGV[2])
|
|
822
800
|
local entry = typed[name]
|
|
823
801
|
if not entry then
|
|
824
802
|
entry = { total, total }
|
|
825
803
|
typed[name] = entry
|
|
826
804
|
end
|
|
827
|
-
|
|
805
|
+
-- Entries below the latest clear belong to an old lifetime: never indexed.
|
|
806
|
+
local floor = math.max(after, boundary - 1)
|
|
807
|
+
if entry[1] > floor then
|
|
828
808
|
local entries = redis.call('XREVRANGE', LOG, tostring(entry[1]) .. '-0',
|
|
829
|
-
tostring(
|
|
809
|
+
tostring(floor + 1) .. '-0', 'COUNT', ${READ_PAGE_SIZE})
|
|
830
810
|
local adds = {}
|
|
831
811
|
for _, stored in ipairs(entries) do
|
|
832
812
|
local fields = stored[2]
|
|
@@ -847,13 +827,15 @@ if entry[1] > after then
|
|
|
847
827
|
else
|
|
848
828
|
entry[1] = entry_index(entries[#entries][1]) - 1
|
|
849
829
|
end
|
|
830
|
+
elseif entry[1] > after then
|
|
831
|
+
entry[1] = after
|
|
850
832
|
end
|
|
851
833
|
redis.call('SET', TYPED, cjson.encode(typed))
|
|
852
834
|
return { entry[1] <= after and '1' or '0' }
|
|
853
835
|
`
|
|
854
836
|
|
|
855
837
|
/**
|
|
856
|
-
* KEYS: counter
|
|
838
|
+
* KEYS: counter.
|
|
857
839
|
* ARGV: holder, nowMs, expiresAtMs, maxFailures or '', served consumers,
|
|
858
840
|
* the highest head to claim or '', excluded count, then per excluded head:
|
|
859
841
|
* consumer, tag.
|
|
@@ -863,7 +845,7 @@ return { entry[1] <= after and '1' or '0' }
|
|
|
863
845
|
const CLAIM_AVAILABLE_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
864
846
|
local ready = redis.call('ZRANGEBYSCORE', READY, 1, ARGV[6] == '' and '+inf' or ARGV[6], 'WITHSCORES')
|
|
865
847
|
if #ready == 0 then return { 'settled' } end
|
|
866
|
-
|
|
848
|
+
read_bounds()
|
|
867
849
|
local holder, now, expires_at = ARGV[1], tonumber(ARGV[2]), ARGV[3]
|
|
868
850
|
local max_failures = tonumber(ARGV[4])
|
|
869
851
|
use_served(ARGV[5])
|
|
@@ -957,13 +939,13 @@ return result
|
|
|
957
939
|
`
|
|
958
940
|
|
|
959
941
|
/**
|
|
960
|
-
* KEYS: counter
|
|
942
|
+
* KEYS: counter.
|
|
961
943
|
* ARGV: holder, nowMs, expiresAtMs, n, then per claim: consumer, tag, index,
|
|
962
944
|
* attempt. Returns [renewedCount, ...renewed, ...superseded] as 1-based
|
|
963
945
|
* positions in the request.
|
|
964
946
|
*/
|
|
965
947
|
const RENEW_CLAIMS_LUA = `${BASE_LUA}
|
|
966
|
-
|
|
948
|
+
read_bounds()
|
|
967
949
|
local holder, now, expires_at = ARGV[1], tonumber(ARGV[2]), ARGV[3]
|
|
968
950
|
local refs, items, superseded = {}, {}, {}
|
|
969
951
|
for i = 1, tonumber(ARGV[4]) do
|
|
@@ -1000,21 +982,19 @@ return result
|
|
|
1000
982
|
`
|
|
1001
983
|
|
|
1002
984
|
/**
|
|
1003
|
-
* KEYS: counter
|
|
985
|
+
* KEYS: counter.
|
|
1004
986
|
* ARGV: sessionId, nowMs, consumer, tag, index, attempt, child causeJson,
|
|
1005
|
-
* served consumers,
|
|
1006
|
-
*
|
|
987
|
+
* served consumers, returned idsJson, n, then per child: id, type,
|
|
988
|
+
* payloadJson, targets.
|
|
1007
989
|
*/
|
|
1008
|
-
const COMPLETE_ATTEMPT_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
1009
|
-
local
|
|
990
|
+
const COMPLETE_ATTEMPT_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
991
|
+
local now = ARGV[2]
|
|
1010
992
|
local consumer, tag, idx, attempt = ARGV[3], ARGV[4], tonumber(ARGV[5]), tonumber(ARGV[6])
|
|
1011
993
|
local at = row_of(consumer, tag)
|
|
1012
|
-
local n = tonumber(ARGV[
|
|
1013
|
-
local
|
|
1014
|
-
for i = 1, n do
|
|
1015
|
-
local
|
|
1016
|
-
if not values then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1017
|
-
local owners = owners_of(values, session_id)
|
|
994
|
+
local n = tonumber(ARGV[10])
|
|
995
|
+
local ids = {}
|
|
996
|
+
for i = 1, n do ids[i] = ARGV[10 + (i - 1) * 4 + 1] end
|
|
997
|
+
local owners = owners_of(read_bounds(ids))
|
|
1018
998
|
if idx <= boundary then return { 'superseded' } end
|
|
1019
999
|
if idx < 1 or idx > total then return { 'missing' } end
|
|
1020
1000
|
local row = read_rows({ { idx, at } }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'l', 'next', 'pins' })[1]
|
|
@@ -1025,14 +1005,12 @@ if row[2] then
|
|
|
1025
1005
|
local recorded = cjson.decode(row[4] or '[]')
|
|
1026
1006
|
if #recorded ~= n then return { 'partial' } end
|
|
1027
1007
|
for i = 1, n do
|
|
1028
|
-
if recorded[i] ~= ARGV[
|
|
1008
|
+
if recorded[i] ~= ARGV[10 + (i - 1) * 4 + 1] then return { 'partial' } end
|
|
1029
1009
|
end
|
|
1030
1010
|
local result = { 'duplicate' }
|
|
1031
1011
|
for i = 1, n do
|
|
1032
1012
|
if not owners[i] then return { 'partial' } end
|
|
1033
|
-
|
|
1034
|
-
if rec[1] ~= session_id then return { 'partial' } end
|
|
1035
|
-
result[#result + 1] = tostring(rec[2])
|
|
1013
|
+
result[#result + 1] = owners[i]
|
|
1036
1014
|
end
|
|
1037
1015
|
return result
|
|
1038
1016
|
end
|
|
@@ -1045,7 +1023,7 @@ end
|
|
|
1045
1023
|
use_served(ARGV[8])
|
|
1046
1024
|
local batch = {}
|
|
1047
1025
|
for i = 1, n do
|
|
1048
|
-
local o =
|
|
1026
|
+
local o = 10 + (i - 1) * 4
|
|
1049
1027
|
batch[i] = { idx = total + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 4]) }
|
|
1050
1028
|
end
|
|
1051
1029
|
local moved, missing = advance(consumer, row[7], idx, row[8], batch)
|
|
@@ -1054,48 +1032,42 @@ local planned, unindexed, after = plan(batch, total)
|
|
|
1054
1032
|
if not planned then return { 'index', unindexed, tostring(after) } end
|
|
1055
1033
|
put_row(idx, at, 'p', now)
|
|
1056
1034
|
put_row(idx, at, 'pa', ARGV[6])
|
|
1057
|
-
put_row(idx, at, 'ri', ARGV[
|
|
1035
|
+
put_row(idx, at, 'ri', ARGV[9])
|
|
1058
1036
|
release_pins(idx, at, row[9])
|
|
1059
|
-
local
|
|
1037
|
+
local marks = {}
|
|
1060
1038
|
for i = 1, n do
|
|
1061
|
-
local o =
|
|
1039
|
+
local o = 10 + (i - 1) * 4
|
|
1062
1040
|
local field = tostring(total + i)
|
|
1063
1041
|
redis.call('XADD', LOG, field .. '-0',
|
|
1064
1042
|
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
1065
1043
|
'created_at', now, 'cause', ARGV[7])
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
1044
|
+
marks[#marks + 1] = id_key(ARGV[o + 1])
|
|
1045
|
+
marks[#marks + 1] = field
|
|
1069
1046
|
end
|
|
1070
1047
|
index_batch(batch)
|
|
1071
1048
|
enqueue(batch)
|
|
1072
1049
|
if n > 0 then
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
if schema_missing then
|
|
1076
|
-
owner_writes[#owner_writes + 1] = SCHEMA
|
|
1077
|
-
owner_writes[#owner_writes + 1] = '${REDIS_LAYOUT}'
|
|
1078
|
-
end
|
|
1050
|
+
marks[#marks + 1] = COUNTER
|
|
1051
|
+
marks[#marks + 1] = tostring(total + n)
|
|
1079
1052
|
end
|
|
1080
1053
|
if typed_dirty then
|
|
1081
|
-
|
|
1082
|
-
|
|
1054
|
+
marks[#marks + 1] = TYPED
|
|
1055
|
+
marks[#marks + 1] = cjson.encode(typed)
|
|
1083
1056
|
end
|
|
1084
|
-
sliced('MSET', nil,
|
|
1057
|
+
sliced('MSET', nil, marks)
|
|
1085
1058
|
flush_states()
|
|
1086
1059
|
flush()
|
|
1087
1060
|
flush_ready()
|
|
1088
|
-
if n > 0 then wake(ARGV[9], tostring(total + n), now) end
|
|
1089
1061
|
return { 'ok', tostring(total + 1) }
|
|
1090
1062
|
`
|
|
1091
1063
|
|
|
1092
1064
|
/**
|
|
1093
|
-
* KEYS: counter
|
|
1065
|
+
* KEYS: counter.
|
|
1094
1066
|
* ARGV: consumer, tag, index, attempt, error, maxFailures, nowMs, retryAtMs
|
|
1095
1067
|
* or ''.
|
|
1096
1068
|
*/
|
|
1097
1069
|
const FAIL_ATTEMPT_LUA = `${BASE_LUA}
|
|
1098
|
-
|
|
1070
|
+
read_bounds()
|
|
1099
1071
|
local consumer, tag, idx, attempt = ARGV[1], ARGV[2], tonumber(ARGV[3]), tonumber(ARGV[4])
|
|
1100
1072
|
local at = row_of(consumer, tag)
|
|
1101
1073
|
if idx <= boundary then return { 'superseded', '0' } end
|
|
@@ -1132,13 +1104,13 @@ return { dead and 'dead_lettered' or 'failed', tostring(failures) }
|
|
|
1132
1104
|
`
|
|
1133
1105
|
|
|
1134
1106
|
/**
|
|
1135
|
-
* KEYS: counter
|
|
1107
|
+
* KEYS: counter. ARGV: consumer, index, action, nowMs, served
|
|
1136
1108
|
* consumers. Returns [redriven, hasPending] | ['index', type, after]. Acts
|
|
1137
1109
|
* only on the consumer's dead-lettered head at the index, above the clear
|
|
1138
1110
|
* marker.
|
|
1139
1111
|
*/
|
|
1140
1112
|
const REDRIVE_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
1141
|
-
|
|
1113
|
+
read_bounds()
|
|
1142
1114
|
use_served(ARGV[5])
|
|
1143
1115
|
local consumer, idx = ARGV[1], tonumber(ARGV[2])
|
|
1144
1116
|
local function result(redriven) return { redriven, has_pending() } end
|
|
@@ -1172,7 +1144,7 @@ return result('1')
|
|
|
1172
1144
|
`
|
|
1173
1145
|
|
|
1174
1146
|
/**
|
|
1175
|
-
* KEYS: counter
|
|
1147
|
+
* KEYS: counter. ARGV: consumer, lane, types JSON or '*', backfill
|
|
1176
1148
|
* ('1' or '0'). Brings one log cursor up to date without an append, as
|
|
1177
1149
|
* `plan` and `enqueue` would for an append carrying its types: a missing
|
|
1178
1150
|
* cursor starts at the clear marker with backfill, else at the head with
|
|
@@ -1182,7 +1154,7 @@ return result('1')
|
|
|
1182
1154
|
* columns, session head] | ['index', type, after].
|
|
1183
1155
|
*/
|
|
1184
1156
|
const WAKE_LOG_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
1185
|
-
|
|
1157
|
+
read_bounds()
|
|
1186
1158
|
local consumer, lane, ty = ARGV[1], ARGV[2], ARGV[3]
|
|
1187
1159
|
local types = types_of(ty)
|
|
1188
1160
|
local cid = cid_of(consumer, 'G' .. lane)
|
|
@@ -1228,13 +1200,13 @@ return { 'ok', tostring(state.p), state.h and tostring(state.h) or false, column
|
|
|
1228
1200
|
`
|
|
1229
1201
|
|
|
1230
1202
|
/**
|
|
1231
|
-
* KEYS: counter
|
|
1203
|
+
* KEYS: counter. Returns the dead-lettered heads above the clear
|
|
1232
1204
|
* marker as [consumer, tag, index, columns, entry] tuples.
|
|
1233
1205
|
*/
|
|
1234
1206
|
const DEAD_LETTERS_LUA = `${BASE_LUA}
|
|
1235
1207
|
local dead = redis.call('ZRANGEBYSCORE', READY, '-inf', '(0', 'WITHSCORES')
|
|
1236
1208
|
if #dead == 0 then return {} end
|
|
1237
|
-
|
|
1209
|
+
read_bounds()
|
|
1238
1210
|
local refs, items = {}, {}
|
|
1239
1211
|
for i = 1, #dead, 2 do
|
|
1240
1212
|
local consumer, tag = parse_cid(dead[i])
|
|
@@ -1256,12 +1228,12 @@ return result
|
|
|
1256
1228
|
`
|
|
1257
1229
|
|
|
1258
1230
|
/**
|
|
1259
|
-
* KEYS: counter
|
|
1231
|
+
* KEYS: counter. Returns every listed cursor as [consumer, tag,
|
|
1260
1232
|
* position, head or nil, columns]: lane and log cursors from the cursor
|
|
1261
1233
|
* hash, read against the clear marker, then the event cursors with a head.
|
|
1262
1234
|
*/
|
|
1263
1235
|
const CURSORS_LUA = `${BASE_LUA}
|
|
1264
|
-
|
|
1236
|
+
read_bounds()
|
|
1265
1237
|
local listed = {}
|
|
1266
1238
|
local fields = redis.call('HGETALL', CURSORS)
|
|
1267
1239
|
local records, order = {}, {}
|
|
@@ -1315,7 +1287,7 @@ return result
|
|
|
1315
1287
|
`
|
|
1316
1288
|
|
|
1317
1289
|
/**
|
|
1318
|
-
* KEYS: counter,
|
|
1290
|
+
* KEYS: counter, head, history.
|
|
1319
1291
|
* ARGV: write count, then per write: index, snapshot JSON, pin count, then
|
|
1320
1292
|
* per pin: consumer, tag, index; finally the reducer name. Writes from
|
|
1321
1293
|
* before the latest clear are dropped, and a head from before it is
|
|
@@ -1323,8 +1295,8 @@ return result
|
|
|
1323
1295
|
* claimed row says so itself, an unclaimed head by its cursor's ready score.
|
|
1324
1296
|
*/
|
|
1325
1297
|
const PUT_SNAPSHOTS_LUA = `${BASE_LUA}
|
|
1326
|
-
|
|
1327
|
-
local HEAD, HISTORY = KEYS[
|
|
1298
|
+
read_bounds()
|
|
1299
|
+
local HEAD, HISTORY = KEYS[2], KEYS[3]
|
|
1328
1300
|
local entries, refs, pins = {}, {}, {}
|
|
1329
1301
|
local offset = 2
|
|
1330
1302
|
for w = 1, tonumber(ARGV[1]) do
|
|
@@ -1440,79 +1412,78 @@ return 1
|
|
|
1440
1412
|
`
|
|
1441
1413
|
|
|
1442
1414
|
/**
|
|
1443
|
-
*
|
|
1444
|
-
*
|
|
1445
|
-
*
|
|
1446
|
-
* and numeric precision through Lua. Field-wise LWW by the sender's
|
|
1447
|
-
* `at`: a stored entry with a strictly newer stamp survives; ties go
|
|
1448
|
-
* to the incoming write (memory-tier parity). Returns the applied
|
|
1449
|
-
* 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
|
|
1450
|
-
* sound because core sends one uniform ttlMs; the per-entry `e` stamp
|
|
1451
|
-
* is the fine bound (the injected storage clock at the applied set
|
|
1452
|
-
* plus ttlMs — never the sender stamp), pruned on read against the
|
|
1453
|
-
* same clock.
|
|
1415
|
+
* Presence entries are `{"a":at,"e":expiresAt,"s":seen,"v":value}`, stamps
|
|
1416
|
+
* first and both integers, so scripts read them with a pattern instead of
|
|
1417
|
+
* decoding the value.
|
|
1454
1418
|
*/
|
|
1455
|
-
const
|
|
1456
|
-
local
|
|
1457
|
-
local
|
|
1458
|
-
|
|
1459
|
-
|
|
1419
|
+
const PRESENCE_STAMPS_LUA = `
|
|
1420
|
+
local function stamps(entry)
|
|
1421
|
+
local a, e = string.match(entry, '^{"a":(%-?%d+),"e":(%-?%d+),')
|
|
1422
|
+
return tonumber(a), tonumber(e)
|
|
1423
|
+
end
|
|
1424
|
+
`
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* KEYS: presence hash. ARGV: atMs, ttlMs, nowMs, n, then per entry:
|
|
1428
|
+
* hashField, entryJson (a null value is a tombstone). Field-wise LWW by the
|
|
1429
|
+
* sender's `at`: an unexpired stored entry with a strictly newer stamp
|
|
1430
|
+
* survives; ties go to the incoming write (memory-tier parity). Returns the applied 1-based entry
|
|
1431
|
+
* positions. The whole-key PEXPIRE is the coarse bound, sound because core
|
|
1432
|
+
* sends one uniform ttlMs; the per-entry `e` stamp is the fine bound (the
|
|
1433
|
+
* injected storage clock at the applied set plus ttlMs — never the sender
|
|
1434
|
+
* stamp), filtered on read against the same clock.
|
|
1435
|
+
*/
|
|
1436
|
+
const PRESENCE_SET_LUA = `${SLICED_LUA}${PRESENCE_STAMPS_LUA}
|
|
1437
|
+
local at, now, n = tonumber(ARGV[1]), tonumber(ARGV[3]), tonumber(ARGV[4])
|
|
1438
|
+
local fields = {}
|
|
1439
|
+
for i = 1, n do fields[i] = ARGV[4 + (i - 1) * 2 + 1] end
|
|
1440
|
+
local stored = sliced('HMGET', KEYS[1], fields)
|
|
1441
|
+
local applied, sets = {}, {}
|
|
1460
1442
|
for i = 1, n do
|
|
1461
|
-
local
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
redis.call('HDEL', KEYS[1], field)
|
|
1467
|
-
else
|
|
1468
|
-
redis.call('HSET', KEYS[1], field, value)
|
|
1469
|
-
end
|
|
1443
|
+
local a, e = false, false
|
|
1444
|
+
if stored[i] then a, e = stamps(stored[i]) end
|
|
1445
|
+
if not stored[i] or a <= at or e <= now then
|
|
1446
|
+
sets[#sets + 1] = fields[i]
|
|
1447
|
+
sets[#sets + 1] = ARGV[4 + (i - 1) * 2 + 2]
|
|
1470
1448
|
applied[#applied + 1] = tostring(i)
|
|
1471
|
-
published[#published + 1] = ARGV[3 + (i - 1) * 3 + 3]
|
|
1472
1449
|
end
|
|
1473
1450
|
end
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
redis.pcall('PUBLISH', ARGV[4 + n * 3], ARGV[5 + n * 3] .. table.concat(published, ',') .. '}}')
|
|
1477
|
-
end
|
|
1451
|
+
sliced('HSET', KEYS[1], sets)
|
|
1452
|
+
if #applied > 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
|
|
1478
1453
|
return applied
|
|
1479
1454
|
`
|
|
1480
1455
|
|
|
1481
1456
|
/**
|
|
1482
|
-
* KEYS: presence hash. ARGV: nowMs
|
|
1483
|
-
*
|
|
1484
|
-
*
|
|
1485
|
-
* expiry are HDELed in the same atomic step, so a stale reader can
|
|
1486
|
-
* never sweep a concurrently refreshed field.
|
|
1457
|
+
* KEYS: presence hash. ARGV: nowMs, then the fields a read saw expired.
|
|
1458
|
+
* Deletes those still at or past their expiry, re-checked in this atomic
|
|
1459
|
+
* step so a stale reader never sweeps a concurrently refreshed field.
|
|
1487
1460
|
*/
|
|
1488
|
-
const
|
|
1461
|
+
const PRESENCE_PRUNE_LUA = `${SLICED_LUA}${PRESENCE_STAMPS_LUA}
|
|
1489
1462
|
local now = tonumber(ARGV[1])
|
|
1490
|
-
local
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1463
|
+
local fields = {}
|
|
1464
|
+
for i = 2, #ARGV do fields[#fields + 1] = ARGV[i] end
|
|
1465
|
+
local stored = sliced('HMGET', KEYS[1], fields)
|
|
1466
|
+
local stale = {}
|
|
1467
|
+
for i = 1, #fields do
|
|
1468
|
+
if stored[i] then
|
|
1469
|
+
local _, e = stamps(stored[i])
|
|
1470
|
+
if e <= now then stale[#stale + 1] = fields[i] end
|
|
1498
1471
|
end
|
|
1499
1472
|
end
|
|
1500
|
-
|
|
1473
|
+
sliced('HDEL', KEYS[1], stale)
|
|
1474
|
+
return 1
|
|
1501
1475
|
`
|
|
1502
1476
|
|
|
1503
1477
|
/**
|
|
1504
|
-
* KEYS: counter,
|
|
1478
|
+
* KEYS: counter, head, history. ARGV: snapshot frontier or ''.
|
|
1505
1479
|
* Returns one atomic multiversion-cache-plus-log-frontier read, with the
|
|
1506
1480
|
* clear boundary; checkpoints from before it are ignored.
|
|
1507
1481
|
*/
|
|
1508
1482
|
const READ_STATE_LUA = `
|
|
1509
1483
|
local session = string.sub(KEYS[1], 1, -6)
|
|
1510
|
-
local counts = redis.call('MGET', KEYS[1], session .. 'clear'
|
|
1511
|
-
if counts[3] and counts[3] ~= '${REDIS_LAYOUT}' then
|
|
1512
|
-
return redis.error_reply('${LAYOUT_ERROR}')
|
|
1513
|
-
end
|
|
1484
|
+
local counts = redis.call('MGET', KEYS[1], session .. 'clear')
|
|
1514
1485
|
local boundary = tonumber(counts[2] or '0')
|
|
1515
|
-
local head = redis.call('GET', KEYS[
|
|
1486
|
+
local head = redis.call('GET', KEYS[2])
|
|
1516
1487
|
local head_index = head and cjson.decode(head)['index'] or nil
|
|
1517
1488
|
if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
|
|
1518
1489
|
return redis.error_reply('invalid snapshot head index')
|
|
@@ -1527,19 +1498,25 @@ if head_index and (cap == '' or head_index <= tonumber(cap)) then
|
|
|
1527
1498
|
snapshot = head
|
|
1528
1499
|
else
|
|
1529
1500
|
local upper = cap == '' and '+inf' or cap
|
|
1530
|
-
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[
|
|
1501
|
+
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[3], upper, boundary, 'LIMIT', 0, 1)
|
|
1531
1502
|
if #historical > 0 then snapshot = historical[1] end
|
|
1532
1503
|
end
|
|
1533
|
-
return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
|
|
1504
|
+
return { head_index or '', snapshot or '', counts[1] or counts[2] or '0', tostring(boundary) }
|
|
1534
1505
|
`
|
|
1535
1506
|
|
|
1536
1507
|
/**
|
|
1537
|
-
* What reclaim removes once
|
|
1508
|
+
* What reclaim removes once no entry is left below the latest clear: every
|
|
1538
1509
|
* registered reducer's checkpoints below the latest clear and the pin counts
|
|
1539
1510
|
* naming them (snapshot writes register each reducer in
|
|
1540
1511
|
* {prefix}:{session}:snap-reducers, so no key scan is needed), every head
|
|
1541
1512
|
* row below it (whole chunks, then the boundary chunk's older rows), the
|
|
1542
1513
|
* type indexes' older entries, and the lane cursors it left idle.
|
|
1514
|
+
*
|
|
1515
|
+
* A lifetime that ended empty then collapses to the clear marker, the log
|
|
1516
|
+
* holding it (readers resuming before the marker still read it), and the log
|
|
1517
|
+
* cursors the sweep reset to the marker. The counter reads from the marker
|
|
1518
|
+
* when absent, type indexes rebuild lazily, and the marker's own id key goes
|
|
1519
|
+
* too.
|
|
1543
1520
|
*/
|
|
1544
1521
|
const SWEEP_LUA = `
|
|
1545
1522
|
local function sweep(boundary)
|
|
@@ -1606,23 +1583,46 @@ local function sweep(boundary)
|
|
|
1606
1583
|
end
|
|
1607
1584
|
sliced('HDEL', CURSORS, idle)
|
|
1608
1585
|
sliced('HSET', CURSORS, moved)
|
|
1586
|
+
local count = redis.call('GET', COUNTER)
|
|
1587
|
+
if not count or tonumber(count) ~= boundary or redis.call('ZCARD', READY) > 0 then
|
|
1588
|
+
return
|
|
1589
|
+
end
|
|
1590
|
+
local stale = { COUNTER, TYPED, PIN_COUNTS, chunk_key(boundary) }
|
|
1591
|
+
local reducers = SESSION .. 'snap-reducers'
|
|
1592
|
+
for _, reducer in ipairs(redis.call('SMEMBERS', reducers)) do
|
|
1593
|
+
stale[#stale + 1] = SESSION .. 'snap:' .. reducer
|
|
1594
|
+
stale[#stale + 1] = SESSION .. 'snap-history:' .. reducer
|
|
1595
|
+
end
|
|
1596
|
+
stale[#stale + 1] = reducers
|
|
1597
|
+
local types = redis.call('GET', TYPED)
|
|
1598
|
+
if types then
|
|
1599
|
+
for name in pairs(cjson.decode(types)) do stale[#stale + 1] = type_key(name) end
|
|
1600
|
+
end
|
|
1601
|
+
local marker = redis.call('XRANGE', LOG, boundary .. '-0', boundary .. '-0')[1]
|
|
1602
|
+
if marker then
|
|
1603
|
+
for i = 1, #marker[2], 2 do
|
|
1604
|
+
if marker[2][i] == 'id' then stale[#stale + 1] = id_key(marker[2][i + 1]) end
|
|
1605
|
+
end
|
|
1606
|
+
end
|
|
1607
|
+
sliced('UNLINK', nil, stale)
|
|
1609
1608
|
end
|
|
1610
1609
|
`
|
|
1611
1610
|
|
|
1612
1611
|
/**
|
|
1613
|
-
* KEYS: counter
|
|
1612
|
+
* KEYS: counter.
|
|
1614
1613
|
* ARGV: sessionId, nowMs, ifLastIndex or '', cause consumer or '', cause tag,
|
|
1615
|
-
* cause index, cause attempt, marker id
|
|
1616
|
-
* Returns ['ok', cleared, head, boundary] | ['superseded'] |
|
|
1617
|
-
*
|
|
1618
|
-
*
|
|
1619
|
-
*
|
|
1620
|
-
*
|
|
1621
|
-
*
|
|
1614
|
+
* cause index, cause attempt, marker id.
|
|
1615
|
+
* Returns ['ok', cleared, head, boundary, appended?] | ['superseded'] |
|
|
1616
|
+
* ['foreign']; `appended` is '1' when this call appended the marker.
|
|
1617
|
+
* The marker joins the same log: the old lifetime's entries stay below it
|
|
1618
|
+
* until reclaim deletes them, and every read of the log starts at the
|
|
1619
|
+
* marker. (A stream can't be renamed aside: Upstash refuses RENAME on one.)
|
|
1620
|
+
* The ready set is unlinked, so claims and pending checks see only the new
|
|
1621
|
+
* lifetime. Type indexes keep their older entries until reclaim too:
|
|
1622
|
+
* lookups start above the marker.
|
|
1622
1623
|
*/
|
|
1623
|
-
const CLEAR_LUA = `${BASE_LUA}
|
|
1624
|
-
local
|
|
1625
|
-
if not values then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1624
|
+
const CLEAR_LUA = `${BASE_LUA}
|
|
1625
|
+
local marker_owner = owners_of(read_bounds({ ARGV[8] }))[1]
|
|
1626
1626
|
local head = total
|
|
1627
1627
|
if ARGV[4] ~= '' then
|
|
1628
1628
|
local idx = tonumber(ARGV[6])
|
|
@@ -1642,61 +1642,47 @@ end
|
|
|
1642
1642
|
if head == 0 or boundary == head then
|
|
1643
1643
|
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
1644
1644
|
end
|
|
1645
|
-
if
|
|
1645
|
+
if marker_owner then return { 'foreign' } end
|
|
1646
1646
|
local field = tostring(head + 1)
|
|
1647
|
-
local moved = SESSION .. 'cleared:' .. field
|
|
1648
|
-
redis.call('RENAME', LOG, moved)
|
|
1649
1647
|
redis.call('UNLINK', READY)
|
|
1650
|
-
redis.call('SADD', SESSION .. 'cleared', moved)
|
|
1651
1648
|
redis.call('XADD', LOG, field .. '-0',
|
|
1652
1649
|
'id', ARGV[8], 'type', CLEAR_TYPE, 'payload', 'null',
|
|
1653
1650
|
'created_at', ARGV[2], 'cause', '')
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
marker[#marker + 1] = SCHEMA
|
|
1657
|
-
marker[#marker + 1] = '${REDIS_LAYOUT}'
|
|
1658
|
-
end
|
|
1659
|
-
redis.call('MSET', unpack(marker))
|
|
1660
|
-
wake_add(field, ARGV[8], CLEAR_TYPE, 'null')
|
|
1661
|
-
wake(ARGV[9], field, ARGV[2])
|
|
1662
|
-
return { 'ok', '1', field, field }
|
|
1651
|
+
redis.call('MSET', id_key(ARGV[8]), field, COUNTER, field, CLEAR, field)
|
|
1652
|
+
return { 'ok', '1', field, field, '1' }
|
|
1663
1653
|
`
|
|
1664
1654
|
|
|
1665
1655
|
/**
|
|
1666
|
-
* KEYS: counter
|
|
1667
|
-
* The next deletion batch:
|
|
1668
|
-
*
|
|
1669
|
-
*
|
|
1670
|
-
*
|
|
1656
|
+
* KEYS: counter. ARGV: page size.
|
|
1657
|
+
* The next deletion batch: up to a page of [index, id] pairs of the log's
|
|
1658
|
+
* entries below the latest clear. With none left it sweeps what the old
|
|
1659
|
+
* lifetimes left and returns nothing, so a retry finishes an interrupted
|
|
1660
|
+
* deletion.
|
|
1671
1661
|
*/
|
|
1672
1662
|
const RECLAIM_BATCH_LUA = `${BASE_LUA}${SWEEP_LUA}
|
|
1673
1663
|
local boundary = tonumber(redis.call('GET', CLEAR) or '0')
|
|
1674
1664
|
if boundary == 0 then return {} end
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1665
|
+
local entries = redis.call('XRANGE', LOG, '-', tostring(boundary - 1) .. '-0', 'COUNT', ARGV[1])
|
|
1666
|
+
if #entries == 0 then
|
|
1667
|
+
sweep(boundary)
|
|
1668
|
+
return {}
|
|
1669
|
+
end
|
|
1670
|
+
local batch = {}
|
|
1671
|
+
for _, entry in ipairs(entries) do
|
|
1672
|
+
local fields = entry[2]
|
|
1673
|
+
for i = 1, #fields, 2 do
|
|
1674
|
+
if fields[i] == 'id' then
|
|
1675
|
+
batch[#batch + 1] = string.match(entry[1], '^(%d+)')
|
|
1676
|
+
batch[#batch + 1] = fields[i + 1]
|
|
1677
|
+
break
|
|
1688
1678
|
end
|
|
1689
|
-
return batch
|
|
1690
1679
|
end
|
|
1691
|
-
redis.call('DEL', log)
|
|
1692
|
-
redis.call('SREM', SESSION .. 'cleared', log)
|
|
1693
1680
|
end
|
|
1694
|
-
|
|
1695
|
-
return {}
|
|
1681
|
+
return batch
|
|
1696
1682
|
`
|
|
1697
1683
|
|
|
1698
1684
|
/**
|
|
1699
|
-
* KEYS: counter
|
|
1685
|
+
* KEYS: counter.
|
|
1700
1686
|
* ARGV: sessionId, n, then per entry: index, id.
|
|
1701
1687
|
* Deletes the listed entries below the latest clear and their id records.
|
|
1702
1688
|
* An id record survives when it names another event, so an id reused in the
|
|
@@ -1704,45 +1690,70 @@ return {}
|
|
|
1704
1690
|
*/
|
|
1705
1691
|
const RECLAIM_LUA = `${BASE_LUA}${SWEEP_LUA}
|
|
1706
1692
|
local boundary = tonumber(redis.call('GET', CLEAR) or '0')
|
|
1707
|
-
local
|
|
1708
|
-
local id_keys, indexes, stream_ids = {}, {}, {}
|
|
1693
|
+
local ids, indexes, stream_ids = {}, {}, {}
|
|
1709
1694
|
-- A concurrent reclaim may list the same batch; only XDEL says who removed it.
|
|
1710
1695
|
local removed = 0
|
|
1711
1696
|
for i = 1, tonumber(ARGV[2]) do
|
|
1712
1697
|
local field = ARGV[2 + (i - 1) * 2 + 1]
|
|
1713
1698
|
if tonumber(field) < boundary then
|
|
1714
|
-
|
|
1715
|
-
indexes[#
|
|
1716
|
-
stream_ids[#
|
|
1717
|
-
end
|
|
1718
|
-
end
|
|
1719
|
-
if #
|
|
1720
|
-
local
|
|
1721
|
-
for i = 1, #
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
end
|
|
1699
|
+
ids[#ids + 1] = ARGV[2 + (i - 1) * 2 + 2]
|
|
1700
|
+
indexes[#ids] = tonumber(field)
|
|
1701
|
+
stream_ids[#ids] = field .. '-0'
|
|
1702
|
+
end
|
|
1703
|
+
end
|
|
1704
|
+
if #ids > 0 then
|
|
1705
|
+
local keys = {}
|
|
1706
|
+
for i = 1, #ids do keys[i] = id_key(ids[i]) end
|
|
1707
|
+
local owners, stale = sliced('MGET', nil, keys), {}
|
|
1708
|
+
for i = 1, #ids do
|
|
1709
|
+
if owners[i] and tonumber(owners[i]) == indexes[i] then
|
|
1710
|
+
stale[#stale + 1] = keys[i]
|
|
1727
1711
|
end
|
|
1728
1712
|
end
|
|
1729
|
-
sliced('DEL', nil,
|
|
1730
|
-
removed = redis.call('XDEL',
|
|
1713
|
+
sliced('DEL', nil, stale)
|
|
1714
|
+
removed = redis.call('XDEL', LOG, unpack(stream_ids))
|
|
1731
1715
|
end
|
|
1732
1716
|
local done = 0
|
|
1733
|
-
if redis.call('
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
if redis.call('SCARD', SESSION .. 'cleared') == 0 then
|
|
1737
|
-
sweep(boundary)
|
|
1738
|
-
done = 1
|
|
1739
|
-
end
|
|
1717
|
+
if #redis.call('XRANGE', LOG, '-', tostring(boundary - 1) .. '-0', 'COUNT', 1) == 0 then
|
|
1718
|
+
sweep(boundary)
|
|
1719
|
+
done = 1
|
|
1740
1720
|
end
|
|
1741
1721
|
return { removed, done }
|
|
1742
1722
|
`
|
|
1743
1723
|
|
|
1744
1724
|
// ── wire helpers ─────────────────────────────────────────────────────
|
|
1745
1725
|
|
|
1726
|
+
const WAKE_PAYLOAD_LIMIT = 32_768
|
|
1727
|
+
const encoder = new TextEncoder()
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* A wake-up announcing events through `frontier`. It carries them, so a
|
|
1731
|
+
* caught-up reader applies them without reading the stream:
|
|
1732
|
+
* `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`, with
|
|
1733
|
+
* payloads inlined as their stored JSON text. One over WAKE_PAYLOAD_LIMIT
|
|
1734
|
+
* bytes, or one without entries, announces only `{"f":frontier}`, and
|
|
1735
|
+
* readers catch up with XRANGE.
|
|
1736
|
+
*/
|
|
1737
|
+
const wakeMessage = (
|
|
1738
|
+
frontier: number,
|
|
1739
|
+
createdAt: number,
|
|
1740
|
+
entries: ReadonlyArray<
|
|
1741
|
+
readonly [index: number, id: string, type: string, payloadJson: string]
|
|
1742
|
+
>,
|
|
1743
|
+
): string => {
|
|
1744
|
+
const bare = `{"f":${frontier}}`
|
|
1745
|
+
if (entries.length === 0) return bare
|
|
1746
|
+
const carried = `{"f":${frontier},"t":${createdAt},"e":[${entries
|
|
1747
|
+
.map(
|
|
1748
|
+
([index, id, type, payload]) =>
|
|
1749
|
+
`[${index},${JSON.stringify(id)},${JSON.stringify(type)},${payload}]`,
|
|
1750
|
+
)
|
|
1751
|
+
.join(',')}]}`
|
|
1752
|
+
return encoder.encode(carried).byteLength > WAKE_PAYLOAD_LIMIT
|
|
1753
|
+
? bare
|
|
1754
|
+
: carried
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1746
1757
|
export type StreamEntry = [id: string, fields: string[]]
|
|
1747
1758
|
|
|
1748
1759
|
const fieldMap = (fields: string[]): Record<string, string> => {
|
|
@@ -2000,9 +2011,9 @@ const toIndex = ({
|
|
|
2000
2011
|
}
|
|
2001
2012
|
|
|
2002
2013
|
/**
|
|
2003
|
-
* A range read that met a gap.
|
|
2004
|
-
* a read whose bounds predate
|
|
2005
|
-
* anywhere else a gap is corruption.
|
|
2014
|
+
* A range read that met a gap. Reclaim deletes the entries below a clear, so
|
|
2015
|
+
* a read whose bounds predate the clear finds nothing where it expected
|
|
2016
|
+
* events; anywhere else a gap is corruption.
|
|
2006
2017
|
*/
|
|
2007
2018
|
const rangeGap = (sessionId: string, message: string): A2Error =>
|
|
2008
2019
|
Object.assign(
|
|
@@ -2042,29 +2053,49 @@ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
|
2042
2053
|
}
|
|
2043
2054
|
}
|
|
2044
2055
|
|
|
2056
|
+
/**
|
|
2057
|
+
* A session's key. Every key of a session shares one hash tag, so a script
|
|
2058
|
+
* locks only its session on Upstash and stays in one slot on a cluster. The
|
|
2059
|
+
* tag is the session id as JSON with its braces escaped: it can't end early,
|
|
2060
|
+
* and no session's tag spells another's.
|
|
2061
|
+
*/
|
|
2062
|
+
export const redisSessionKey = ({
|
|
2063
|
+
prefix,
|
|
2064
|
+
sessionId,
|
|
2065
|
+
suffix,
|
|
2066
|
+
}: {
|
|
2067
|
+
prefix: string
|
|
2068
|
+
sessionId: string
|
|
2069
|
+
suffix: string
|
|
2070
|
+
}): string =>
|
|
2071
|
+
`${prefix}:{${JSON.stringify(sessionId).replaceAll('{', '\\u007b').replaceAll('}', '\\u007d')}}:${suffix}`
|
|
2072
|
+
|
|
2045
2073
|
export function createRedisStoreCore(
|
|
2046
2074
|
options: RedisStoreCoreOptions,
|
|
2047
2075
|
): RedisStoreCore {
|
|
2048
2076
|
const { call, callBytes, clock } = options
|
|
2049
2077
|
const generateId = options.ids
|
|
2050
2078
|
const prefix = options.keyPrefix
|
|
2079
|
+
if (prefix.includes('{') || prefix.includes('}')) {
|
|
2080
|
+
throw new TypeError(
|
|
2081
|
+
'a redis keyPrefix cannot contain braces: they would set the hash tag of every key',
|
|
2082
|
+
)
|
|
2083
|
+
}
|
|
2051
2084
|
|
|
2052
|
-
const
|
|
2053
|
-
|
|
2085
|
+
const sessionKey = (sessionId: string, suffix: string): string =>
|
|
2086
|
+
redisSessionKey({ prefix, sessionId, suffix })
|
|
2087
|
+
const storeKey = (s: string): string => sessionKey(s, 'log')
|
|
2088
|
+
const countKey = (s: string): string => sessionKey(s, 'count')
|
|
2054
2089
|
// Scripts derive the session's other keys from the counter; keep them in step.
|
|
2055
|
-
const clearKey = (s: string): string =>
|
|
2090
|
+
const clearKey = (s: string): string => sessionKey(s, 'clear')
|
|
2056
2091
|
const schemaKey = `${prefix}:schema`
|
|
2057
|
-
const snapKey = (s: string, r: string): string =>
|
|
2092
|
+
const snapKey = (s: string, r: string): string => sessionKey(s, `snap:${r}`)
|
|
2058
2093
|
const snapHistoryKey = (s: string, r: string): string =>
|
|
2059
|
-
|
|
2060
|
-
const presenceKey = (s: string): string =>
|
|
2094
|
+
sessionKey(s, `snap-history:${r}`)
|
|
2095
|
+
const presenceKey = (s: string): string => sessionKey(s, 'presence')
|
|
2096
|
+
// Channels aren't keys; the notify store subscribes by these names.
|
|
2061
2097
|
const notifyChannel = (s: string): string => `${prefix}:${s}:notify`
|
|
2062
|
-
|
|
2063
|
-
// Session ids are raw and may contain colons, so a raw id could name a
|
|
2064
|
-
// session key (`ids:count`). A JSON string with escaped colons starts with
|
|
2065
|
-
// a quote and has no colon, so it never matches a session key's suffix.
|
|
2066
|
-
const idKey = (id: string): string =>
|
|
2067
|
-
`${prefix}:ids:${JSON.stringify(id).replaceAll(':', '\\u003a')}`
|
|
2098
|
+
const presenceChannel = (s: string): string => `${prefix}:${s}:presence`
|
|
2068
2099
|
|
|
2069
2100
|
const layoutError = (): A2Error =>
|
|
2070
2101
|
new A2Error(
|
|
@@ -2072,21 +2103,80 @@ export function createRedisStoreCore(
|
|
|
2072
2103
|
`the redis keys under prefix '${prefix}' hold an A2 layout other than '${REDIS_LAYOUT}', from another release; point the store at a fresh keyPrefix, or give the contract a new name so it starts new sessions`,
|
|
2073
2104
|
)
|
|
2074
2105
|
|
|
2075
|
-
/**
|
|
2076
|
-
|
|
2106
|
+
/**
|
|
2107
|
+
* Once per store, before its first script: claim or check the prefix's
|
|
2108
|
+
* layout, and ask the server whether scripts may lock only their keys.
|
|
2109
|
+
* Only the server's own rejection of the flag means no; a transport error
|
|
2110
|
+
* fails this attempt, and the next operation asks again.
|
|
2111
|
+
*/
|
|
2112
|
+
const prepared = retryableLazy(async () => {
|
|
2113
|
+
const [, keyLocking] = await Promise.all([
|
|
2114
|
+
(async () => {
|
|
2115
|
+
await call('SET', schemaKey, REDIS_LAYOUT, 'NX')
|
|
2116
|
+
if ((await call('GET', schemaKey)) !== REDIS_LAYOUT) throw layoutError()
|
|
2117
|
+
})(),
|
|
2118
|
+
call('EVAL', `${KEY_LOCKING}\nreturn 1`, 1, schemaKey).then(
|
|
2119
|
+
() => true,
|
|
2120
|
+
(error: unknown) => {
|
|
2121
|
+
if (SHEBANG_REJECTED.test(String((error as Error)?.message)))
|
|
2122
|
+
return false
|
|
2123
|
+
throw error
|
|
2124
|
+
},
|
|
2125
|
+
),
|
|
2126
|
+
])
|
|
2127
|
+
return { keyLocking }
|
|
2128
|
+
})
|
|
2129
|
+
|
|
2130
|
+
/** Runs a script; a `readOnly` one takes Upstash's shared lock. */
|
|
2131
|
+
const runScript = async ({
|
|
2132
|
+
script,
|
|
2133
|
+
keys,
|
|
2134
|
+
args,
|
|
2135
|
+
readOnly,
|
|
2136
|
+
}: {
|
|
2137
|
+
script: string
|
|
2138
|
+
keys: string[]
|
|
2139
|
+
args: Array<string | number>
|
|
2140
|
+
readOnly: boolean
|
|
2141
|
+
}): Promise<unknown> => {
|
|
2142
|
+
const { keyLocking } = await prepared.get()
|
|
2143
|
+
let source = script
|
|
2144
|
+
if (keyLocking)
|
|
2145
|
+
source = `${readOnly ? READ_ONLY_KEY_LOCKING : KEY_LOCKING}\n${script}`
|
|
2146
|
+
return call('EVAL', source, keys.length, ...keys, ...args)
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
/** Runs a writing script over a session: KEYS start with its counter. */
|
|
2150
|
+
const evalScript = (
|
|
2077
2151
|
script: string,
|
|
2078
2152
|
sessionId: string,
|
|
2079
2153
|
keys: string[],
|
|
2080
2154
|
args: Array<string | number>,
|
|
2081
|
-
): Promise<unknown> =>
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2155
|
+
): Promise<unknown> =>
|
|
2156
|
+
runScript({
|
|
2157
|
+
script,
|
|
2158
|
+
keys: [countKey(sessionId), ...keys],
|
|
2159
|
+
args,
|
|
2160
|
+
readOnly: false,
|
|
2161
|
+
})
|
|
2162
|
+
|
|
2163
|
+
/**
|
|
2164
|
+
* Publishes a write's wake-up after its script, never inside it: a script
|
|
2165
|
+
* holds its lock while PUBLISH waits on subscribers. Wake-ups are hints (a
|
|
2166
|
+
* lost one costs subscribers a catch-up read, never an event), so a failed
|
|
2167
|
+
* publish is dropped and the write waits for it at most
|
|
2168
|
+
* NOTIFY_TIMINGS.publishMs.
|
|
2169
|
+
*/
|
|
2170
|
+
const publish = async (channel: string, message: string): Promise<void> => {
|
|
2171
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
2172
|
+
await Promise.race([
|
|
2173
|
+
call('PUBLISH', channel, message).catch(() => undefined),
|
|
2174
|
+
new Promise<void>((resolve) => {
|
|
2175
|
+
timer = setTimeout(resolve, NOTIFY_TIMINGS.publishMs)
|
|
2176
|
+
;(timer as { unref?: () => void }).unref?.()
|
|
2177
|
+
}),
|
|
2178
|
+
])
|
|
2179
|
+
clearTimeout(timer)
|
|
2090
2180
|
}
|
|
2091
2181
|
|
|
2092
2182
|
/**
|
|
@@ -2115,25 +2205,36 @@ export function createRedisStoreCore(
|
|
|
2115
2205
|
}
|
|
2116
2206
|
}
|
|
2117
2207
|
|
|
2118
|
-
/** The frontier and clear marker,
|
|
2208
|
+
/** The frontier and clear marker, in one MGET. */
|
|
2119
2209
|
const readBounds = async (
|
|
2120
2210
|
sessionId: string,
|
|
2121
2211
|
): Promise<{ frontier: number; clearIndex: number }> => {
|
|
2122
|
-
|
|
2212
|
+
await prepared.get()
|
|
2213
|
+
const [count, cleared] = (await call(
|
|
2123
2214
|
'MGET',
|
|
2124
2215
|
countKey(sessionId),
|
|
2125
2216
|
clearKey(sessionId),
|
|
2126
|
-
|
|
2127
|
-
)) as [unknown, unknown, unknown]
|
|
2128
|
-
if (schema !== null && schema !== undefined && schema !== REDIS_LAYOUT) {
|
|
2129
|
-
throw layoutError()
|
|
2130
|
-
}
|
|
2217
|
+
)) as [unknown, unknown]
|
|
2131
2218
|
return {
|
|
2132
|
-
|
|
2219
|
+
// A collapsed session keeps only its clear marker.
|
|
2220
|
+
frontier: toIndex({
|
|
2221
|
+
raw: count ?? cleared,
|
|
2222
|
+
sessionId,
|
|
2223
|
+
name: 'event count',
|
|
2224
|
+
}),
|
|
2133
2225
|
clearIndex: toIndex({ raw: cleared, sessionId, name: 'clear boundary' }),
|
|
2134
2226
|
}
|
|
2135
2227
|
}
|
|
2136
2228
|
|
|
2229
|
+
const readClearIndex = async (sessionId: string): Promise<number> => {
|
|
2230
|
+
await prepared.get()
|
|
2231
|
+
return toIndex({
|
|
2232
|
+
raw: await call('GET', clearKey(sessionId)),
|
|
2233
|
+
sessionId,
|
|
2234
|
+
name: 'clear boundary',
|
|
2235
|
+
})
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2137
2238
|
const readEventPages = async (
|
|
2138
2239
|
sessionId: string,
|
|
2139
2240
|
afterIndex: number,
|
|
@@ -2314,22 +2415,22 @@ export function createRedisStoreCore(
|
|
|
2314
2415
|
cause: e.cause ? { ...e.cause } : null,
|
|
2315
2416
|
targets: e.targets,
|
|
2316
2417
|
}))
|
|
2418
|
+
const payloads = withIds.map((e) => json(e.payload))
|
|
2317
2419
|
const reply = (await indexing(sessionId, () =>
|
|
2318
2420
|
evalScript(
|
|
2319
2421
|
APPEND_LUA,
|
|
2320
2422
|
sessionId,
|
|
2321
|
-
|
|
2423
|
+
[],
|
|
2322
2424
|
[
|
|
2323
2425
|
sessionId,
|
|
2324
2426
|
now,
|
|
2325
2427
|
withIds.length,
|
|
2326
2428
|
servedArg(appendOptions?.consumers),
|
|
2327
|
-
notifyChannel(sessionId),
|
|
2328
2429
|
appendOptions?.originIndex ?? '',
|
|
2329
|
-
...withIds.flatMap((e) => [
|
|
2430
|
+
...withIds.flatMap((e, i) => [
|
|
2330
2431
|
e.id,
|
|
2331
2432
|
e.type,
|
|
2332
|
-
|
|
2433
|
+
payloads[i]!,
|
|
2333
2434
|
e.cause ? json(e.cause) : '',
|
|
2334
2435
|
targetsArg(e.targets),
|
|
2335
2436
|
]),
|
|
@@ -2341,12 +2442,6 @@ export function createRedisStoreCore(
|
|
|
2341
2442
|
if (verdict === 'empty') {
|
|
2342
2443
|
return { events: [], hasPending: reply[1] === '1' }
|
|
2343
2444
|
}
|
|
2344
|
-
if (verdict === 'foreign') {
|
|
2345
|
-
throw new A2Error(
|
|
2346
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
2347
|
-
`event id '${reply[1]}' already exists in another session`,
|
|
2348
|
-
)
|
|
2349
|
-
}
|
|
2350
2445
|
if (verdict === 'cleared') {
|
|
2351
2446
|
throw sessionClearedError(
|
|
2352
2447
|
`scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`,
|
|
@@ -2382,12 +2477,23 @@ export function createRedisStoreCore(
|
|
|
2382
2477
|
`session '${sessionId}' was cleared while an append retry replayed; retry it`,
|
|
2383
2478
|
)
|
|
2384
2479
|
}
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2480
|
+
const replayed = idempotentReplay(events, existing)
|
|
2481
|
+
// The first attempt may have committed without publishing.
|
|
2482
|
+
await publish(
|
|
2483
|
+
notifyChannel(sessionId),
|
|
2484
|
+
wakeMessage(firstIndex + events.length - 1, now, []),
|
|
2485
|
+
)
|
|
2486
|
+
return { events: replayed, hasPending }
|
|
2389
2487
|
}
|
|
2390
2488
|
const base = Number(reply[1]) - 1
|
|
2489
|
+
await publish(
|
|
2490
|
+
notifyChannel(sessionId),
|
|
2491
|
+
wakeMessage(
|
|
2492
|
+
base + withIds.length,
|
|
2493
|
+
now,
|
|
2494
|
+
withIds.map((e, i) => [base + 1 + i, e.id, e.type, payloads[i]!]),
|
|
2495
|
+
),
|
|
2496
|
+
)
|
|
2391
2497
|
return {
|
|
2392
2498
|
events: withIds.map((e, i) => ({
|
|
2393
2499
|
id: e.id,
|
|
@@ -2405,12 +2511,12 @@ export function createRedisStoreCore(
|
|
|
2405
2511
|
|
|
2406
2512
|
async cursors(sessionId) {
|
|
2407
2513
|
return wrap(async () => {
|
|
2408
|
-
const reply = (await
|
|
2409
|
-
CURSORS_LUA,
|
|
2410
|
-
sessionId,
|
|
2411
|
-
[],
|
|
2412
|
-
|
|
2413
|
-
)) as Array<
|
|
2514
|
+
const reply = (await runScript({
|
|
2515
|
+
script: CURSORS_LUA,
|
|
2516
|
+
keys: [countKey(sessionId)],
|
|
2517
|
+
args: [],
|
|
2518
|
+
readOnly: true,
|
|
2519
|
+
})) as Array<
|
|
2414
2520
|
[
|
|
2415
2521
|
consumer: string,
|
|
2416
2522
|
tag: string,
|
|
@@ -2544,11 +2650,12 @@ export function createRedisStoreCore(
|
|
|
2544
2650
|
}
|
|
2545
2651
|
const now = clock.now().getTime()
|
|
2546
2652
|
const cause: EventCause = { consumer, lane, index, attempt }
|
|
2653
|
+
const payloads = events.map((event) => json(event.payload))
|
|
2547
2654
|
const reply = (await indexing(sessionId, () =>
|
|
2548
2655
|
evalScript(
|
|
2549
2656
|
COMPLETE_ATTEMPT_LUA,
|
|
2550
2657
|
sessionId,
|
|
2551
|
-
|
|
2658
|
+
[],
|
|
2552
2659
|
[
|
|
2553
2660
|
sessionId,
|
|
2554
2661
|
now,
|
|
@@ -2558,13 +2665,12 @@ export function createRedisStoreCore(
|
|
|
2558
2665
|
attempt,
|
|
2559
2666
|
json(cause),
|
|
2560
2667
|
servedArg(consumers),
|
|
2561
|
-
notifyChannel(sessionId),
|
|
2562
2668
|
json(ids),
|
|
2563
2669
|
events.length,
|
|
2564
|
-
...events.flatMap((event) => [
|
|
2670
|
+
...events.flatMap((event, i) => [
|
|
2565
2671
|
event.id,
|
|
2566
2672
|
event.type,
|
|
2567
|
-
|
|
2673
|
+
payloads[i]!,
|
|
2568
2674
|
targetsArg(event.targets),
|
|
2569
2675
|
]),
|
|
2570
2676
|
],
|
|
@@ -2586,6 +2692,21 @@ export function createRedisStoreCore(
|
|
|
2586
2692
|
|
|
2587
2693
|
if (outcome === 'ok') {
|
|
2588
2694
|
const base = Number(reply[1]) - 1
|
|
2695
|
+
if (events.length > 0) {
|
|
2696
|
+
await publish(
|
|
2697
|
+
notifyChannel(sessionId),
|
|
2698
|
+
wakeMessage(
|
|
2699
|
+
base + events.length,
|
|
2700
|
+
now,
|
|
2701
|
+
events.map((event, i) => [
|
|
2702
|
+
base + 1 + i,
|
|
2703
|
+
event.id,
|
|
2704
|
+
event.type,
|
|
2705
|
+
payloads[i]!,
|
|
2706
|
+
]),
|
|
2707
|
+
),
|
|
2708
|
+
)
|
|
2709
|
+
}
|
|
2589
2710
|
return {
|
|
2590
2711
|
outcome: 'completed',
|
|
2591
2712
|
events: events.map((event, i) => ({
|
|
@@ -2627,10 +2748,13 @@ export function createRedisStoreCore(
|
|
|
2627
2748
|
'returned event retry does not match the committed batch',
|
|
2628
2749
|
)
|
|
2629
2750
|
}
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2751
|
+
const replayed = idempotentReplay(events, existing)
|
|
2752
|
+
// The first attempt may have committed without publishing.
|
|
2753
|
+
await publish(
|
|
2754
|
+
notifyChannel(sessionId),
|
|
2755
|
+
wakeMessage(firstIndex + events.length - 1, now, []),
|
|
2756
|
+
)
|
|
2757
|
+
return { outcome: 'completed', events: replayed }
|
|
2634
2758
|
}
|
|
2635
2759
|
throw new TypeError(`unexpected completion outcome '${outcome}'`)
|
|
2636
2760
|
})
|
|
@@ -2678,12 +2802,12 @@ export function createRedisStoreCore(
|
|
|
2678
2802
|
|
|
2679
2803
|
async deadLetters(sessionId) {
|
|
2680
2804
|
return wrap(async () => {
|
|
2681
|
-
const reply = (await
|
|
2682
|
-
DEAD_LETTERS_LUA,
|
|
2683
|
-
sessionId,
|
|
2684
|
-
[],
|
|
2685
|
-
|
|
2686
|
-
)) as HeadTuple[]
|
|
2805
|
+
const reply = (await runScript({
|
|
2806
|
+
script: DEAD_LETTERS_LUA,
|
|
2807
|
+
keys: [countKey(sessionId)],
|
|
2808
|
+
args: [],
|
|
2809
|
+
readOnly: true,
|
|
2810
|
+
})) as HeadTuple[]
|
|
2687
2811
|
return reply
|
|
2688
2812
|
.map((head) => toHeadEvent(sessionId, head))
|
|
2689
2813
|
.toSorted(byHead)
|
|
@@ -2742,19 +2866,20 @@ export function createRedisStoreCore(
|
|
|
2742
2866
|
async readState(sessionId, reducerName, stateOptions) {
|
|
2743
2867
|
return wrap(() =>
|
|
2744
2868
|
retryAfterGap(async () => {
|
|
2745
|
-
const reply = (await
|
|
2746
|
-
READ_STATE_LUA,
|
|
2747
|
-
|
|
2748
|
-
|
|
2869
|
+
const reply = (await runScript({
|
|
2870
|
+
script: READ_STATE_LUA,
|
|
2871
|
+
keys: [
|
|
2872
|
+
countKey(sessionId),
|
|
2749
2873
|
snapKey(sessionId, reducerName),
|
|
2750
2874
|
snapHistoryKey(sessionId, reducerName),
|
|
2751
2875
|
],
|
|
2752
|
-
[
|
|
2876
|
+
args: [
|
|
2753
2877
|
stateOptions?.snapshotThroughIndex ??
|
|
2754
2878
|
stateOptions?.throughIndex ??
|
|
2755
2879
|
'',
|
|
2756
2880
|
],
|
|
2757
|
-
|
|
2881
|
+
readOnly: true,
|
|
2882
|
+
})) as [number | string, string, string, string]
|
|
2758
2883
|
const rawHead = reply[0]
|
|
2759
2884
|
const raw = reply[1]
|
|
2760
2885
|
const headIndex = rawHead === '' ? null : Number(rawHead)
|
|
@@ -2864,71 +2989,76 @@ export function createRedisStoreCore(
|
|
|
2864
2989
|
// Expiry anchors on the injected storage clock — the sender's
|
|
2865
2990
|
// `a` stamp orders writes, the `e` stamp bounds their
|
|
2866
2991
|
// lifetime, and the two clocks never mix.
|
|
2867
|
-
const
|
|
2992
|
+
const nowMs = clock.now().getTime()
|
|
2993
|
+
const expiresAtMs = nowMs + meta.ttlMs
|
|
2868
2994
|
// The hash field is JSON `[participant, field]`: both are
|
|
2869
2995
|
// caller strings, so no separator character is
|
|
2870
2996
|
// collision-safe — the JSON array is.
|
|
2871
|
-
const applied = (await
|
|
2872
|
-
|
|
2873
|
-
|
|
2874
|
-
|
|
2875
|
-
|
|
2876
|
-
|
|
2877
|
-
|
|
2878
|
-
|
|
2879
|
-
|
|
2880
|
-
|
|
2881
|
-
value
|
|
2882
|
-
|
|
2883
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
|
|
2887
|
-
|
|
2888
|
-
}),
|
|
2889
|
-
`${json(field)}:${json(value)}`,
|
|
2890
|
-
]),
|
|
2891
|
-
presenceKey(ns),
|
|
2892
|
-
`${json({ src: options.presenceSource, participant, seen: meta.seen, at: atMs }).slice(0, -1)},"values":{`,
|
|
2893
|
-
)) as string[]
|
|
2997
|
+
const applied = (await runScript({
|
|
2998
|
+
script: PRESENCE_SET_LUA,
|
|
2999
|
+
keys: [presenceKey(ns)],
|
|
3000
|
+
args: [
|
|
3001
|
+
atMs,
|
|
3002
|
+
meta.ttlMs,
|
|
3003
|
+
nowMs,
|
|
3004
|
+
entries.length,
|
|
3005
|
+
// A null is stored as a tombstone: it keeps its stamp, so an
|
|
3006
|
+
// older set that arrives after it still loses.
|
|
3007
|
+
...entries.flatMap(([field, value]) => [
|
|
3008
|
+
JSON.stringify([participant, field]),
|
|
3009
|
+
json({ a: atMs, e: expiresAtMs, s: meta.seen, v: value }),
|
|
3010
|
+
]),
|
|
3011
|
+
],
|
|
3012
|
+
readOnly: false,
|
|
3013
|
+
})) as string[]
|
|
2894
3014
|
if (applied.length === 0) return null
|
|
2895
3015
|
// Field-keyed and caller-named — null-prototype, like every
|
|
2896
3016
|
// presence map (see `nullProtoRecord`).
|
|
2897
3017
|
const appliedValues: Record<string, unknown> = nullProtoRecord()
|
|
3018
|
+
const published: string[] = []
|
|
2898
3019
|
for (const position of applied) {
|
|
2899
3020
|
const [field, value] = entries[Number(position) - 1]!
|
|
2900
3021
|
appliedValues[field] = structuredClone(value)
|
|
3022
|
+
published.push(`${json(field)}:${json(value)}`)
|
|
2901
3023
|
}
|
|
2902
3024
|
return {
|
|
2903
|
-
|
|
2904
|
-
|
|
2905
|
-
|
|
2906
|
-
|
|
3025
|
+
patch: {
|
|
3026
|
+
participant,
|
|
3027
|
+
values: appliedValues,
|
|
3028
|
+
seen: meta.seen,
|
|
3029
|
+
at: new Date(atMs),
|
|
3030
|
+
},
|
|
3031
|
+
published: publish(
|
|
3032
|
+
presenceChannel(ns),
|
|
3033
|
+
`${json({ src: options.presenceSource, participant, seen: meta.seen, at: atMs }).slice(0, -1)},"values":{${published.join(',')}}}`,
|
|
3034
|
+
),
|
|
2907
3035
|
}
|
|
2908
3036
|
})
|
|
2909
3037
|
},
|
|
2910
3038
|
|
|
2911
3039
|
async read(ns) {
|
|
2912
3040
|
return wrap(async () => {
|
|
2913
|
-
const
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
1,
|
|
2917
|
-
presenceKey(ns),
|
|
2918
|
-
clock.now().getTime(),
|
|
2919
|
-
)) as string[]
|
|
3041
|
+
const key = presenceKey(ns)
|
|
3042
|
+
const reply = (await call('HGETALL', key)) as string[]
|
|
3043
|
+
const nowMs = clock.now().getTime()
|
|
2920
3044
|
const rows: PresenceRow[] = []
|
|
3045
|
+
const expired: string[] = []
|
|
2921
3046
|
for (let i = 0; i + 1 < reply.length; i += 2) {
|
|
2922
|
-
const [participant, field] = JSON.parse(reply[i]!) as [
|
|
2923
|
-
string,
|
|
2924
|
-
string,
|
|
2925
|
-
]
|
|
2926
3047
|
const entry = JSON.parse(reply[i + 1]!) as {
|
|
2927
3048
|
v: unknown
|
|
2928
3049
|
s: number
|
|
2929
3050
|
a: number
|
|
2930
3051
|
e: number
|
|
2931
3052
|
}
|
|
3053
|
+
if (entry.e <= nowMs) {
|
|
3054
|
+
expired.push(reply[i]!)
|
|
3055
|
+
continue
|
|
3056
|
+
}
|
|
3057
|
+
if (entry.v === null) continue
|
|
3058
|
+
const [participant, field] = JSON.parse(reply[i]!) as [
|
|
3059
|
+
string,
|
|
3060
|
+
string,
|
|
3061
|
+
]
|
|
2932
3062
|
rows.push({
|
|
2933
3063
|
participant,
|
|
2934
3064
|
field,
|
|
@@ -2938,6 +3068,16 @@ export function createRedisStoreCore(
|
|
|
2938
3068
|
expiresAt: new Date(entry.e),
|
|
2939
3069
|
})
|
|
2940
3070
|
}
|
|
3071
|
+
// The rows are already filtered; a failed prune only defers the
|
|
3072
|
+
// deletion to the next read.
|
|
3073
|
+
if (expired.length > 0) {
|
|
3074
|
+
await runScript({
|
|
3075
|
+
script: PRESENCE_PRUNE_LUA,
|
|
3076
|
+
keys: [key],
|
|
3077
|
+
args: [nowMs, ...expired],
|
|
3078
|
+
readOnly: false,
|
|
3079
|
+
}).catch(() => undefined)
|
|
3080
|
+
}
|
|
2941
3081
|
return rows
|
|
2942
3082
|
})
|
|
2943
3083
|
},
|
|
@@ -2949,14 +3089,20 @@ export function createRedisStoreCore(
|
|
|
2949
3089
|
|
|
2950
3090
|
async readTail(sessionId, fromIndex, throughIndex) {
|
|
2951
3091
|
return wrap(async () => {
|
|
2952
|
-
|
|
2953
|
-
|
|
2954
|
-
|
|
2955
|
-
|
|
2956
|
-
|
|
2957
|
-
|
|
2958
|
-
|
|
2959
|
-
|
|
3092
|
+
// Entries below the latest clear are an old lifetime's, awaiting
|
|
3093
|
+
// reclaim: a tail that reaches them is the boundary-aware reads' job.
|
|
3094
|
+
const [newest, clearIndex] = await Promise.all([
|
|
3095
|
+
call(
|
|
3096
|
+
'XREVRANGE',
|
|
3097
|
+
storeKey(sessionId),
|
|
3098
|
+
throughIndex === undefined ? '+' : `${throughIndex}-0`,
|
|
3099
|
+
`${fromIndex}-0`,
|
|
3100
|
+
'COUNT',
|
|
3101
|
+
READ_PAGE_SIZE,
|
|
3102
|
+
) as Promise<StreamEntry[]>,
|
|
3103
|
+
readClearIndex(sessionId),
|
|
3104
|
+
])
|
|
3105
|
+
if (clearIndex > fromIndex) return null
|
|
2960
3106
|
const page = newest
|
|
2961
3107
|
.toReversed()
|
|
2962
3108
|
.map((entry) => toEvent(sessionId, entry))
|
|
@@ -2976,20 +3122,20 @@ export function createRedisStoreCore(
|
|
|
2976
3122
|
async clear({ sessionId, ifLastIndex, cause }) {
|
|
2977
3123
|
return wrap(async () => {
|
|
2978
3124
|
const markerId = generateId()
|
|
3125
|
+
const now = clock.now().getTime()
|
|
2979
3126
|
const reply = (await evalScript(
|
|
2980
3127
|
CLEAR_LUA,
|
|
2981
3128
|
sessionId,
|
|
2982
|
-
[
|
|
3129
|
+
[],
|
|
2983
3130
|
[
|
|
2984
3131
|
sessionId,
|
|
2985
|
-
|
|
3132
|
+
now,
|
|
2986
3133
|
ifLastIndex ?? '',
|
|
2987
3134
|
cause?.consumer ?? '',
|
|
2988
3135
|
cause ? tagOf(cause.lane) : '',
|
|
2989
3136
|
cause?.index ?? '',
|
|
2990
3137
|
cause?.attempt ?? '',
|
|
2991
3138
|
markerId,
|
|
2992
|
-
notifyChannel(sessionId),
|
|
2993
3139
|
],
|
|
2994
3140
|
)) as string[]
|
|
2995
3141
|
if (reply[0] === 'superseded') {
|
|
@@ -3001,6 +3147,18 @@ export function createRedisStoreCore(
|
|
|
3001
3147
|
`the clear marker id '${markerId}' already exists`,
|
|
3002
3148
|
)
|
|
3003
3149
|
}
|
|
3150
|
+
const head = Number(reply[2])
|
|
3151
|
+
if (reply[4] === '1') {
|
|
3152
|
+
await publish(
|
|
3153
|
+
notifyChannel(sessionId),
|
|
3154
|
+
wakeMessage(head, now, [
|
|
3155
|
+
[head, markerId, CLEAR_EVENT_TYPE, 'null'],
|
|
3156
|
+
]),
|
|
3157
|
+
)
|
|
3158
|
+
} else if (reply[1] === '1' && head > 0) {
|
|
3159
|
+
// An earlier attempt may have appended the marker without publishing.
|
|
3160
|
+
await publish(notifyChannel(sessionId), wakeMessage(head, now, []))
|
|
3161
|
+
}
|
|
3004
3162
|
return {
|
|
3005
3163
|
cleared: reply[1] === '1',
|
|
3006
3164
|
index: Number(reply[2]),
|
|
@@ -3011,27 +3169,25 @@ export function createRedisStoreCore(
|
|
|
3011
3169
|
|
|
3012
3170
|
async reclaim(sessionId) {
|
|
3013
3171
|
return wrap(async () => {
|
|
3014
|
-
const
|
|
3172
|
+
const pairs = (await evalScript(
|
|
3015
3173
|
RECLAIM_BATCH_LUA,
|
|
3016
3174
|
sessionId,
|
|
3017
3175
|
[],
|
|
3018
3176
|
[READ_PAGE_SIZE],
|
|
3019
3177
|
)) as string[]
|
|
3020
|
-
if (
|
|
3021
|
-
// Id keys are built here, like append's: they use JSON.stringify,
|
|
3022
|
-
// whose escaping cjson does not reproduce.
|
|
3023
|
-
const ids: string[] = []
|
|
3024
|
-
for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]!)
|
|
3178
|
+
if (pairs.length === 0) return { deleted: 0, done: true }
|
|
3025
3179
|
const [deleted, done] = (await evalScript(
|
|
3026
3180
|
RECLAIM_LUA,
|
|
3027
3181
|
sessionId,
|
|
3028
|
-
[
|
|
3029
|
-
[sessionId,
|
|
3182
|
+
[],
|
|
3183
|
+
[sessionId, pairs.length / 2, ...pairs],
|
|
3030
3184
|
)) as [number, number]
|
|
3031
3185
|
return { deleted: Number(deleted), done: Number(done) === 1 }
|
|
3032
3186
|
})
|
|
3033
3187
|
},
|
|
3034
3188
|
|
|
3189
|
+
clearIndex: (sessionId) => wrap(() => readClearIndex(sessionId)),
|
|
3190
|
+
|
|
3035
3191
|
async readEvents(sessionId, afterIndex) {
|
|
3036
3192
|
return wrap(async () => {
|
|
3037
3193
|
const entries = (await call(
|