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.
Files changed (139) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/{actor-HnGmrOZZ.d.ts → actor-CqibLgjQ.d.ts} +13 -5
  3. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  4. package/dist/actor-client.d.ts +1 -1
  5. package/dist/actor-react.d.ts +1 -1
  6. package/dist/actor-react.js +1 -1
  7. package/dist/actor-shared-DFET5q16.js.map +1 -1
  8. package/dist/actor.d.ts +1 -1
  9. package/dist/actor.js +3 -3
  10. package/dist/{ai-CvEnqVfo.d.ts → ai-DAu77CDr.d.ts} +2 -2
  11. package/dist/{ai-CvEnqVfo.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  12. package/dist/{ai-CzE672Q3.js → ai-EEfKyc-z.js} +2 -2
  13. package/dist/{ai-CzE672Q3.js.map → ai-EEfKyc-z.js.map} +1 -1
  14. package/dist/ai-client.d.ts +1 -1
  15. package/dist/ai-client.js +2 -2
  16. package/dist/ai-fx.d.ts +4 -2
  17. package/dist/ai-fx.d.ts.map +1 -1
  18. package/dist/ai-fx.js +211 -61
  19. package/dist/ai-fx.js.map +1 -1
  20. package/dist/{ai-harness-Bfk-Lycn.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  21. package/dist/{ai-harness-Bfk-Lycn.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  22. package/dist/ai-harness-dQeJaR1t.js.map +1 -1
  23. package/dist/ai-pi.d.ts +1 -1
  24. package/dist/ai-pi.js +1 -1
  25. package/dist/ai-react.d.ts +1 -1
  26. package/dist/ai-react.js +2 -2
  27. package/dist/ai-server.d.ts +3 -3
  28. package/dist/ai-server.d.ts.map +1 -1
  29. package/dist/ai-server.js +10 -4
  30. package/dist/ai-server.js.map +1 -1
  31. package/dist/ai.d.ts +1 -1
  32. package/dist/ai.js +1 -1
  33. package/dist/{client-9IeTsiAs.js → client-BQS4i_V9.js} +27 -4
  34. package/dist/client-BQS4i_V9.js.map +1 -0
  35. package/dist/client-CUMbS_38.d.ts.map +1 -1
  36. package/dist/client.js +1 -1
  37. package/dist/index.d.ts +2 -2
  38. package/dist/{internal-Bc4PvfHk.js → internal-CvsrprnU.js} +32 -15
  39. package/dist/{internal-Bc4PvfHk.js.map → internal-CvsrprnU.js.map} +1 -1
  40. package/dist/react.js +1 -1
  41. package/dist/{runtime-consumer-BWmht60z.js → runtime-consumer-CIeL33wj.js} +2 -2
  42. package/dist/{runtime-consumer-BWmht60z.js.map → runtime-consumer-CIeL33wj.js.map} +1 -1
  43. package/dist/scheduler-qstash.d.ts +2 -2
  44. package/dist/scheduler-qstash.js +2 -2
  45. package/dist/scheduler-vercel.d.ts +2 -2
  46. package/dist/scheduler-vercel.js +2 -2
  47. package/dist/{server-DvIB8kj5.js → server-DtFrWxi3.js} +30 -8
  48. package/dist/server-DtFrWxi3.js.map +1 -0
  49. package/dist/{server-BshFZ0eL.d.ts → server-qgez0Xny.d.ts} +3 -3
  50. package/dist/{server-BshFZ0eL.d.ts.map → server-qgez0Xny.d.ts.map} +1 -1
  51. package/dist/server.d.ts +2 -2
  52. package/dist/server.js +1 -1
  53. package/dist/{session-clear-Bk_29eEw.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  54. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  55. package/dist/{store-Z2qjw50n.d.ts → store-D0MhBvg6.d.ts} +15 -6
  56. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  57. package/dist/store-N8PXxDAS.js.map +1 -1
  58. package/dist/store-memory.d.ts +1 -1
  59. package/dist/store-memory.d.ts.map +1 -1
  60. package/dist/store-memory.js +15 -17
  61. package/dist/store-memory.js.map +1 -1
  62. package/dist/{store-polling-DYNyxCev.js → store-polling-BZt2Rf3D.js} +2 -2
  63. package/dist/{store-polling-DYNyxCev.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  64. package/dist/store-postgres.d.ts +1 -1
  65. package/dist/store-postgres.d.ts.map +1 -1
  66. package/dist/store-postgres.js +31 -40
  67. package/dist/store-postgres.js.map +1 -1
  68. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  69. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  70. package/dist/store-redis-http.d.ts +1 -1
  71. package/dist/store-redis-http.js +3 -3
  72. package/dist/{store-redis-notify-IiVdlYHc.js → store-redis-notify-CsJw_hfD.js} +538 -397
  73. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  74. package/dist/store-redis.d.ts +1 -1
  75. package/dist/store-redis.d.ts.map +1 -1
  76. package/dist/store-redis.js +3 -4
  77. package/dist/store-redis.js.map +1 -1
  78. package/dist/store-sqlite.d.ts +1 -1
  79. package/dist/store-sqlite.d.ts.map +1 -1
  80. package/dist/store-sqlite.js +16 -18
  81. package/dist/store-sqlite.js.map +1 -1
  82. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  83. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  84. package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
  85. package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
  86. package/docs/actors/01-introduction.mdx +5 -2
  87. package/docs/agents/03-conversations.mdx +9 -8
  88. package/docs/agents/04-harnesses.mdx +7 -4
  89. package/docs/reference/01-core.mdx +3 -2
  90. package/docs/reference/02-server.mdx +5 -1
  91. package/docs/reference/04-ai.mdx +13 -4
  92. package/docs/reference/06-errors.mdx +1 -1
  93. package/docs/server/01-consumers.mdx +4 -1
  94. package/docs/server/06-guarantees.mdx +7 -4
  95. package/docs/stores/02-redis.mdx +16 -10
  96. package/docs/stores/03-redis-http.mdx +6 -4
  97. package/docs/stores/04-postgres.mdx +2 -2
  98. package/docs/upgrades/01-overview.mdx +1 -0
  99. package/docs/upgrades/78-0-22.mdx +115 -0
  100. package/examples/playground/app/a0/sandbox.ts +58 -13
  101. package/examples/playground/app/a2slack/db/a2.ts +7 -4
  102. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  103. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  104. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
  105. package/examples/playground/app/chat/server.ts +2 -2
  106. package/examples/playground/lib/store.ts +1 -1
  107. package/examples/playground/package.json +1 -1
  108. package/package.json +1 -1
  109. package/sql/postgres/drizzle-schema.ts +7 -4
  110. package/sql/postgres/schema.sql +24 -3
  111. package/src/actor-shared.ts +10 -2
  112. package/src/ai-agent.ts +13 -0
  113. package/src/ai-fx.ts +321 -77
  114. package/src/ai-harness.ts +7 -0
  115. package/src/client.ts +19 -2
  116. package/src/deterministic-id.ts +1 -1
  117. package/src/internal.ts +42 -11
  118. package/src/presence-recovery.ts +18 -11
  119. package/src/presence-tombstones.ts +52 -0
  120. package/src/push-envelope.ts +2 -2
  121. package/src/server.ts +49 -15
  122. package/src/store-memory.ts +24 -32
  123. package/src/store-postgres.ts +43 -53
  124. package/src/store-presence-polling.ts +59 -34
  125. package/src/store-redis-core.ts +595 -439
  126. package/src/store-redis-notify.ts +74 -26
  127. package/src/store-redis.ts +2 -3
  128. package/src/store-sqlite.ts +34 -40
  129. package/src/store.ts +14 -5
  130. package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
  131. package/dist/client-9IeTsiAs.js.map +0 -1
  132. package/dist/server-DvIB8kj5.js.map +0 -1
  133. package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
  134. package/dist/store-Z2qjw50n.d.ts.map +0 -1
  135. package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
  136. package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
  137. package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
  138. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  139. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
@@ -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
- * The cursor layout, per session `{p}:{s}:`:
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, and a prefix holding another one is
35
- * rejected. A session from before cursors (0.17 to 0.20) opens as is: its
36
- * log, counter, clear marker, snapshots, and ids read the same, and its
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
- * Global event-id ownership is one key per id, so no key grows with the
42
- * number of sessions. The conformance suite in test/conformance is the
43
- * executable contract, run against a real spawned `redis-server`.
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. Cluster mode is out: the atomic
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
- * or null when every field lost the LWW race. The script publishes
107
- * the applied fields for remote subscribers.
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 = 'cursors-2'
158
- const LAYOUT_ERROR = 'A2_LAYOUT'
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
- * Upstash bills every call a script makes, so scripts read the counter,
169
- * clear marker, layout, typed-type index, and id keys with one MGET
170
- * (`read_bounds`), read rows with one HMGET per chunk (`read_rows`), and
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 BASE_LUA = `
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
- local COUNTER, SCHEMA = KEYS[1], KEYS[2]
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, schema_missing = 0, 0, false
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
- -- Reads the counter, the latest clear marker, the layout, the typed types,
206
- -- and the extra keys in one MGET. Returns the extra keys' values, or nil when
207
- -- the prefix holds another layout.
208
- local function read_bounds(extra)
209
- local keys = { COUNTER, CLEAR, SCHEMA, TYPED }
210
- for _, key in ipairs(extra or {}) do keys[#keys + 1] = key end
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
- if values[3] and values[3] ~= '${REDIS_LAYOUT}' then return nil end
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
- schema_missing = not values[3]
216
- if values[4] then typed = cjson.decode(values[4]) end
217
- local rest = {}
218
- for i = 5, #keys do rest[i - 4] = values[i] end
219
- return rest
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
- * Wake-up messages carry the events they announce, so a subscribed reader
685
- * that is caught up applies them without reading the stream:
686
- * `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
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
- * ['foreign', eventId] | ['superseded', position] | ['cleared', originIndex] |
712
+ * ['superseded', position] | ['cleared', originIndex] |
722
713
  * ['index', type, after].
723
714
  */
724
- const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}${WAKE_LUA}
725
- local session_id, now, n = ARGV[1], ARGV[2], tonumber(ARGV[3])
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 id_keys = {}
729
- for i = 1, n do id_keys[i] = KEYS[2 + i] end
730
- local values = read_bounds(id_keys)
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[6]
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[6 + (i - 1) * 5 + 4]
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 = 6 + (i - 1) * 5
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 owner_writes = {}
766
+ local marks = {}
782
767
  for i = 1, n do
783
- local o = 6 + (i - 1) * 5
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
- owner_writes[#owner_writes + 1] = KEYS[2 + i]
789
- owner_writes[#owner_writes + 1] = cjson.encode({ session_id, base + i })
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
- owner_writes[#owner_writes + 1] = COUNTER
795
- owner_writes[#owner_writes + 1] = tostring(base + n)
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
- owner_writes[#owner_writes + 1] = TYPED
802
- owner_writes[#owner_writes + 1] = cjson.encode(typed)
781
+ marks[#marks + 1] = TYPED
782
+ marks[#marks + 1] = cjson.encode(typed)
803
783
  end
804
- sliced('MSET', nil, owner_writes)
784
+ sliced('MSET', nil, marks)
805
785
  flush_states()
806
786
  flush()
807
787
  flush_ready()
808
- local pending = has_pending()
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, layout.
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
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
- if entry[1] > after then
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(after + 1) .. '-0', 'COUNT', ${READ_PAGE_SIZE})
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, layout.
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
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, layout.
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
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, layout, then one id ownership key per child.
985
+ * KEYS: counter.
1004
986
  * ARGV: sessionId, nowMs, consumer, tag, index, attempt, child causeJson,
1005
- * served consumers, notify channel, returned idsJson, n, then per child: id,
1006
- * type, payloadJson, targets.
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}${WAKE_LUA}
1009
- local session_id, now = ARGV[1], ARGV[2]
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[11])
1013
- local id_keys = {}
1014
- for i = 1, n do id_keys[i] = KEYS[2 + i] end
1015
- local values = read_bounds(id_keys)
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[11 + (i - 1) * 4 + 1] then return { 'partial' } end
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
- local rec = cjson.decode(owners[i])
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 = 11 + (i - 1) * 4
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[10])
1035
+ put_row(idx, at, 'ri', ARGV[9])
1058
1036
  release_pins(idx, at, row[9])
1059
- local owner_writes = {}
1037
+ local marks = {}
1060
1038
  for i = 1, n do
1061
- local o = 11 + (i - 1) * 4
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
- owner_writes[#owner_writes + 1] = KEYS[2 + i]
1067
- owner_writes[#owner_writes + 1] = cjson.encode({ session_id, total + i })
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
- owner_writes[#owner_writes + 1] = COUNTER
1074
- owner_writes[#owner_writes + 1] = tostring(total + n)
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
- owner_writes[#owner_writes + 1] = TYPED
1082
- owner_writes[#owner_writes + 1] = cjson.encode(typed)
1054
+ marks[#marks + 1] = TYPED
1055
+ marks[#marks + 1] = cjson.encode(typed)
1083
1056
  end
1084
- sliced('MSET', nil, owner_writes)
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, layout.
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
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, layout. ARGV: consumer, index, action, nowMs, served
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
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, layout. ARGV: consumer, lane, types JSON or '*', backfill
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
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, layout. Returns the dead-lettered heads above the clear
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
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, layout. Returns every listed cursor as [consumer, tag,
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
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, layout, head, history.
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
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1327
- local HEAD, HISTORY = KEYS[3], KEYS[4]
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
- * KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
1444
- * hashField, valueJson ('' deletes), patchFieldJson; finally channel and
1445
- * message prefix. Wire fragments stay JSON text to preserve arrays
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 PRESENCE_SET_LUA = `
1456
- local at = tonumber(ARGV[1])
1457
- local applied = {}
1458
- local published = {}
1459
- local n = tonumber(ARGV[3])
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 field = ARGV[3 + (i - 1) * 3 + 1]
1462
- local value = ARGV[3 + (i - 1) * 3 + 2]
1463
- local existing = redis.call('HGET', KEYS[1], field)
1464
- if not existing or tonumber(cjson.decode(existing)['a']) <= at then
1465
- if value == '' then
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
- if #applied > 0 then
1475
- redis.call('PEXPIRE', KEYS[1], ARGV[2])
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 (the injected clock, so tests can
1483
- * time-travel expiry against a real server). Returns the live
1484
- * [field, value, ...] pairs; entries at or past their fine-bound
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 PRESENCE_READ_LUA = `
1461
+ const PRESENCE_PRUNE_LUA = `${SLICED_LUA}${PRESENCE_STAMPS_LUA}
1489
1462
  local now = tonumber(ARGV[1])
1490
- local rows = redis.call('HGETALL', KEYS[1])
1491
- local live = {}
1492
- for i = 1, #rows, 2 do
1493
- if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
1494
- redis.call('HDEL', KEYS[1], rows[i])
1495
- else
1496
- live[#live + 1] = rows[i]
1497
- live[#live + 1] = rows[i + 1]
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
- return live
1473
+ sliced('HDEL', KEYS[1], stale)
1474
+ return 1
1501
1475
  `
1502
1476
 
1503
1477
  /**
1504
- * KEYS: counter, layout, head, history. ARGV: snapshot frontier or ''.
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', KEYS[2])
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[3])
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[4], upper, boundary, 'LIMIT', 0, 1)
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 a session has no moved log left: every
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, layout, marker id ownership key.
1612
+ * KEYS: counter.
1614
1613
  * ARGV: sessionId, nowMs, ifLastIndex or '', cause consumer or '', cause tag,
1615
- * cause index, cause attempt, marker id, notify channel.
1616
- * Returns ['ok', cleared, head, boundary] | ['superseded'] | ['foreign'].
1617
- * The old lifetime's log moves aside in one RENAME, so every read of the log
1618
- * starts at the marker, and its ready set is unlinked, so claims and pending
1619
- * checks see only the new lifetime; deletion drains the moved log. Type
1620
- * indexes keep their older entries until then: lookups start above the
1621
- * marker.
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}${WAKE_LUA}
1624
- local values = read_bounds({ KEYS[3] })
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 values[1] then return { 'foreign' } end
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
- local marker = { KEYS[3], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
1655
- if schema_missing then
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, layout. ARGV: page size.
1667
- * The next deletion batch: a moved log and up to a page of its [index, id]
1668
- * pairs, without payloads. Empty moved logs are dropped; with none left it
1669
- * sweeps what the old lifetime left and returns nothing, so a retry
1670
- * finishes an interrupted deletion.
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
- for _, log in ipairs(redis.call('SMEMBERS', SESSION .. 'cleared')) do
1676
- local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
1677
- if #entries > 0 then
1678
- local batch = { log }
1679
- for _, entry in ipairs(entries) do
1680
- local fields = entry[2]
1681
- for i = 1, #fields, 2 do
1682
- if fields[i] == 'id' then
1683
- batch[#batch + 1] = string.match(entry[1], '^(%d+)')
1684
- batch[#batch + 1] = fields[i + 1]
1685
- break
1686
- end
1687
- end
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
- sweep(boundary)
1695
- return {}
1681
+ return batch
1696
1682
  `
1697
1683
 
1698
1684
  /**
1699
- * KEYS: counter, layout, moved log, then one id ownership key per entry.
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 MOVED = KEYS[3]
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
- id_keys[#id_keys + 1] = KEYS[3 + i]
1715
- indexes[#id_keys] = tonumber(field)
1716
- stream_ids[#id_keys] = field .. '-0'
1717
- end
1718
- end
1719
- if #id_keys > 0 then
1720
- local owners, stale_ids = sliced('MGET', nil, id_keys), {}
1721
- for i = 1, #id_keys do
1722
- if owners[i] then
1723
- local rec = cjson.decode(owners[i])
1724
- if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
1725
- stale_ids[#stale_ids + 1] = id_keys[i]
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, stale_ids)
1730
- removed = redis.call('XDEL', MOVED, unpack(stream_ids))
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('XLEN', MOVED) == 0 then
1734
- redis.call('DEL', MOVED)
1735
- redis.call('SREM', SESSION .. 'cleared', MOVED)
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. A clear moves the old lifetime's log aside, so
2004
- * a read whose bounds predate it finds nothing where it expected events;
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 storeKey = (s: string): string => `${prefix}:${s}:log`
2053
- const countKey = (s: string): string => `${prefix}:${s}:count`
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 => `${prefix}:${s}:clear`
2090
+ const clearKey = (s: string): string => sessionKey(s, 'clear')
2056
2091
  const schemaKey = `${prefix}:schema`
2057
- const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
2092
+ const snapKey = (s: string, r: string): string => sessionKey(s, `snap:${r}`)
2058
2093
  const snapHistoryKey = (s: string, r: string): string =>
2059
- `${prefix}:${s}:snap-history:${r}`
2060
- const presenceKey = (s: string): string => `${prefix}:${s}:presence`
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
- // One ownership key per event id; reclaim deletes those naming cleared events.
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
- /** Runs a script over a session: KEYS start with its counter and the layout key. */
2076
- const evalScript = async (
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
- const allKeys = [countKey(sessionId), schemaKey, ...keys]
2083
- try {
2084
- return await call('EVAL', script, allKeys.length, ...allKeys, ...args)
2085
- } catch (error) {
2086
- if (String((error as Error)?.message).includes(LAYOUT_ERROR))
2087
- throw layoutError()
2088
- throw error
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, checking the layout in the same MGET. */
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
- const [count, cleared, schema] = (await call(
2212
+ await prepared.get()
2213
+ const [count, cleared] = (await call(
2123
2214
  'MGET',
2124
2215
  countKey(sessionId),
2125
2216
  clearKey(sessionId),
2126
- schemaKey,
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
- frontier: toIndex({ raw: count, sessionId, name: 'event count' }),
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
- withIds.map((e) => idKey(e.id)),
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
- json(e.payload),
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
- return {
2386
- events: idempotentReplay(events, existing),
2387
- hasPending,
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 evalScript(
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
- ids.map((id) => idKey(id)),
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
- json(event.payload),
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
- return {
2631
- outcome: 'completed',
2632
- events: idempotentReplay(events, existing),
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 evalScript(
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 evalScript(
2746
- READ_STATE_LUA,
2747
- sessionId,
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
- )) as [number | string, string, string, string]
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 expiresAtMs = clock.now().getTime() + meta.ttlMs
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 call(
2872
- 'EVAL',
2873
- PRESENCE_SET_LUA,
2874
- 1,
2875
- presenceKey(ns),
2876
- atMs,
2877
- meta.ttlMs,
2878
- entries.length,
2879
- ...entries.flatMap(([field, value]) => [
2880
- JSON.stringify([participant, field]),
2881
- value === null
2882
- ? ''
2883
- : json({
2884
- v: value,
2885
- s: meta.seen,
2886
- a: atMs,
2887
- e: expiresAtMs,
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
- participant,
2904
- values: appliedValues,
2905
- seen: meta.seen,
2906
- at: new Date(atMs),
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 reply = (await call(
2914
- 'EVAL',
2915
- PRESENCE_READ_LUA,
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
- const newest = (await call(
2953
- 'XREVRANGE',
2954
- storeKey(sessionId),
2955
- throughIndex === undefined ? '+' : `${throughIndex}-0`,
2956
- `${fromIndex}-0`,
2957
- 'COUNT',
2958
- READ_PAGE_SIZE,
2959
- )) as StreamEntry[]
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
- [idKey(markerId)],
3129
+ [],
2983
3130
  [
2984
3131
  sessionId,
2985
- clock.now().getTime(),
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 [log, ...pairs] = (await evalScript(
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 (log === undefined) return { deleted: 0, done: true }
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
- [log, ...ids.map((id) => idKey(id))],
3029
- [sessionId, ids.length, ...pairs],
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(