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
@@ -1,13 +1,15 @@
1
1
  import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-wHd5GCpZ.js";
2
- import { _ as nullProtoRecord, r as NOTIFY_TIMINGS, x as stateTailReaders } from "./internal-Bc4PvfHk.js";
3
- import { i as streamActivities, n as pollingStream, r as createStreamActivity, t as defaultSleep } from "./store-polling-DYNyxCev.js";
4
- import { n as isSerializedEvent, r as parseSerializedEvent, t as bufferedEvents } from "./stream-buffer-Cx7AAPGq.js";
2
+ import { C as stateTailReaders, i as NOTIFY_TIMINGS, y as nullProtoRecord } from "./internal-CvsrprnU.js";
3
+ import { a as presenceTombstones, n as isSerializedEvent, r as parseSerializedEvent, t as bufferedEvents } from "./stream-buffer-DrTyiIuo.js";
4
+ import { i as streamActivities, n as pollingStream, r as createStreamActivity, t as defaultSleep } from "./store-polling-BZt2Rf3D.js";
5
+ import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
5
6
  import { a as idempotentReplay, n as decodeCause } from "./store-codec-DwrWlgQx.js";
6
- import { t as pollPresence } from "./store-presence-polling-CzQ0wdtM.js";
7
+ import { t as pollPresence } from "./store-presence-polling-JuAy2Ck2.js";
7
8
  //#region src/presence-recovery.ts
8
9
  const key = ({ participant, field }) => JSON.stringify([participant, field]);
9
10
  function presenceRecovery({ read, deliver, isActive }) {
10
11
  const fields = /* @__PURE__ */ new Map();
12
+ const deleted = presenceTombstones();
11
13
  const reading = /* @__PURE__ */ new Set();
12
14
  let generation = 0;
13
15
  let latest;
@@ -33,11 +35,13 @@ function presenceRecovery({ read, deliver, isActive }) {
33
35
  const rows = await read();
34
36
  if (!isActive()) return rows;
35
37
  if (run !== generation) return latest;
38
+ const current = rows.filter((row) => !deleted.outranks(row));
36
39
  const live = /* @__PURE__ */ new Set();
37
- for (const row of rows) {
40
+ for (const row of current) {
38
41
  const id = key(row);
39
42
  live.add(id);
40
43
  if (changed.has(id)) continue;
44
+ deleted.drop(row);
41
45
  remember(row);
42
46
  if (reconcile) deliver({
43
47
  participant: row.participant,
@@ -49,6 +53,7 @@ function presenceRecovery({ read, deliver, isActive }) {
49
53
  if (reconcile) for (const [id, prior] of before) {
50
54
  if (live.has(id) || changed.has(id) || fields.get(id) !== prior) continue;
51
55
  fields.delete(id);
56
+ deleted.keep(prior);
52
57
  deliver({
53
58
  participant: prior.participant,
54
59
  values: { [prior.field]: null },
@@ -56,7 +61,7 @@ function presenceRecovery({ read, deliver, isActive }) {
56
61
  at: prior.at
57
62
  });
58
63
  }
59
- return rows;
64
+ return current;
60
65
  } finally {
61
66
  reading.delete(changed);
62
67
  if (reading.size === 0) latest = void 0;
@@ -80,20 +85,26 @@ function presenceRecovery({ read, deliver, isActive }) {
80
85
  update(patch) {
81
86
  for (const [field, value] of Object.entries(patch.values)) {
82
87
  if (value === void 0) continue;
83
- const id = key({
88
+ const write = {
84
89
  participant: patch.participant,
85
- field
86
- });
90
+ field,
91
+ at: patch.at
92
+ };
93
+ const id = key(write);
87
94
  const previous = fields.get(id);
88
95
  if (previous && previous.at.getTime() > patch.at.getTime()) continue;
96
+ if (deleted.outranks(write)) continue;
89
97
  for (const changed of reading) changed.add(id);
90
- if (value === null) fields.delete(id);
91
- else remember({
92
- participant: patch.participant,
93
- field,
94
- seen: patch.seen,
95
- at: patch.at
96
- });
98
+ if (value === null) {
99
+ fields.delete(id);
100
+ deleted.keep(write);
101
+ } else {
102
+ deleted.drop(write);
103
+ remember({
104
+ ...write,
105
+ seen: patch.seen
106
+ });
107
+ }
97
108
  }
98
109
  }
99
110
  };
@@ -101,22 +112,24 @@ function presenceRecovery({ read, deliver, isActive }) {
101
112
  const FEED_PAGE_SIZE = 1e3;
102
113
  const READ_CONCURRENCY = 16;
103
114
  /** The key layout this core writes; any other is rejected. */
104
- const REDIS_LAYOUT = "cursors-2";
105
- const LAYOUT_ERROR = "A2_LAYOUT";
115
+ const REDIS_LAYOUT = "tagged-1";
116
+ /**
117
+ * Upstash runs a script under a database-wide lock unless it opts into
118
+ * locking only its keys' hash tags. Redis and Valkey reject the flag, and
119
+ * have no such lock to opt out of: one thread runs every command.
120
+ */
121
+ const KEY_LOCKING = "#!lua flags=allow-key-locking";
122
+ const READ_ONLY_KEY_LOCKING = "#!lua flags=no-writes,allow-key-locking";
123
+ const SHEBANG_REJECTED = /shebang|unexpected symbol near '#'/i;
106
124
  const META_CHUNK_SIZE = 1e3;
107
125
  /** A head row's dispatch columns, in the order scripts return them. */
108
126
  const HEAD_COLUMNS = `{ 'd', 'fc', 'lc', 'ch', 'ce', 'a', 'lf', 'lfa', 'e', 'f' }`;
109
127
  /**
110
- * Upstash bills every call a script makes, so scripts read the counter,
111
- * clear marker, layout, typed-type index, and id keys with one MGET
112
- * (`read_bounds`), read rows with one HMGET per chunk (`read_rows`), and
113
- * buffer hash writes (`put`, `drop`) into one HSET and one HDEL per hash
114
- * (`flush`). Buffered writes are invisible to reads until flushed, so a
115
- * script must not read a field it has buffered. Every script binds KEYS[1]
116
- * to the session's counter and KEYS[2] to the prefix's layout key, and
117
- * derives the session's other keys.
128
+ * Issues a variadic command in calls of at most 1000 arguments, since Lua's
129
+ * `unpack` is bounded by its stack, and concatenates table replies. An even
130
+ * slice size keeps HSET's field/value pairs together.
118
131
  */
119
- const BASE_LUA = `
132
+ const SLICED_LUA = `
120
133
  local function sliced(command, key, args)
121
134
  local results = {}
122
135
  for first = 1, #args, 1000 do
@@ -133,32 +146,55 @@ local function sliced(command, key, args)
133
146
  end
134
147
  return results
135
148
  end
136
- local COUNTER, SCHEMA = KEYS[1], KEYS[2]
149
+ `;
150
+ /**
151
+ * Upstash bills every call a script makes, so scripts read the counter,
152
+ * clear marker, typed-type index, and event-id owners with one MGET
153
+ * (`read_bounds`), read rows with one HMGET per chunk
154
+ * (`read_rows`), and buffer hash writes (`put`, `drop`) into one HSET and
155
+ * one HDEL per hash (`flush`). Buffered writes are invisible to reads until
156
+ * flushed, so a script must not read a field it has buffered. Every script
157
+ * binds KEYS[1] to the session's counter and derives the session's other
158
+ * keys, which share its hash tag.
159
+ */
160
+ const BASE_LUA = `${SLICED_LUA}
161
+ local COUNTER = KEYS[1]
137
162
  local SESSION = string.sub(COUNTER, 1, -6)
138
163
  local LOG, CLEAR = SESSION .. 'log', SESSION .. 'clear'
139
164
  local META, READY, CURSORS = SESSION .. 'heads', SESSION .. 'runnable', SESSION .. 'cursors'
140
165
  local PIN_COUNTS, TYPED = SESSION .. 'pins', SESSION .. 'typed'
141
166
  local CLEAR_TYPE = '${CLEAR_EVENT_TYPE}'
142
- local total, boundary, schema_missing = 0, 0, false
167
+ local total, boundary = 0, 0
143
168
  -- The event types typed log cursors read: type → { from, last }. The type's
144
169
  -- index holds every event of it above \`from\`, and no event of it lies above
145
170
  -- \`last\`.
146
171
  local typed, typed_dirty = {}, false
147
- -- Reads the counter, the latest clear marker, the layout, the typed types,
148
- -- and the extra keys in one MGET. Returns the extra keys' values, or nil when
149
- -- the prefix holds another layout.
150
- local function read_bounds(extra)
151
- local keys = { COUNTER, CLEAR, SCHEMA, TYPED }
152
- for _, key in ipairs(extra or {}) do keys[#keys + 1] = key end
172
+ -- One key per event id holds the index it names in this session. The
173
+ -- \`id:\` namespace keeps a raw id from spelling another session key.
174
+ local function id_key(id) return SESSION .. 'id:' .. id end
175
+ -- Reads the counter, the latest clear marker, the typed types, and the
176
+ -- owners of \`ids\` in one MGET. Returns each id's index, or false.
177
+ local function read_bounds(ids)
178
+ local keys = { COUNTER, CLEAR, TYPED }
179
+ for _, id in ipairs(ids or {}) do keys[#keys + 1] = id_key(id) end
153
180
  local values = sliced('MGET', nil, keys)
154
- if values[3] and values[3] ~= '${REDIS_LAYOUT}' then return nil end
155
- total = tonumber(values[1] or '0')
181
+ total = tonumber(values[1] or values[2] or '0')
156
182
  boundary = tonumber(values[2] or '0')
157
- schema_missing = not values[3]
158
- if values[4] then typed = cjson.decode(values[4]) end
159
- local rest = {}
160
- for i = 5, #keys do rest[i - 4] = values[i] end
161
- return rest
183
+ if values[3] then typed = cjson.decode(values[3]) end
184
+ local owners = {}
185
+ for i = 4, #keys do owners[i - 3] = values[i] end
186
+ return owners
187
+ end
188
+ -- Event-id owners from read_bounds: an id recorded below the latest clear
189
+ -- is free.
190
+ local function owners_of(values)
191
+ local owners = {}
192
+ for i = 1, #values do
193
+ local owner = values[i]
194
+ if owner and tonumber(owner) < boundary then owner = false end
195
+ owners[i] = owner
196
+ end
197
+ return owners
162
198
  end
163
199
  local function entry_index(id) return tonumber(string.match(id, '^(%d+)')) end
164
200
  local function cid_of(consumer, tag)
@@ -603,79 +639,27 @@ local function enqueue(batch)
603
639
  state.dirty = true
604
640
  end
605
641
  end
606
- -- Event-id owners for the extra keys read_bounds returned. A record this
607
- -- session made below its latest clear is free.
608
- local function owners_of(values, session)
609
- local owners = {}
610
- for i = 1, #values do
611
- local owner = values[i]
612
- if owner then
613
- local rec = cjson.decode(owner)
614
- if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
615
- end
616
- owners[i] = owner
617
- end
618
- return owners
619
- end
620
- `;
621
- const WAKE_PAYLOAD_LIMIT = 32768;
622
- /**
623
- * Wake-up messages carry the events they announce, so a subscribed reader
624
- * that is caught up applies them without reading the stream:
625
- * `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
626
- * are the stored JSON text, inlined verbatim. A batch over
627
- * WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
628
- * with XRANGE.
629
- */
630
- const WAKE_LUA = `
631
- local wake_entries, wake_bytes = {}, 0
632
- local function wake_add(field, id, typ, payload)
633
- if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
634
- wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
635
- return
636
- end
637
- local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
638
- cjson.encode(typ) .. ',' .. payload .. ']'
639
- wake_bytes = wake_bytes + #entry + 1
640
- if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
641
- wake_entries[#wake_entries + 1] = entry
642
- end
643
- end
644
- local function wake(channel, frontier, created_at)
645
- local message = '{"f":' .. frontier .. '}'
646
- if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
647
- message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
648
- table.concat(wake_entries, ',') .. ']}'
649
- end
650
- redis.pcall('PUBLISH', channel, message)
651
- end
652
642
  `;
653
643
  /**
654
- * KEYS: counter, layout, then one id ownership key per event.
655
- * ARGV: sessionId, nowMs, n, served consumers, notify channel, origin index
656
- * or '', then per event: id, type, payloadJson, causeJson or '', targets.
644
+ * KEYS: counter.
645
+ * ARGV: sessionId, nowMs, n, served consumers, origin index or '', then per
646
+ * event: id, type, payloadJson, causeJson or '', targets.
657
647
  * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
658
648
  * ['dup', hasPending, ...indexes] | ['partial', count] |
659
- * ['foreign', eventId] | ['superseded', position] | ['cleared', originIndex] |
649
+ * ['superseded', position] | ['cleared', originIndex] |
660
650
  * ['index', type, after].
661
651
  */
662
- const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}${WAKE_LUA}
663
- local session_id, now, n = ARGV[1], ARGV[2], tonumber(ARGV[3])
652
+ const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}
653
+ local now, n = ARGV[2], tonumber(ARGV[3])
664
654
  use_served(ARGV[4])
665
655
  if n == 0 then return { 'empty', has_pending() } end
666
- local id_keys = {}
667
- for i = 1, n do id_keys[i] = KEYS[2 + i] end
668
- local values = read_bounds(id_keys)
669
- if not values then return redis.error_reply('${LAYOUT_ERROR}') end
670
- local owners = owners_of(values, session_id)
656
+ local ids = {}
657
+ for i = 1, n do ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
658
+ local owners = owners_of(read_bounds(ids))
671
659
  local base = total
672
660
  local dups = {}
673
661
  for i = 1, n do
674
- if owners[i] then
675
- local rec = cjson.decode(owners[i])
676
- if rec[1] ~= session_id then return { 'foreign', ARGV[6 + (i - 1) * 5 + 1] } end
677
- dups[#dups + 1] = tostring(rec[2])
678
- end
662
+ if owners[i] then dups[#dups + 1] = owners[i] end
679
663
  end
680
664
  if #dups == n then
681
665
  local res = { 'dup', has_pending() }
@@ -683,13 +667,13 @@ if #dups == n then
683
667
  return res
684
668
  end
685
669
  if #dups > 0 then return { 'partial', tostring(#dups) } end
686
- local origin = ARGV[6]
670
+ local origin = ARGV[5]
687
671
  if origin ~= '' and tonumber(origin) <= boundary then return { 'cleared', origin } end
688
672
  -- The attempt-currency fence: a cause's head row must still be its cursor's
689
673
  -- head, at that attempt, not dead-lettered.
690
674
  local refs, fenced = {}, {}
691
675
  for i = 1, n do
692
- local raw = ARGV[6 + (i - 1) * 5 + 4]
676
+ local raw = ARGV[5 + (i - 1) * 5 + 4]
693
677
  if raw ~= '' then
694
678
  local cause = cjson.decode(raw)
695
679
  local index = tonumber(cause.index)
@@ -711,59 +695,54 @@ for k, cause in ipairs(fenced) do
711
695
  end
712
696
  local batch = {}
713
697
  for i = 1, n do
714
- local o = 6 + (i - 1) * 5
698
+ local o = 5 + (i - 1) * 5
715
699
  batch[i] = { idx = base + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 5]) }
716
700
  end
717
701
  local planned, missing, after = plan(batch, base)
718
702
  if not planned then return { 'index', missing, tostring(after) } end
719
- local owner_writes = {}
703
+ local marks = {}
720
704
  for i = 1, n do
721
- local o = 6 + (i - 1) * 5
705
+ local o = 5 + (i - 1) * 5
722
706
  local field = tostring(base + i)
723
707
  redis.call('XADD', LOG, field .. '-0',
724
708
  'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
725
709
  'created_at', now, 'cause', ARGV[o + 4])
726
- owner_writes[#owner_writes + 1] = KEYS[2 + i]
727
- owner_writes[#owner_writes + 1] = cjson.encode({ session_id, base + i })
728
- wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
710
+ marks[#marks + 1] = id_key(ARGV[o + 1])
711
+ marks[#marks + 1] = field
729
712
  end
730
713
  index_batch(batch)
731
714
  enqueue(batch)
732
- owner_writes[#owner_writes + 1] = COUNTER
733
- owner_writes[#owner_writes + 1] = tostring(base + n)
734
- if schema_missing then
735
- owner_writes[#owner_writes + 1] = SCHEMA
736
- owner_writes[#owner_writes + 1] = '${REDIS_LAYOUT}'
737
- end
715
+ marks[#marks + 1] = COUNTER
716
+ marks[#marks + 1] = tostring(base + n)
738
717
  if typed_dirty then
739
- owner_writes[#owner_writes + 1] = TYPED
740
- owner_writes[#owner_writes + 1] = cjson.encode(typed)
718
+ marks[#marks + 1] = TYPED
719
+ marks[#marks + 1] = cjson.encode(typed)
741
720
  end
742
- sliced('MSET', nil, owner_writes)
721
+ sliced('MSET', nil, marks)
743
722
  flush_states()
744
723
  flush()
745
724
  flush_ready()
746
- local pending = has_pending()
747
- wake(ARGV[5], tostring(base + n), now)
748
- return { 'ok', tostring(base + 1), pending }
725
+ return { 'ok', tostring(base + 1), has_pending() }
749
726
  `;
750
727
  /**
751
- * KEYS: counter, layout.
728
+ * KEYS: counter.
752
729
  * ARGV: type, after. Indexes one page of the stored log below the type's
753
730
  * index, toward `after`, registering the type first if no cursor read it
754
731
  * yet. Returns ['1'] once the index reaches `after`, else ['0'].
755
732
  */
756
733
  const INDEX_TYPE_LUA = `${BASE_LUA}
757
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
734
+ read_bounds()
758
735
  local name, after = ARGV[1], tonumber(ARGV[2])
759
736
  local entry = typed[name]
760
737
  if not entry then
761
738
  entry = { total, total }
762
739
  typed[name] = entry
763
740
  end
764
- if entry[1] > after then
741
+ -- Entries below the latest clear belong to an old lifetime: never indexed.
742
+ local floor = math.max(after, boundary - 1)
743
+ if entry[1] > floor then
765
744
  local entries = redis.call('XREVRANGE', LOG, tostring(entry[1]) .. '-0',
766
- tostring(after + 1) .. '-0', 'COUNT', 256)
745
+ tostring(floor + 1) .. '-0', 'COUNT', 256)
767
746
  local adds = {}
768
747
  for _, stored in ipairs(entries) do
769
748
  local fields = stored[2]
@@ -784,12 +763,14 @@ if entry[1] > after then
784
763
  else
785
764
  entry[1] = entry_index(entries[#entries][1]) - 1
786
765
  end
766
+ elseif entry[1] > after then
767
+ entry[1] = after
787
768
  end
788
769
  redis.call('SET', TYPED, cjson.encode(typed))
789
770
  return { entry[1] <= after and '1' or '0' }
790
771
  `;
791
772
  /**
792
- * KEYS: counter, layout.
773
+ * KEYS: counter.
793
774
  * ARGV: holder, nowMs, expiresAtMs, maxFailures or '', served consumers,
794
775
  * the highest head to claim or '', excluded count, then per excluded head:
795
776
  * consumer, tag.
@@ -799,7 +780,7 @@ return { entry[1] <= after and '1' or '0' }
799
780
  const CLAIM_AVAILABLE_LUA = `${BASE_LUA}${CURSOR_LUA}
800
781
  local ready = redis.call('ZRANGEBYSCORE', READY, 1, ARGV[6] == '' and '+inf' or ARGV[6], 'WITHSCORES')
801
782
  if #ready == 0 then return { 'settled' } end
802
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
783
+ read_bounds()
803
784
  local holder, now, expires_at = ARGV[1], tonumber(ARGV[2]), ARGV[3]
804
785
  local max_failures = tonumber(ARGV[4])
805
786
  use_served(ARGV[5])
@@ -892,13 +873,13 @@ result[#result + 1] = tostring(total)
892
873
  return result
893
874
  `;
894
875
  /**
895
- * KEYS: counter, layout.
876
+ * KEYS: counter.
896
877
  * ARGV: holder, nowMs, expiresAtMs, n, then per claim: consumer, tag, index,
897
878
  * attempt. Returns [renewedCount, ...renewed, ...superseded] as 1-based
898
879
  * positions in the request.
899
880
  */
900
881
  const RENEW_CLAIMS_LUA = `${BASE_LUA}
901
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
882
+ read_bounds()
902
883
  local holder, now, expires_at = ARGV[1], tonumber(ARGV[2]), ARGV[3]
903
884
  local refs, items, superseded = {}, {}, {}
904
885
  for i = 1, tonumber(ARGV[4]) do
@@ -934,21 +915,19 @@ for i = 1, #superseded do result[#result + 1] = superseded[i] end
934
915
  return result
935
916
  `;
936
917
  /**
937
- * KEYS: counter, layout, then one id ownership key per child.
918
+ * KEYS: counter.
938
919
  * ARGV: sessionId, nowMs, consumer, tag, index, attempt, child causeJson,
939
- * served consumers, notify channel, returned idsJson, n, then per child: id,
940
- * type, payloadJson, targets.
920
+ * served consumers, returned idsJson, n, then per child: id, type,
921
+ * payloadJson, targets.
941
922
  */
942
- const COMPLETE_ATTEMPT_LUA = `${BASE_LUA}${CURSOR_LUA}${WAKE_LUA}
943
- local session_id, now = ARGV[1], ARGV[2]
923
+ const COMPLETE_ATTEMPT_LUA = `${BASE_LUA}${CURSOR_LUA}
924
+ local now = ARGV[2]
944
925
  local consumer, tag, idx, attempt = ARGV[3], ARGV[4], tonumber(ARGV[5]), tonumber(ARGV[6])
945
926
  local at = row_of(consumer, tag)
946
- local n = tonumber(ARGV[11])
947
- local id_keys = {}
948
- for i = 1, n do id_keys[i] = KEYS[2 + i] end
949
- local values = read_bounds(id_keys)
950
- if not values then return redis.error_reply('${LAYOUT_ERROR}') end
951
- local owners = owners_of(values, session_id)
927
+ local n = tonumber(ARGV[10])
928
+ local ids = {}
929
+ for i = 1, n do ids[i] = ARGV[10 + (i - 1) * 4 + 1] end
930
+ local owners = owners_of(read_bounds(ids))
952
931
  if idx <= boundary then return { 'superseded' } end
953
932
  if idx < 1 or idx > total then return { 'missing' } end
954
933
  local row = read_rows({ { idx, at } }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'l', 'next', 'pins' })[1]
@@ -959,14 +938,12 @@ if row[2] then
959
938
  local recorded = cjson.decode(row[4] or '[]')
960
939
  if #recorded ~= n then return { 'partial' } end
961
940
  for i = 1, n do
962
- if recorded[i] ~= ARGV[11 + (i - 1) * 4 + 1] then return { 'partial' } end
941
+ if recorded[i] ~= ARGV[10 + (i - 1) * 4 + 1] then return { 'partial' } end
963
942
  end
964
943
  local result = { 'duplicate' }
965
944
  for i = 1, n do
966
945
  if not owners[i] then return { 'partial' } end
967
- local rec = cjson.decode(owners[i])
968
- if rec[1] ~= session_id then return { 'partial' } end
969
- result[#result + 1] = tostring(rec[2])
946
+ result[#result + 1] = owners[i]
970
947
  end
971
948
  return result
972
949
  end
@@ -979,7 +956,7 @@ end
979
956
  use_served(ARGV[8])
980
957
  local batch = {}
981
958
  for i = 1, n do
982
- local o = 11 + (i - 1) * 4
959
+ local o = 10 + (i - 1) * 4
983
960
  batch[i] = { idx = total + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 4]) }
984
961
  end
985
962
  local moved, missing = advance(consumer, row[7], idx, row[8], batch)
@@ -988,47 +965,41 @@ local planned, unindexed, after = plan(batch, total)
988
965
  if not planned then return { 'index', unindexed, tostring(after) } end
989
966
  put_row(idx, at, 'p', now)
990
967
  put_row(idx, at, 'pa', ARGV[6])
991
- put_row(idx, at, 'ri', ARGV[10])
968
+ put_row(idx, at, 'ri', ARGV[9])
992
969
  release_pins(idx, at, row[9])
993
- local owner_writes = {}
970
+ local marks = {}
994
971
  for i = 1, n do
995
- local o = 11 + (i - 1) * 4
972
+ local o = 10 + (i - 1) * 4
996
973
  local field = tostring(total + i)
997
974
  redis.call('XADD', LOG, field .. '-0',
998
975
  'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
999
976
  'created_at', now, 'cause', ARGV[7])
1000
- owner_writes[#owner_writes + 1] = KEYS[2 + i]
1001
- owner_writes[#owner_writes + 1] = cjson.encode({ session_id, total + i })
1002
- wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
977
+ marks[#marks + 1] = id_key(ARGV[o + 1])
978
+ marks[#marks + 1] = field
1003
979
  end
1004
980
  index_batch(batch)
1005
981
  enqueue(batch)
1006
982
  if n > 0 then
1007
- owner_writes[#owner_writes + 1] = COUNTER
1008
- owner_writes[#owner_writes + 1] = tostring(total + n)
1009
- if schema_missing then
1010
- owner_writes[#owner_writes + 1] = SCHEMA
1011
- owner_writes[#owner_writes + 1] = '${REDIS_LAYOUT}'
1012
- end
983
+ marks[#marks + 1] = COUNTER
984
+ marks[#marks + 1] = tostring(total + n)
1013
985
  end
1014
986
  if typed_dirty then
1015
- owner_writes[#owner_writes + 1] = TYPED
1016
- owner_writes[#owner_writes + 1] = cjson.encode(typed)
987
+ marks[#marks + 1] = TYPED
988
+ marks[#marks + 1] = cjson.encode(typed)
1017
989
  end
1018
- sliced('MSET', nil, owner_writes)
990
+ sliced('MSET', nil, marks)
1019
991
  flush_states()
1020
992
  flush()
1021
993
  flush_ready()
1022
- if n > 0 then wake(ARGV[9], tostring(total + n), now) end
1023
994
  return { 'ok', tostring(total + 1) }
1024
995
  `;
1025
996
  /**
1026
- * KEYS: counter, layout.
997
+ * KEYS: counter.
1027
998
  * ARGV: consumer, tag, index, attempt, error, maxFailures, nowMs, retryAtMs
1028
999
  * or ''.
1029
1000
  */
1030
1001
  const FAIL_ATTEMPT_LUA = `${BASE_LUA}
1031
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1002
+ read_bounds()
1032
1003
  local consumer, tag, idx, attempt = ARGV[1], ARGV[2], tonumber(ARGV[3]), tonumber(ARGV[4])
1033
1004
  local at = row_of(consumer, tag)
1034
1005
  if idx <= boundary then return { 'superseded', '0' } end
@@ -1064,13 +1035,13 @@ flush_ready()
1064
1035
  return { dead and 'dead_lettered' or 'failed', tostring(failures) }
1065
1036
  `;
1066
1037
  /**
1067
- * KEYS: counter, layout. ARGV: consumer, index, action, nowMs, served
1038
+ * KEYS: counter. ARGV: consumer, index, action, nowMs, served
1068
1039
  * consumers. Returns [redriven, hasPending] | ['index', type, after]. Acts
1069
1040
  * only on the consumer's dead-lettered head at the index, above the clear
1070
1041
  * marker.
1071
1042
  */
1072
1043
  const REDRIVE_LUA = `${BASE_LUA}${CURSOR_LUA}
1073
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1044
+ read_bounds()
1074
1045
  use_served(ARGV[5])
1075
1046
  local consumer, idx = ARGV[1], tonumber(ARGV[2])
1076
1047
  local function result(redriven) return { redriven, has_pending() } end
@@ -1103,7 +1074,7 @@ flush_ready()
1103
1074
  return result('1')
1104
1075
  `;
1105
1076
  /**
1106
- * KEYS: counter, layout. ARGV: consumer, lane, types JSON or '*', backfill
1077
+ * KEYS: counter. ARGV: consumer, lane, types JSON or '*', backfill
1107
1078
  * ('1' or '0'). Brings one log cursor up to date without an append, as
1108
1079
  * `plan` and `enqueue` would for an append carrying its types: a missing
1109
1080
  * cursor starts at the clear marker with backfill, else at the head with
@@ -1113,7 +1084,7 @@ return result('1')
1113
1084
  * columns, session head] | ['index', type, after].
1114
1085
  */
1115
1086
  const WAKE_LOG_LUA = `${BASE_LUA}${CURSOR_LUA}
1116
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1087
+ read_bounds()
1117
1088
  local consumer, lane, ty = ARGV[1], ARGV[2], ARGV[3]
1118
1089
  local types = types_of(ty)
1119
1090
  local cid = cid_of(consumer, 'G' .. lane)
@@ -1158,13 +1129,13 @@ flush_ready()
1158
1129
  return { 'ok', tostring(state.p), state.h and tostring(state.h) or false, columns, tostring(total) }
1159
1130
  `;
1160
1131
  /**
1161
- * KEYS: counter, layout. Returns the dead-lettered heads above the clear
1132
+ * KEYS: counter. Returns the dead-lettered heads above the clear
1162
1133
  * marker as [consumer, tag, index, columns, entry] tuples.
1163
1134
  */
1164
1135
  const DEAD_LETTERS_LUA = `${BASE_LUA}
1165
1136
  local dead = redis.call('ZRANGEBYSCORE', READY, '-inf', '(0', 'WITHSCORES')
1166
1137
  if #dead == 0 then return {} end
1167
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1138
+ read_bounds()
1168
1139
  local refs, items = {}, {}
1169
1140
  for i = 1, #dead, 2 do
1170
1141
  local consumer, tag = parse_cid(dead[i])
@@ -1185,12 +1156,12 @@ end
1185
1156
  return result
1186
1157
  `;
1187
1158
  /**
1188
- * KEYS: counter, layout. Returns every listed cursor as [consumer, tag,
1159
+ * KEYS: counter. Returns every listed cursor as [consumer, tag,
1189
1160
  * position, head or nil, columns]: lane and log cursors from the cursor
1190
1161
  * hash, read against the clear marker, then the event cursors with a head.
1191
1162
  */
1192
1163
  const CURSORS_LUA = `${BASE_LUA}
1193
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1164
+ read_bounds()
1194
1165
  local listed = {}
1195
1166
  local fields = redis.call('HGETALL', CURSORS)
1196
1167
  local records, order = {}, {}
@@ -1243,7 +1214,7 @@ end
1243
1214
  return result
1244
1215
  `;
1245
1216
  /**
1246
- * KEYS: counter, layout, head, history.
1217
+ * KEYS: counter, head, history.
1247
1218
  * ARGV: write count, then per write: index, snapshot JSON, pin count, then
1248
1219
  * per pin: consumer, tag, index; finally the reducer name. Writes from
1249
1220
  * before the latest clear are dropped, and a head from before it is
@@ -1251,8 +1222,8 @@ return result
1251
1222
  * claimed row says so itself, an unclaimed head by its cursor's ready score.
1252
1223
  */
1253
1224
  const PUT_SNAPSHOTS_LUA = `${BASE_LUA}
1254
- if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
1255
- local HEAD, HISTORY = KEYS[3], KEYS[4]
1225
+ read_bounds()
1226
+ local HEAD, HISTORY = KEYS[2], KEYS[3]
1256
1227
  local entries, refs, pins = {}, {}, {}
1257
1228
  local offset = 2
1258
1229
  for w = 1, tonumber(ARGV[1]) do
@@ -1367,77 +1338,75 @@ end
1367
1338
  return 1
1368
1339
  `;
1369
1340
  /**
1370
- * KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
1371
- * hashField, valueJson ('' deletes), patchFieldJson; finally channel and
1372
- * message prefix. Wire fragments stay JSON text to preserve arrays
1373
- * and numeric precision through Lua. Field-wise LWW by the sender's
1374
- * `at`: a stored entry with a strictly newer stamp survives; ties go
1375
- * to the incoming write (memory-tier parity). Returns the applied
1376
- * 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
1377
- * sound because core sends one uniform ttlMs; the per-entry `e` stamp
1378
- * is the fine bound (the injected storage clock at the applied set
1379
- * plus ttlMs — never the sender stamp), pruned on read against the
1380
- * same clock.
1341
+ * Presence entries are `{"a":at,"e":expiresAt,"s":seen,"v":value}`, stamps
1342
+ * first and both integers, so scripts read them with a pattern instead of
1343
+ * decoding the value.
1344
+ */
1345
+ const PRESENCE_STAMPS_LUA = `
1346
+ local function stamps(entry)
1347
+ local a, e = string.match(entry, '^{"a":(%-?%d+),"e":(%-?%d+),')
1348
+ return tonumber(a), tonumber(e)
1349
+ end
1350
+ `;
1351
+ /**
1352
+ * KEYS: presence hash. ARGV: atMs, ttlMs, nowMs, n, then per entry:
1353
+ * hashField, entryJson (a null value is a tombstone). Field-wise LWW by the
1354
+ * sender's `at`: an unexpired stored entry with a strictly newer stamp
1355
+ * survives; ties go to the incoming write (memory-tier parity). Returns the applied 1-based entry
1356
+ * positions. The whole-key PEXPIRE is the coarse bound, sound because core
1357
+ * sends one uniform ttlMs; the per-entry `e` stamp is the fine bound (the
1358
+ * injected storage clock at the applied set plus ttlMs — never the sender
1359
+ * stamp), filtered on read against the same clock.
1381
1360
  */
1382
- const PRESENCE_SET_LUA = `
1383
- local at = tonumber(ARGV[1])
1384
- local applied = {}
1385
- local published = {}
1386
- local n = tonumber(ARGV[3])
1361
+ const PRESENCE_SET_LUA = `${SLICED_LUA}${PRESENCE_STAMPS_LUA}
1362
+ local at, now, n = tonumber(ARGV[1]), tonumber(ARGV[3]), tonumber(ARGV[4])
1363
+ local fields = {}
1364
+ for i = 1, n do fields[i] = ARGV[4 + (i - 1) * 2 + 1] end
1365
+ local stored = sliced('HMGET', KEYS[1], fields)
1366
+ local applied, sets = {}, {}
1387
1367
  for i = 1, n do
1388
- local field = ARGV[3 + (i - 1) * 3 + 1]
1389
- local value = ARGV[3 + (i - 1) * 3 + 2]
1390
- local existing = redis.call('HGET', KEYS[1], field)
1391
- if not existing or tonumber(cjson.decode(existing)['a']) <= at then
1392
- if value == '' then
1393
- redis.call('HDEL', KEYS[1], field)
1394
- else
1395
- redis.call('HSET', KEYS[1], field, value)
1396
- end
1368
+ local a, e = false, false
1369
+ if stored[i] then a, e = stamps(stored[i]) end
1370
+ if not stored[i] or a <= at or e <= now then
1371
+ sets[#sets + 1] = fields[i]
1372
+ sets[#sets + 1] = ARGV[4 + (i - 1) * 2 + 2]
1397
1373
  applied[#applied + 1] = tostring(i)
1398
- published[#published + 1] = ARGV[3 + (i - 1) * 3 + 3]
1399
1374
  end
1400
1375
  end
1401
- if #applied > 0 then
1402
- redis.call('PEXPIRE', KEYS[1], ARGV[2])
1403
- redis.pcall('PUBLISH', ARGV[4 + n * 3], ARGV[5 + n * 3] .. table.concat(published, ',') .. '}}')
1404
- end
1376
+ sliced('HSET', KEYS[1], sets)
1377
+ if #applied > 0 then redis.call('PEXPIRE', KEYS[1], ARGV[2]) end
1405
1378
  return applied
1406
1379
  `;
1407
1380
  /**
1408
- * KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
1409
- * time-travel expiry against a real server). Returns the live
1410
- * [field, value, ...] pairs; entries at or past their fine-bound
1411
- * expiry are HDELed in the same atomic step, so a stale reader can
1412
- * never sweep a concurrently refreshed field.
1381
+ * KEYS: presence hash. ARGV: nowMs, then the fields a read saw expired.
1382
+ * Deletes those still at or past their expiry, re-checked in this atomic
1383
+ * step so a stale reader never sweeps a concurrently refreshed field.
1413
1384
  */
1414
- const PRESENCE_READ_LUA = `
1385
+ const PRESENCE_PRUNE_LUA = `${SLICED_LUA}${PRESENCE_STAMPS_LUA}
1415
1386
  local now = tonumber(ARGV[1])
1416
- local rows = redis.call('HGETALL', KEYS[1])
1417
- local live = {}
1418
- for i = 1, #rows, 2 do
1419
- if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
1420
- redis.call('HDEL', KEYS[1], rows[i])
1421
- else
1422
- live[#live + 1] = rows[i]
1423
- live[#live + 1] = rows[i + 1]
1387
+ local fields = {}
1388
+ for i = 2, #ARGV do fields[#fields + 1] = ARGV[i] end
1389
+ local stored = sliced('HMGET', KEYS[1], fields)
1390
+ local stale = {}
1391
+ for i = 1, #fields do
1392
+ if stored[i] then
1393
+ local _, e = stamps(stored[i])
1394
+ if e <= now then stale[#stale + 1] = fields[i] end
1424
1395
  end
1425
1396
  end
1426
- return live
1397
+ sliced('HDEL', KEYS[1], stale)
1398
+ return 1
1427
1399
  `;
1428
1400
  /**
1429
- * KEYS: counter, layout, head, history. ARGV: snapshot frontier or ''.
1401
+ * KEYS: counter, head, history. ARGV: snapshot frontier or ''.
1430
1402
  * Returns one atomic multiversion-cache-plus-log-frontier read, with the
1431
1403
  * clear boundary; checkpoints from before it are ignored.
1432
1404
  */
1433
1405
  const READ_STATE_LUA = `
1434
1406
  local session = string.sub(KEYS[1], 1, -6)
1435
- local counts = redis.call('MGET', KEYS[1], session .. 'clear', KEYS[2])
1436
- if counts[3] and counts[3] ~= '${REDIS_LAYOUT}' then
1437
- return redis.error_reply('${LAYOUT_ERROR}')
1438
- end
1407
+ local counts = redis.call('MGET', KEYS[1], session .. 'clear')
1439
1408
  local boundary = tonumber(counts[2] or '0')
1440
- local head = redis.call('GET', KEYS[3])
1409
+ local head = redis.call('GET', KEYS[2])
1441
1410
  local head_index = head and cjson.decode(head)['index'] or nil
1442
1411
  if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
1443
1412
  return redis.error_reply('invalid snapshot head index')
@@ -1452,18 +1421,24 @@ if head_index and (cap == '' or head_index <= tonumber(cap)) then
1452
1421
  snapshot = head
1453
1422
  else
1454
1423
  local upper = cap == '' and '+inf' or cap
1455
- local historical = redis.call('ZREVRANGEBYSCORE', KEYS[4], upper, boundary, 'LIMIT', 0, 1)
1424
+ local historical = redis.call('ZREVRANGEBYSCORE', KEYS[3], upper, boundary, 'LIMIT', 0, 1)
1456
1425
  if #historical > 0 then snapshot = historical[1] end
1457
1426
  end
1458
- return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
1427
+ return { head_index or '', snapshot or '', counts[1] or counts[2] or '0', tostring(boundary) }
1459
1428
  `;
1460
1429
  /**
1461
- * What reclaim removes once a session has no moved log left: every
1430
+ * What reclaim removes once no entry is left below the latest clear: every
1462
1431
  * registered reducer's checkpoints below the latest clear and the pin counts
1463
1432
  * naming them (snapshot writes register each reducer in
1464
1433
  * {prefix}:{session}:snap-reducers, so no key scan is needed), every head
1465
1434
  * row below it (whole chunks, then the boundary chunk's older rows), the
1466
1435
  * type indexes' older entries, and the lane cursors it left idle.
1436
+ *
1437
+ * A lifetime that ended empty then collapses to the clear marker, the log
1438
+ * holding it (readers resuming before the marker still read it), and the log
1439
+ * cursors the sweep reset to the marker. The counter reads from the marker
1440
+ * when absent, type indexes rebuild lazily, and the marker's own id key goes
1441
+ * too.
1467
1442
  */
1468
1443
  const SWEEP_LUA = `
1469
1444
  local function sweep(boundary)
@@ -1530,22 +1505,45 @@ local function sweep(boundary)
1530
1505
  end
1531
1506
  sliced('HDEL', CURSORS, idle)
1532
1507
  sliced('HSET', CURSORS, moved)
1508
+ local count = redis.call('GET', COUNTER)
1509
+ if not count or tonumber(count) ~= boundary or redis.call('ZCARD', READY) > 0 then
1510
+ return
1511
+ end
1512
+ local stale = { COUNTER, TYPED, PIN_COUNTS, chunk_key(boundary) }
1513
+ local reducers = SESSION .. 'snap-reducers'
1514
+ for _, reducer in ipairs(redis.call('SMEMBERS', reducers)) do
1515
+ stale[#stale + 1] = SESSION .. 'snap:' .. reducer
1516
+ stale[#stale + 1] = SESSION .. 'snap-history:' .. reducer
1517
+ end
1518
+ stale[#stale + 1] = reducers
1519
+ local types = redis.call('GET', TYPED)
1520
+ if types then
1521
+ for name in pairs(cjson.decode(types)) do stale[#stale + 1] = type_key(name) end
1522
+ end
1523
+ local marker = redis.call('XRANGE', LOG, boundary .. '-0', boundary .. '-0')[1]
1524
+ if marker then
1525
+ for i = 1, #marker[2], 2 do
1526
+ if marker[2][i] == 'id' then stale[#stale + 1] = id_key(marker[2][i + 1]) end
1527
+ end
1528
+ end
1529
+ sliced('UNLINK', nil, stale)
1533
1530
  end
1534
1531
  `;
1535
1532
  /**
1536
- * KEYS: counter, layout, marker id ownership key.
1533
+ * KEYS: counter.
1537
1534
  * ARGV: sessionId, nowMs, ifLastIndex or '', cause consumer or '', cause tag,
1538
- * cause index, cause attempt, marker id, notify channel.
1539
- * Returns ['ok', cleared, head, boundary] | ['superseded'] | ['foreign'].
1540
- * The old lifetime's log moves aside in one RENAME, so every read of the log
1541
- * starts at the marker, and its ready set is unlinked, so claims and pending
1542
- * checks see only the new lifetime; deletion drains the moved log. Type
1543
- * indexes keep their older entries until then: lookups start above the
1544
- * marker.
1535
+ * cause index, cause attempt, marker id.
1536
+ * Returns ['ok', cleared, head, boundary, appended?] | ['superseded'] |
1537
+ * ['foreign']; `appended` is '1' when this call appended the marker.
1538
+ * The marker joins the same log: the old lifetime's entries stay below it
1539
+ * until reclaim deletes them, and every read of the log starts at the
1540
+ * marker. (A stream can't be renamed aside: Upstash refuses RENAME on one.)
1541
+ * The ready set is unlinked, so claims and pending checks see only the new
1542
+ * lifetime. Type indexes keep their older entries until reclaim too:
1543
+ * lookups start above the marker.
1545
1544
  */
1546
- const CLEAR_LUA = `${BASE_LUA}${WAKE_LUA}
1547
- local values = read_bounds({ KEYS[3] })
1548
- if not values then return redis.error_reply('${LAYOUT_ERROR}') end
1545
+ const CLEAR_LUA = `${BASE_LUA}
1546
+ local marker_owner = owners_of(read_bounds({ ARGV[8] }))[1]
1549
1547
  local head = total
1550
1548
  if ARGV[4] ~= '' then
1551
1549
  local idx = tonumber(ARGV[6])
@@ -1565,59 +1563,45 @@ end
1565
1563
  if head == 0 or boundary == head then
1566
1564
  return { 'ok', '1', tostring(head), tostring(boundary) }
1567
1565
  end
1568
- if values[1] then return { 'foreign' } end
1566
+ if marker_owner then return { 'foreign' } end
1569
1567
  local field = tostring(head + 1)
1570
- local moved = SESSION .. 'cleared:' .. field
1571
- redis.call('RENAME', LOG, moved)
1572
1568
  redis.call('UNLINK', READY)
1573
- redis.call('SADD', SESSION .. 'cleared', moved)
1574
1569
  redis.call('XADD', LOG, field .. '-0',
1575
1570
  'id', ARGV[8], 'type', CLEAR_TYPE, 'payload', 'null',
1576
1571
  'created_at', ARGV[2], 'cause', '')
1577
- local marker = { KEYS[3], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
1578
- if schema_missing then
1579
- marker[#marker + 1] = SCHEMA
1580
- marker[#marker + 1] = '${REDIS_LAYOUT}'
1581
- end
1582
- redis.call('MSET', unpack(marker))
1583
- wake_add(field, ARGV[8], CLEAR_TYPE, 'null')
1584
- wake(ARGV[9], field, ARGV[2])
1585
- return { 'ok', '1', field, field }
1572
+ redis.call('MSET', id_key(ARGV[8]), field, COUNTER, field, CLEAR, field)
1573
+ return { 'ok', '1', field, field, '1' }
1586
1574
  `;
1587
1575
  /**
1588
- * KEYS: counter, layout. ARGV: page size.
1589
- * The next deletion batch: a moved log and up to a page of its [index, id]
1590
- * pairs, without payloads. Empty moved logs are dropped; with none left it
1591
- * sweeps what the old lifetime left and returns nothing, so a retry
1592
- * finishes an interrupted deletion.
1576
+ * KEYS: counter. ARGV: page size.
1577
+ * The next deletion batch: up to a page of [index, id] pairs of the log's
1578
+ * entries below the latest clear. With none left it sweeps what the old
1579
+ * lifetimes left and returns nothing, so a retry finishes an interrupted
1580
+ * deletion.
1593
1581
  */
1594
1582
  const RECLAIM_BATCH_LUA = `${BASE_LUA}${SWEEP_LUA}
1595
1583
  local boundary = tonumber(redis.call('GET', CLEAR) or '0')
1596
1584
  if boundary == 0 then return {} end
1597
- for _, log in ipairs(redis.call('SMEMBERS', SESSION .. 'cleared')) do
1598
- local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
1599
- if #entries > 0 then
1600
- local batch = { log }
1601
- for _, entry in ipairs(entries) do
1602
- local fields = entry[2]
1603
- for i = 1, #fields, 2 do
1604
- if fields[i] == 'id' then
1605
- batch[#batch + 1] = string.match(entry[1], '^(%d+)')
1606
- batch[#batch + 1] = fields[i + 1]
1607
- break
1608
- end
1609
- end
1585
+ local entries = redis.call('XRANGE', LOG, '-', tostring(boundary - 1) .. '-0', 'COUNT', ARGV[1])
1586
+ if #entries == 0 then
1587
+ sweep(boundary)
1588
+ return {}
1589
+ end
1590
+ local batch = {}
1591
+ for _, entry in ipairs(entries) do
1592
+ local fields = entry[2]
1593
+ for i = 1, #fields, 2 do
1594
+ if fields[i] == 'id' then
1595
+ batch[#batch + 1] = string.match(entry[1], '^(%d+)')
1596
+ batch[#batch + 1] = fields[i + 1]
1597
+ break
1610
1598
  end
1611
- return batch
1612
1599
  end
1613
- redis.call('DEL', log)
1614
- redis.call('SREM', SESSION .. 'cleared', log)
1615
1600
  end
1616
- sweep(boundary)
1617
- return {}
1601
+ return batch
1618
1602
  `;
1619
1603
  /**
1620
- * KEYS: counter, layout, moved log, then one id ownership key per entry.
1604
+ * KEYS: counter.
1621
1605
  * ARGV: sessionId, n, then per entry: index, id.
1622
1606
  * Deletes the listed entries below the latest clear and their id records.
1623
1607
  * An id record survives when it names another event, so an id reused in the
@@ -1625,42 +1609,52 @@ return {}
1625
1609
  */
1626
1610
  const RECLAIM_LUA = `${BASE_LUA}${SWEEP_LUA}
1627
1611
  local boundary = tonumber(redis.call('GET', CLEAR) or '0')
1628
- local MOVED = KEYS[3]
1629
- local id_keys, indexes, stream_ids = {}, {}, {}
1612
+ local ids, indexes, stream_ids = {}, {}, {}
1630
1613
  -- A concurrent reclaim may list the same batch; only XDEL says who removed it.
1631
1614
  local removed = 0
1632
1615
  for i = 1, tonumber(ARGV[2]) do
1633
1616
  local field = ARGV[2 + (i - 1) * 2 + 1]
1634
1617
  if tonumber(field) < boundary then
1635
- id_keys[#id_keys + 1] = KEYS[3 + i]
1636
- indexes[#id_keys] = tonumber(field)
1637
- stream_ids[#id_keys] = field .. '-0'
1638
- end
1639
- end
1640
- if #id_keys > 0 then
1641
- local owners, stale_ids = sliced('MGET', nil, id_keys), {}
1642
- for i = 1, #id_keys do
1643
- if owners[i] then
1644
- local rec = cjson.decode(owners[i])
1645
- if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
1646
- stale_ids[#stale_ids + 1] = id_keys[i]
1647
- end
1618
+ ids[#ids + 1] = ARGV[2 + (i - 1) * 2 + 2]
1619
+ indexes[#ids] = tonumber(field)
1620
+ stream_ids[#ids] = field .. '-0'
1621
+ end
1622
+ end
1623
+ if #ids > 0 then
1624
+ local keys = {}
1625
+ for i = 1, #ids do keys[i] = id_key(ids[i]) end
1626
+ local owners, stale = sliced('MGET', nil, keys), {}
1627
+ for i = 1, #ids do
1628
+ if owners[i] and tonumber(owners[i]) == indexes[i] then
1629
+ stale[#stale + 1] = keys[i]
1648
1630
  end
1649
1631
  end
1650
- sliced('DEL', nil, stale_ids)
1651
- removed = redis.call('XDEL', MOVED, unpack(stream_ids))
1632
+ sliced('DEL', nil, stale)
1633
+ removed = redis.call('XDEL', LOG, unpack(stream_ids))
1652
1634
  end
1653
1635
  local done = 0
1654
- if redis.call('XLEN', MOVED) == 0 then
1655
- redis.call('DEL', MOVED)
1656
- redis.call('SREM', SESSION .. 'cleared', MOVED)
1657
- if redis.call('SCARD', SESSION .. 'cleared') == 0 then
1658
- sweep(boundary)
1659
- done = 1
1660
- end
1636
+ if #redis.call('XRANGE', LOG, '-', tostring(boundary - 1) .. '-0', 'COUNT', 1) == 0 then
1637
+ sweep(boundary)
1638
+ done = 1
1661
1639
  end
1662
1640
  return { removed, done }
1663
1641
  `;
1642
+ const WAKE_PAYLOAD_LIMIT = 32768;
1643
+ const encoder = new TextEncoder();
1644
+ /**
1645
+ * A wake-up announcing events through `frontier`. It carries them, so a
1646
+ * caught-up reader applies them without reading the stream:
1647
+ * `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`, with
1648
+ * payloads inlined as their stored JSON text. One over WAKE_PAYLOAD_LIMIT
1649
+ * bytes, or one without entries, announces only `{"f":frontier}`, and
1650
+ * readers catch up with XRANGE.
1651
+ */
1652
+ const wakeMessage = (frontier, createdAt, entries) => {
1653
+ const bare = `{"f":${frontier}}`;
1654
+ if (entries.length === 0) return bare;
1655
+ const carried = `{"f":${frontier},"t":${createdAt},"e":[${entries.map(([index, id, type, payload]) => `[${index},${JSON.stringify(id)},${JSON.stringify(type)},${payload}]`).join(",")}]}`;
1656
+ return encoder.encode(carried).byteLength > WAKE_PAYLOAD_LIMIT ? bare : carried;
1657
+ };
1664
1658
  const fieldMap = (fields) => {
1665
1659
  const map = {};
1666
1660
  for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
@@ -1819,9 +1813,9 @@ const toIndex = ({ raw, sessionId, name }) => {
1819
1813
  return index;
1820
1814
  };
1821
1815
  /**
1822
- * A range read that met a gap. A clear moves the old lifetime's log aside, so
1823
- * a read whose bounds predate it finds nothing where it expected events;
1824
- * anywhere else a gap is corruption.
1816
+ * A range read that met a gap. Reclaim deletes the entries below a clear, so
1817
+ * a read whose bounds predate the clear finds nothing where it expected
1818
+ * events; anywhere else a gap is corruption.
1825
1819
  */
1826
1820
  const rangeGap = (sessionId, message) => Object.assign(new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ${message}`), { rangeGap: true });
1827
1821
  const isRangeGap = (error) => error instanceof A2Error && error.rangeGap === true;
@@ -1846,33 +1840,77 @@ const wrap = async (fn) => {
1846
1840
  throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
1847
1841
  }
1848
1842
  };
1843
+ /**
1844
+ * A session's key. Every key of a session shares one hash tag, so a script
1845
+ * locks only its session on Upstash and stays in one slot on a cluster. The
1846
+ * tag is the session id as JSON with its braces escaped: it can't end early,
1847
+ * and no session's tag spells another's.
1848
+ */
1849
+ const redisSessionKey = ({ prefix, sessionId, suffix }) => `${prefix}:{${JSON.stringify(sessionId).replaceAll("{", "\\u007b").replaceAll("}", "\\u007d")}}:${suffix}`;
1849
1850
  function createRedisStoreCore(options) {
1850
1851
  const { call, callBytes, clock } = options;
1851
1852
  const generateId = options.ids;
1852
1853
  const prefix = options.keyPrefix;
1853
- const storeKey = (s) => `${prefix}:${s}:log`;
1854
- const countKey = (s) => `${prefix}:${s}:count`;
1855
- const clearKey = (s) => `${prefix}:${s}:clear`;
1854
+ if (prefix.includes("{") || prefix.includes("}")) throw new TypeError("a redis keyPrefix cannot contain braces: they would set the hash tag of every key");
1855
+ const sessionKey = (sessionId, suffix) => redisSessionKey({
1856
+ prefix,
1857
+ sessionId,
1858
+ suffix
1859
+ });
1860
+ const storeKey = (s) => sessionKey(s, "log");
1861
+ const countKey = (s) => sessionKey(s, "count");
1862
+ const clearKey = (s) => sessionKey(s, "clear");
1856
1863
  const schemaKey = `${prefix}:schema`;
1857
- const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
1858
- const snapHistoryKey = (s, r) => `${prefix}:${s}:snap-history:${r}`;
1859
- const presenceKey = (s) => `${prefix}:${s}:presence`;
1864
+ const snapKey = (s, r) => sessionKey(s, `snap:${r}`);
1865
+ const snapHistoryKey = (s, r) => sessionKey(s, `snap-history:${r}`);
1866
+ const presenceKey = (s) => sessionKey(s, "presence");
1860
1867
  const notifyChannel = (s) => `${prefix}:${s}:notify`;
1861
- const idKey = (id) => `${prefix}:ids:${JSON.stringify(id).replaceAll(":", "\\u003a")}`;
1868
+ const presenceChannel = (s) => `${prefix}:${s}:presence`;
1862
1869
  const layoutError = () => new A2Error("STORE_NOT_CONFIGURED", `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`);
1863
- /** Runs a script over a session: KEYS start with its counter and the layout key. */
1864
- const evalScript = async (script, sessionId, keys, args) => {
1865
- const allKeys = [
1866
- countKey(sessionId),
1867
- schemaKey,
1868
- ...keys
1869
- ];
1870
- try {
1871
- return await call("EVAL", script, allKeys.length, ...allKeys, ...args);
1872
- } catch (error) {
1873
- if (String(error?.message).includes(LAYOUT_ERROR)) throw layoutError();
1870
+ /**
1871
+ * Once per store, before its first script: claim or check the prefix's
1872
+ * layout, and ask the server whether scripts may lock only their keys.
1873
+ * Only the server's own rejection of the flag means no; a transport error
1874
+ * fails this attempt, and the next operation asks again.
1875
+ */
1876
+ const prepared = retryableLazy(async () => {
1877
+ const [, keyLocking] = await Promise.all([(async () => {
1878
+ await call("SET", schemaKey, REDIS_LAYOUT, "NX");
1879
+ if (await call("GET", schemaKey) !== "tagged-1") throw layoutError();
1880
+ })(), call("EVAL", `${KEY_LOCKING}\nreturn 1`, 1, schemaKey).then(() => true, (error) => {
1881
+ if (SHEBANG_REJECTED.test(String(error?.message))) return false;
1874
1882
  throw error;
1875
- }
1883
+ })]);
1884
+ return { keyLocking };
1885
+ });
1886
+ /** Runs a script; a `readOnly` one takes Upstash's shared lock. */
1887
+ const runScript = async ({ script, keys, args, readOnly }) => {
1888
+ const { keyLocking } = await prepared.get();
1889
+ let source = script;
1890
+ if (keyLocking) source = `${readOnly ? READ_ONLY_KEY_LOCKING : KEY_LOCKING}\n${script}`;
1891
+ return call("EVAL", source, keys.length, ...keys, ...args);
1892
+ };
1893
+ /** Runs a writing script over a session: KEYS start with its counter. */
1894
+ const evalScript = (script, sessionId, keys, args) => runScript({
1895
+ script,
1896
+ keys: [countKey(sessionId), ...keys],
1897
+ args,
1898
+ readOnly: false
1899
+ });
1900
+ /**
1901
+ * Publishes a write's wake-up after its script, never inside it: a script
1902
+ * holds its lock while PUBLISH waits on subscribers. Wake-ups are hints (a
1903
+ * lost one costs subscribers a catch-up read, never an event), so a failed
1904
+ * publish is dropped and the write waits for it at most
1905
+ * NOTIFY_TIMINGS.publishMs.
1906
+ */
1907
+ const publish = async (channel, message) => {
1908
+ let timer;
1909
+ await Promise.race([call("PUBLISH", channel, message).catch(() => void 0), new Promise((resolve) => {
1910
+ timer = setTimeout(resolve, NOTIFY_TIMINGS.publishMs);
1911
+ timer.unref?.();
1912
+ })]);
1913
+ clearTimeout(timer);
1876
1914
  };
1877
1915
  /**
1878
1916
  * Runs a write that may need a type's index to reach further back first.
@@ -1889,13 +1927,13 @@ function createRedisStoreCore(options) {
1889
1927
  }
1890
1928
  }
1891
1929
  };
1892
- /** The frontier and clear marker, checking the layout in the same MGET. */
1930
+ /** The frontier and clear marker, in one MGET. */
1893
1931
  const readBounds = async (sessionId) => {
1894
- const [count, cleared, schema] = await call("MGET", countKey(sessionId), clearKey(sessionId), schemaKey);
1895
- if (schema !== null && schema !== void 0 && schema !== "cursors-2") throw layoutError();
1932
+ await prepared.get();
1933
+ const [count, cleared] = await call("MGET", countKey(sessionId), clearKey(sessionId));
1896
1934
  return {
1897
1935
  frontier: toIndex({
1898
- raw: count,
1936
+ raw: count ?? cleared,
1899
1937
  sessionId,
1900
1938
  name: "event count"
1901
1939
  }),
@@ -1906,6 +1944,14 @@ function createRedisStoreCore(options) {
1906
1944
  })
1907
1945
  };
1908
1946
  };
1947
+ const readClearIndex = async (sessionId) => {
1948
+ await prepared.get();
1949
+ return toIndex({
1950
+ raw: await call("GET", clearKey(sessionId)),
1951
+ sessionId,
1952
+ name: "clear boundary"
1953
+ });
1954
+ };
1909
1955
  const readEventPages = async (sessionId, afterIndex, throughIndex) => {
1910
1956
  if (throughIndex <= afterIndex) return [];
1911
1957
  const events = [];
@@ -1998,17 +2044,17 @@ function createRedisStoreCore(options) {
1998
2044
  cause: e.cause ? { ...e.cause } : null,
1999
2045
  targets: e.targets
2000
2046
  }));
2001
- const reply = await indexing(sessionId, () => evalScript(APPEND_LUA, sessionId, withIds.map((e) => idKey(e.id)), [
2047
+ const payloads = withIds.map((e) => json(e.payload));
2048
+ const reply = await indexing(sessionId, () => evalScript(APPEND_LUA, sessionId, [], [
2002
2049
  sessionId,
2003
2050
  now,
2004
2051
  withIds.length,
2005
2052
  servedArg(appendOptions?.consumers),
2006
- notifyChannel(sessionId),
2007
2053
  appendOptions?.originIndex ?? "",
2008
- ...withIds.flatMap((e) => [
2054
+ ...withIds.flatMap((e, i) => [
2009
2055
  e.id,
2010
2056
  e.type,
2011
- json(e.payload),
2057
+ payloads[i],
2012
2058
  e.cause ? json(e.cause) : "",
2013
2059
  targetsArg(e.targets)
2014
2060
  ])
@@ -2018,7 +2064,6 @@ function createRedisStoreCore(options) {
2018
2064
  events: [],
2019
2065
  hasPending: reply[1] === "1"
2020
2066
  };
2021
- if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
2022
2067
  if (verdict === "cleared") throw sessionClearedError(`scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`);
2023
2068
  if (verdict === "superseded") throw supersededAttemptError(sessionId, withIds[Number(reply[1]) - 1].cause);
2024
2069
  if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
@@ -2027,12 +2072,20 @@ function createRedisStoreCore(options) {
2027
2072
  const firstIndex = Math.min(...reply.slice(2).map(Number));
2028
2073
  const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
2029
2074
  if (firstIndex < clearIndex) throw new A2Error("STORE_UNAVAILABLE", `session '${sessionId}' was cleared while an append retry replayed; retry it`);
2075
+ const replayed = idempotentReplay(events, existing);
2076
+ await publish(notifyChannel(sessionId), wakeMessage(firstIndex + events.length - 1, now, []));
2030
2077
  return {
2031
- events: idempotentReplay(events, existing),
2078
+ events: replayed,
2032
2079
  hasPending
2033
2080
  };
2034
2081
  }
2035
2082
  const base = Number(reply[1]) - 1;
2083
+ await publish(notifyChannel(sessionId), wakeMessage(base + withIds.length, now, withIds.map((e, i) => [
2084
+ base + 1 + i,
2085
+ e.id,
2086
+ e.type,
2087
+ payloads[i]
2088
+ ])));
2036
2089
  return {
2037
2090
  events: withIds.map((e, i) => ({
2038
2091
  id: e.id,
@@ -2049,7 +2102,12 @@ function createRedisStoreCore(options) {
2049
2102
  },
2050
2103
  async cursors(sessionId) {
2051
2104
  return wrap(async () => {
2052
- return (await evalScript(CURSORS_LUA, sessionId, [], [])).map(([consumer, tag, position, head, columns]) => Object.assign(dispatchOf(consumer, laneOf(tag), columns), {
2105
+ return (await runScript({
2106
+ script: CURSORS_LUA,
2107
+ keys: [countKey(sessionId)],
2108
+ args: [],
2109
+ readOnly: true
2110
+ })).map(([consumer, tag, position, head, columns]) => Object.assign(dispatchOf(consumer, laneOf(tag), columns), {
2053
2111
  position: Number(position),
2054
2112
  head: head === null ? null : Number(head)
2055
2113
  })).toSorted(byCursor);
@@ -2130,7 +2188,8 @@ function createRedisStoreCore(options) {
2130
2188
  index,
2131
2189
  attempt
2132
2190
  };
2133
- const reply = await indexing(sessionId, () => evalScript(COMPLETE_ATTEMPT_LUA, sessionId, ids.map((id) => idKey(id)), [
2191
+ const payloads = events.map((event) => json(event.payload));
2192
+ const reply = await indexing(sessionId, () => evalScript(COMPLETE_ATTEMPT_LUA, sessionId, [], [
2134
2193
  sessionId,
2135
2194
  now,
2136
2195
  consumer,
@@ -2139,13 +2198,12 @@ function createRedisStoreCore(options) {
2139
2198
  attempt,
2140
2199
  json(cause),
2141
2200
  servedArg(consumers),
2142
- notifyChannel(sessionId),
2143
2201
  json(ids),
2144
2202
  events.length,
2145
- ...events.flatMap((event) => [
2203
+ ...events.flatMap((event, i) => [
2146
2204
  event.id,
2147
2205
  event.type,
2148
- json(event.payload),
2206
+ payloads[i],
2149
2207
  targetsArg(event.targets)
2150
2208
  ])
2151
2209
  ]));
@@ -2155,6 +2213,12 @@ function createRedisStoreCore(options) {
2155
2213
  if (outcome === "superseded") return { outcome };
2156
2214
  if (outcome === "ok") {
2157
2215
  const base = Number(reply[1]) - 1;
2216
+ if (events.length > 0) await publish(notifyChannel(sessionId), wakeMessage(base + events.length, now, events.map((event, i) => [
2217
+ base + 1 + i,
2218
+ event.id,
2219
+ event.type,
2220
+ payloads[i]
2221
+ ])));
2158
2222
  return {
2159
2223
  outcome: "completed",
2160
2224
  events: events.map((event, i) => ({
@@ -2177,9 +2241,11 @@ function createRedisStoreCore(options) {
2177
2241
  const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
2178
2242
  if (index <= clearIndex) return { outcome: "superseded" };
2179
2243
  if (existing.some((row) => row.cause?.consumer !== consumer || row.cause.lane !== lane || row.cause.index !== index || row.cause.attempt !== attempt)) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
2244
+ const replayed = idempotentReplay(events, existing);
2245
+ await publish(notifyChannel(sessionId), wakeMessage(firstIndex + events.length - 1, now, []));
2180
2246
  return {
2181
2247
  outcome: "completed",
2182
- events: idempotentReplay(events, existing)
2248
+ events: replayed
2183
2249
  };
2184
2250
  }
2185
2251
  throw new TypeError(`unexpected completion outcome '${outcome}'`);
@@ -2208,7 +2274,12 @@ function createRedisStoreCore(options) {
2208
2274
  },
2209
2275
  async deadLetters(sessionId) {
2210
2276
  return wrap(async () => {
2211
- return (await evalScript(DEAD_LETTERS_LUA, sessionId, [], [])).map((head) => toHeadEvent(sessionId, head)).toSorted(byHead);
2277
+ return (await runScript({
2278
+ script: DEAD_LETTERS_LUA,
2279
+ keys: [countKey(sessionId)],
2280
+ args: [],
2281
+ readOnly: true
2282
+ })).map((head) => toHeadEvent(sessionId, head)).toSorted(byHead);
2212
2283
  });
2213
2284
  },
2214
2285
  async redrive({ sessionId, consumer, index, action, consumers }) {
@@ -2246,7 +2317,16 @@ function createRedisStoreCore(options) {
2246
2317
  },
2247
2318
  async readState(sessionId, reducerName, stateOptions) {
2248
2319
  return wrap(() => retryAfterGap(async () => {
2249
- const reply = await evalScript(READ_STATE_LUA, sessionId, [snapKey(sessionId, reducerName), snapHistoryKey(sessionId, reducerName)], [stateOptions?.snapshotThroughIndex ?? stateOptions?.throughIndex ?? ""]);
2320
+ const reply = await runScript({
2321
+ script: READ_STATE_LUA,
2322
+ keys: [
2323
+ countKey(sessionId),
2324
+ snapKey(sessionId, reducerName),
2325
+ snapHistoryKey(sessionId, reducerName)
2326
+ ],
2327
+ args: [stateOptions?.snapshotThroughIndex ?? stateOptions?.throughIndex ?? ""],
2328
+ readOnly: true
2329
+ });
2250
2330
  const rawHead = reply[0];
2251
2331
  const raw = reply[1];
2252
2332
  const headIndex = rawHead === "" ? null : Number(rawHead);
@@ -2308,43 +2388,64 @@ function createRedisStoreCore(options) {
2308
2388
  const entries = Object.entries(values);
2309
2389
  if (entries.length === 0) return null;
2310
2390
  const atMs = meta.at.getTime();
2311
- const expiresAtMs = clock.now().getTime() + meta.ttlMs;
2312
- const applied = await call("EVAL", PRESENCE_SET_LUA, 1, presenceKey(ns), atMs, meta.ttlMs, entries.length, ...entries.flatMap(([field, value]) => [
2313
- JSON.stringify([participant, field]),
2314
- value === null ? "" : json({
2315
- v: value,
2316
- s: meta.seen,
2317
- a: atMs,
2318
- e: expiresAtMs
2319
- }),
2320
- `${json(field)}:${json(value)}`
2321
- ]), presenceKey(ns), `${json({
2322
- src: options.presenceSource,
2323
- participant,
2324
- seen: meta.seen,
2325
- at: atMs
2326
- }).slice(0, -1)},"values":{`);
2391
+ const nowMs = clock.now().getTime();
2392
+ const expiresAtMs = nowMs + meta.ttlMs;
2393
+ const applied = await runScript({
2394
+ script: PRESENCE_SET_LUA,
2395
+ keys: [presenceKey(ns)],
2396
+ args: [
2397
+ atMs,
2398
+ meta.ttlMs,
2399
+ nowMs,
2400
+ entries.length,
2401
+ ...entries.flatMap(([field, value]) => [JSON.stringify([participant, field]), json({
2402
+ a: atMs,
2403
+ e: expiresAtMs,
2404
+ s: meta.seen,
2405
+ v: value
2406
+ })])
2407
+ ],
2408
+ readOnly: false
2409
+ });
2327
2410
  if (applied.length === 0) return null;
2328
2411
  const appliedValues = nullProtoRecord();
2412
+ const published = [];
2329
2413
  for (const position of applied) {
2330
2414
  const [field, value] = entries[Number(position) - 1];
2331
2415
  appliedValues[field] = structuredClone(value);
2416
+ published.push(`${json(field)}:${json(value)}`);
2332
2417
  }
2333
2418
  return {
2334
- participant,
2335
- values: appliedValues,
2336
- seen: meta.seen,
2337
- at: new Date(atMs)
2419
+ patch: {
2420
+ participant,
2421
+ values: appliedValues,
2422
+ seen: meta.seen,
2423
+ at: new Date(atMs)
2424
+ },
2425
+ published: publish(presenceChannel(ns), `${json({
2426
+ src: options.presenceSource,
2427
+ participant,
2428
+ seen: meta.seen,
2429
+ at: atMs
2430
+ }).slice(0, -1)},"values":{${published.join(",")}}}`)
2338
2431
  };
2339
2432
  });
2340
2433
  },
2341
2434
  async read(ns) {
2342
2435
  return wrap(async () => {
2343
- const reply = await call("EVAL", PRESENCE_READ_LUA, 1, presenceKey(ns), clock.now().getTime());
2436
+ const key = presenceKey(ns);
2437
+ const reply = await call("HGETALL", key);
2438
+ const nowMs = clock.now().getTime();
2344
2439
  const rows = [];
2440
+ const expired = [];
2345
2441
  for (let i = 0; i + 1 < reply.length; i += 2) {
2346
- const [participant, field] = JSON.parse(reply[i]);
2347
2442
  const entry = JSON.parse(reply[i + 1]);
2443
+ if (entry.e <= nowMs) {
2444
+ expired.push(reply[i]);
2445
+ continue;
2446
+ }
2447
+ if (entry.v === null) continue;
2448
+ const [participant, field] = JSON.parse(reply[i]);
2348
2449
  rows.push({
2349
2450
  participant,
2350
2451
  field,
@@ -2354,6 +2455,12 @@ function createRedisStoreCore(options) {
2354
2455
  expiresAt: new Date(entry.e)
2355
2456
  });
2356
2457
  }
2458
+ if (expired.length > 0) await runScript({
2459
+ script: PRESENCE_PRUNE_LUA,
2460
+ keys: [key],
2461
+ args: [nowMs, ...expired],
2462
+ readOnly: false
2463
+ }).catch(() => void 0);
2357
2464
  return rows;
2358
2465
  });
2359
2466
  }
@@ -2363,7 +2470,9 @@ function createRedisStoreCore(options) {
2363
2470
  },
2364
2471
  async readTail(sessionId, fromIndex, throughIndex) {
2365
2472
  return wrap(async () => {
2366
- const page = (await call("XREVRANGE", storeKey(sessionId), throughIndex === void 0 ? "+" : `${throughIndex}-0`, `${fromIndex}-0`, "COUNT", 256)).toReversed().map((entry) => toEvent(sessionId, entry));
2473
+ const [newest, clearIndex] = await Promise.all([call("XREVRANGE", storeKey(sessionId), throughIndex === void 0 ? "+" : `${throughIndex}-0`, `${fromIndex}-0`, "COUNT", 256), readClearIndex(sessionId)]);
2474
+ if (clearIndex > fromIndex) return null;
2475
+ const page = newest.toReversed().map((entry) => toEvent(sessionId, entry));
2367
2476
  for (let position = 1; position < page.length; position += 1) if (page[position].index !== page[position - 1].index + 1) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
2368
2477
  const oldest = page[0]?.index;
2369
2478
  return oldest === void 0 || oldest <= fromIndex ? page : null;
@@ -2372,19 +2481,27 @@ function createRedisStoreCore(options) {
2372
2481
  async clear({ sessionId, ifLastIndex, cause }) {
2373
2482
  return wrap(async () => {
2374
2483
  const markerId = generateId();
2375
- const reply = await evalScript(CLEAR_LUA, sessionId, [idKey(markerId)], [
2484
+ const now = clock.now().getTime();
2485
+ const reply = await evalScript(CLEAR_LUA, sessionId, [], [
2376
2486
  sessionId,
2377
- clock.now().getTime(),
2487
+ now,
2378
2488
  ifLastIndex ?? "",
2379
2489
  cause?.consumer ?? "",
2380
2490
  cause ? tagOf(cause.lane) : "",
2381
2491
  cause?.index ?? "",
2382
2492
  cause?.attempt ?? "",
2383
- markerId,
2384
- notifyChannel(sessionId)
2493
+ markerId
2385
2494
  ]);
2386
2495
  if (reply[0] === "superseded") throw supersededAttemptError(sessionId, cause);
2387
2496
  if (reply[0] === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `the clear marker id '${markerId}' already exists`);
2497
+ const head = Number(reply[2]);
2498
+ if (reply[4] === "1") await publish(notifyChannel(sessionId), wakeMessage(head, now, [[
2499
+ head,
2500
+ markerId,
2501
+ CLEAR_EVENT_TYPE,
2502
+ "null"
2503
+ ]]));
2504
+ else if (reply[1] === "1" && head > 0) await publish(notifyChannel(sessionId), wakeMessage(head, now, []));
2388
2505
  return {
2389
2506
  cleared: reply[1] === "1",
2390
2507
  index: Number(reply[2]),
@@ -2394,16 +2511,14 @@ function createRedisStoreCore(options) {
2394
2511
  },
2395
2512
  async reclaim(sessionId) {
2396
2513
  return wrap(async () => {
2397
- const [log, ...pairs] = await evalScript(RECLAIM_BATCH_LUA, sessionId, [], [256]);
2398
- if (log === void 0) return {
2514
+ const pairs = await evalScript(RECLAIM_BATCH_LUA, sessionId, [], [256]);
2515
+ if (pairs.length === 0) return {
2399
2516
  deleted: 0,
2400
2517
  done: true
2401
2518
  };
2402
- const ids = [];
2403
- for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]);
2404
- const [deleted, done] = await evalScript(RECLAIM_LUA, sessionId, [log, ...ids.map((id) => idKey(id))], [
2519
+ const [deleted, done] = await evalScript(RECLAIM_LUA, sessionId, [], [
2405
2520
  sessionId,
2406
- ids.length,
2521
+ pairs.length / 2,
2407
2522
  ...pairs
2408
2523
  ]);
2409
2524
  return {
@@ -2412,6 +2527,7 @@ function createRedisStoreCore(options) {
2412
2527
  };
2413
2528
  });
2414
2529
  },
2530
+ clearIndex: (sessionId) => wrap(() => readClearIndex(sessionId)),
2415
2531
  async readEvents(sessionId, afterIndex) {
2416
2532
  return wrap(async () => {
2417
2533
  return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+", "COUNT", 256)).map((entry) => toEvent(sessionId, entry));
@@ -2527,6 +2643,8 @@ function createRedisNotifyStore(options) {
2527
2643
  feed.bytes += message.length;
2528
2644
  } else {
2529
2645
  feed.inbox.length = 0;
2646
+ feed.held.length = 0;
2647
+ feed.gapSince = void 0;
2530
2648
  feed.bytes = 0;
2531
2649
  feed.dirty = true;
2532
2650
  }
@@ -2631,8 +2749,9 @@ function createRedisNotifyStore(options) {
2631
2749
  wakeLog: core.wakeLog,
2632
2750
  presence: {
2633
2751
  async set(ns, participant, values, meta) {
2634
- const patch = await core.presence.set(ns, participant, values, meta);
2635
- if (!patch) return;
2752
+ const applied = await core.presence.set(ns, participant, values, meta);
2753
+ if (!applied) return;
2754
+ const { patch } = applied;
2636
2755
  const channel = presenceChannel(ns);
2637
2756
  const subscription = presenceSubscriptions.get(channel);
2638
2757
  if (subscription) {
@@ -2640,11 +2759,13 @@ function createRedisNotifyStore(options) {
2640
2759
  subscription.recovery?.update(patch);
2641
2760
  for (const listener of subscription.listeners) listener(patch);
2642
2761
  }
2762
+ await applied.published;
2643
2763
  },
2644
2764
  async read(ns) {
2645
2765
  const channel = presenceChannel(ns);
2646
2766
  await subscriptions.get(channel)?.ready;
2647
- return presenceSubscriptions.get(channel)?.recovery?.read() ?? core.presence.read(ns);
2767
+ const subscription = presenceSubscriptions.get(channel);
2768
+ return subscription?.recovery?.read() ?? subscription?.poll?.snapshot(() => core.presence.read(ns)) ?? core.presence.read(ns);
2648
2769
  },
2649
2770
  subscribe(ns, onPatch) {
2650
2771
  const channel = presenceChannel(ns);
@@ -2725,6 +2846,8 @@ function createRedisNotifyStore(options) {
2725
2846
  inbox: [],
2726
2847
  bytes: 0,
2727
2848
  dirty: true,
2849
+ held: [],
2850
+ gapSince: void 0,
2728
2851
  wake: null,
2729
2852
  stopRead
2730
2853
  };
@@ -2747,35 +2870,48 @@ function createRedisNotifyStore(options) {
2747
2870
  };
2748
2871
  /**
2749
2872
  * Moves carried events that continue this feed's sequence into
2750
- * the buffer. A gap, a message without events, or an unreadable
2751
- * message marks the feed dirty so it catches up with XRANGE.
2873
+ * the buffer. Writers publish after their scripts, so concurrent
2874
+ * writers' wake-ups can arrive out of order: they apply in index
2875
+ * order, and one past a gap is held until the gap fills or
2876
+ * NOTIFY_TIMINGS.reorderMs passes. A message without events past
2877
+ * the buffer, or an unreadable one, marks the feed dirty so it
2878
+ * catches up with XRANGE.
2752
2879
  */
2753
2880
  const applyInbox = () => {
2754
- feed.bytes = 0;
2881
+ const carried = feed.held.splice(0);
2882
+ let announced = 0;
2755
2883
  for (const message of feed.inbox.splice(0)) {
2756
2884
  const wake = parseWake(message);
2757
- if (!wake) {
2758
- feed.dirty = true;
2885
+ if (!wake) feed.dirty = true;
2886
+ else if (wake.entries.length === 0) announced = Math.max(announced, wake.frontier);
2887
+ else carried.push({
2888
+ wake,
2889
+ bytes: message.length
2890
+ });
2891
+ }
2892
+ carried.sort((a, b) => a.wake.entries[0][0] - b.wake.entries[0][0]);
2893
+ feed.bytes = 0;
2894
+ for (const held of carried) {
2895
+ if (held.wake.entries[0][0] > (buffer.at(-1)?.index ?? last) + 1) {
2896
+ feed.held.push(held);
2897
+ feed.bytes += held.bytes;
2759
2898
  continue;
2760
2899
  }
2761
- for (const [index, id, type, payload] of wake.entries) {
2762
- const expected = (buffer.at(-1)?.index ?? last) + 1;
2763
- if (index < expected) continue;
2764
- if (index > expected) {
2765
- feed.dirty = true;
2766
- break;
2767
- }
2900
+ for (const [index, id, type, payload] of held.wake.entries) {
2901
+ if (index <= (buffer.at(-1)?.index ?? last)) continue;
2768
2902
  buffer.push({
2769
2903
  id,
2770
2904
  type,
2771
2905
  payload,
2772
2906
  index,
2773
2907
  sessionId,
2774
- createdAt: new Date(wake.createdAt)
2908
+ createdAt: new Date(held.wake.createdAt)
2775
2909
  });
2776
2910
  }
2777
- if (wake.frontier > (buffer.at(-1)?.index ?? last)) feed.dirty = true;
2778
2911
  }
2912
+ if (announced > (buffer.at(-1)?.index ?? last)) feed.dirty = true;
2913
+ if (feed.held.length === 0) feed.gapSince = void 0;
2914
+ else feed.gapSince ??= Date.now();
2779
2915
  };
2780
2916
  const iterator = {
2781
2917
  async next() {
@@ -2817,7 +2953,8 @@ function createRedisNotifyStore(options) {
2817
2953
  feeds.set(channel, set);
2818
2954
  }
2819
2955
  set.add(feed);
2820
- await acquireChannel(channel);
2956
+ const [, clearIndex] = await Promise.all([acquireChannel(channel), core.clearIndex(sessionId)]);
2957
+ if (clearIndex - 1 > last) last = clearIndex - 1;
2821
2958
  if (closed || severed) continue;
2822
2959
  }
2823
2960
  if (options.polling?.()) {
@@ -2839,8 +2976,11 @@ function createRedisNotifyStore(options) {
2839
2976
  }
2840
2977
  applyInbox();
2841
2978
  if (buffer.length > 0) continue;
2979
+ if (feed.gapSince !== void 0 && Date.now() - feed.gapSince >= NOTIFY_TIMINGS.reorderMs) feed.dirty = true;
2842
2980
  if (feed.dirty) {
2843
2981
  feed.dirty = false;
2982
+ feed.held.length = 0;
2983
+ feed.gapSince = void 0;
2844
2984
  if (core.streamEvents) {
2845
2985
  const result = await nextRead(last);
2846
2986
  if (closed || severed) continue;
@@ -2854,11 +2994,12 @@ function createRedisNotifyStore(options) {
2854
2994
  const woken = new Promise((resolve) => {
2855
2995
  feed.wake = () => resolve("wake");
2856
2996
  });
2857
- pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
2997
+ const holding = feed.gapSince !== void 0;
2998
+ pending = defaultSleep(holding ? Math.max(0, NOTIFY_TIMINGS.reorderMs - (Date.now() - feed.gapSince)) : NOTIFY_TIMINGS.safetyReadMs);
2858
2999
  const outcome = await Promise.race([woken, pending.promise.then(() => "timer")]);
2859
3000
  pending.cancel();
2860
3001
  pending = null;
2861
- if (outcome === "timer") feed.dirty = true;
3002
+ if (outcome === "timer" && !holding) feed.dirty = true;
2862
3003
  } catch (err) {
2863
3004
  release();
2864
3005
  if (closed) return {
@@ -2916,4 +3057,4 @@ function createRedisNotifyStore(options) {
2916
3057
  //#endregion
2917
3058
  export { createRedisNotifyStore as t };
2918
3059
 
2919
- //# sourceMappingURL=store-redis-notify-IiVdlYHc.js.map
3060
+ //# sourceMappingURL=store-redis-notify-CsJw_hfD.js.map