experimental-a2 0.21.1 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/dist/{actor-HnGmrOZZ.d.ts → actor-CqibLgjQ.d.ts} +13 -5
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-react.d.ts +1 -1
- package/dist/actor-react.js +1 -1
- package/dist/actor-shared-DFET5q16.js.map +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +3 -3
- package/dist/{ai-CvEnqVfo.d.ts → ai-DAu77CDr.d.ts} +2 -2
- package/dist/{ai-CvEnqVfo.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-CzE672Q3.js → ai-EEfKyc-z.js} +2 -2
- package/dist/{ai-CzE672Q3.js.map → ai-EEfKyc-z.js.map} +1 -1
- package/dist/ai-client.d.ts +1 -1
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +211 -61
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-Bfk-Lycn.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-Bfk-Lycn.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/ai-harness-dQeJaR1t.js.map +1 -1
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +1 -1
- package/dist/ai-react.d.ts +1 -1
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +3 -3
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +10 -4
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +1 -1
- package/dist/ai.js +1 -1
- package/dist/{client-9IeTsiAs.js → client-BQS4i_V9.js} +27 -4
- package/dist/client-BQS4i_V9.js.map +1 -0
- package/dist/client-CUMbS_38.d.ts.map +1 -1
- package/dist/client.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/{internal-Bc4PvfHk.js → internal-CvsrprnU.js} +32 -15
- package/dist/{internal-Bc4PvfHk.js.map → internal-CvsrprnU.js.map} +1 -1
- package/dist/react.js +1 -1
- package/dist/{runtime-consumer-BWmht60z.js → runtime-consumer-CIeL33wj.js} +2 -2
- package/dist/{runtime-consumer-BWmht60z.js.map → runtime-consumer-CIeL33wj.js.map} +1 -1
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +2 -2
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +2 -2
- package/dist/{server-DvIB8kj5.js → server-DtFrWxi3.js} +30 -8
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-BshFZ0eL.d.ts → server-qgez0Xny.d.ts} +3 -3
- package/dist/{server-BshFZ0eL.d.ts.map → server-qgez0Xny.d.ts.map} +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.js +1 -1
- package/dist/{session-clear-Bk_29eEw.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
- package/dist/{store-Z2qjw50n.d.ts → store-D0MhBvg6.d.ts} +15 -6
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +15 -17
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-DYNyxCev.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-DYNyxCev.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +31 -40
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +3 -3
- package/dist/{store-redis-notify-IiVdlYHc.js → store-redis-notify-CsJw_hfD.js} +538 -397
- package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +3 -4
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +16 -18
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- package/dist/{wire-CZGqNZRx.js → wire-CV-Z7wz-.js} +3 -3
- package/dist/{wire-CZGqNZRx.js.map → wire-CV-Z7wz-.js.map} +1 -1
- package/docs/actors/01-introduction.mdx +5 -2
- package/docs/agents/03-conversations.mdx +9 -8
- package/docs/agents/04-harnesses.mdx +7 -4
- package/docs/reference/01-core.mdx +3 -2
- package/docs/reference/02-server.mdx +5 -1
- package/docs/reference/04-ai.mdx +13 -4
- package/docs/reference/06-errors.mdx +1 -1
- package/docs/server/01-consumers.mdx +4 -1
- package/docs/server/06-guarantees.mdx +7 -4
- package/docs/stores/02-redis.mdx +16 -10
- package/docs/stores/03-redis-http.mdx +6 -4
- package/docs/stores/04-postgres.mdx +2 -2
- package/docs/upgrades/01-overview.mdx +1 -0
- package/docs/upgrades/78-0-22.mdx +115 -0
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a2slack/db/a2.ts +7 -4
- package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +8 -1
- package/examples/playground/app/chat/server.ts +2 -2
- package/examples/playground/lib/store.ts +1 -1
- package/examples/playground/package.json +1 -1
- package/package.json +1 -1
- package/sql/postgres/drizzle-schema.ts +7 -4
- package/sql/postgres/schema.sql +24 -3
- package/src/actor-shared.ts +10 -2
- package/src/ai-agent.ts +13 -0
- package/src/ai-fx.ts +321 -77
- package/src/ai-harness.ts +7 -0
- package/src/client.ts +19 -2
- package/src/deterministic-id.ts +1 -1
- package/src/internal.ts +42 -11
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +2 -2
- package/src/server.ts +49 -15
- package/src/store-memory.ts +24 -32
- package/src/store-postgres.ts +43 -53
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +595 -439
- package/src/store-redis-notify.ts +74 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +34 -40
- package/src/store.ts +14 -5
- package/dist/actor-HnGmrOZZ.d.ts.map +0 -1
- package/dist/client-9IeTsiAs.js.map +0 -1
- package/dist/server-DvIB8kj5.js.map +0 -1
- package/dist/session-clear-Bk_29eEw.d.ts.map +0 -1
- package/dist/store-Z2qjw50n.d.ts.map +0 -1
- package/dist/store-presence-polling-CzQ0wdtM.js +0 -94
- package/dist/store-presence-polling-CzQ0wdtM.js.map +0 -1
- package/dist/store-redis-notify-IiVdlYHc.js.map +0 -1
- package/dist/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
|
@@ -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 {
|
|
3
|
-
import {
|
|
4
|
-
import { n as
|
|
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-
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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 = "
|
|
105
|
-
|
|
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
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
--
|
|
148
|
-
--
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
655
|
-
* ARGV: sessionId, nowMs, n, served consumers,
|
|
656
|
-
*
|
|
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
|
-
* ['
|
|
649
|
+
* ['superseded', position] | ['cleared', originIndex] |
|
|
660
650
|
* ['index', type, after].
|
|
661
651
|
*/
|
|
662
|
-
const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
663
|
-
local
|
|
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
|
|
667
|
-
for i = 1, n do
|
|
668
|
-
local
|
|
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[
|
|
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[
|
|
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 =
|
|
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
|
|
703
|
+
local marks = {}
|
|
720
704
|
for i = 1, n do
|
|
721
|
-
local o =
|
|
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
|
-
|
|
727
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
740
|
-
|
|
718
|
+
marks[#marks + 1] = TYPED
|
|
719
|
+
marks[#marks + 1] = cjson.encode(typed)
|
|
741
720
|
end
|
|
742
|
-
sliced('MSET', nil,
|
|
721
|
+
sliced('MSET', nil, marks)
|
|
743
722
|
flush_states()
|
|
744
723
|
flush()
|
|
745
724
|
flush_ready()
|
|
746
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
918
|
+
* KEYS: counter.
|
|
938
919
|
* ARGV: sessionId, nowMs, consumer, tag, index, attempt, child causeJson,
|
|
939
|
-
* served consumers,
|
|
940
|
-
*
|
|
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}
|
|
943
|
-
local
|
|
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[
|
|
947
|
-
local
|
|
948
|
-
for i = 1, n do
|
|
949
|
-
local
|
|
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[
|
|
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
|
-
|
|
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 =
|
|
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[
|
|
968
|
+
put_row(idx, at, 'ri', ARGV[9])
|
|
992
969
|
release_pins(idx, at, row[9])
|
|
993
|
-
local
|
|
970
|
+
local marks = {}
|
|
994
971
|
for i = 1, n do
|
|
995
|
-
local o =
|
|
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
|
-
|
|
1001
|
-
|
|
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
|
-
|
|
1008
|
-
|
|
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
|
-
|
|
1016
|
-
|
|
987
|
+
marks[#marks + 1] = TYPED
|
|
988
|
+
marks[#marks + 1] = cjson.encode(typed)
|
|
1017
989
|
end
|
|
1018
|
-
sliced('MSET', nil,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1255
|
-
local HEAD, HISTORY = KEYS[
|
|
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
|
-
*
|
|
1371
|
-
*
|
|
1372
|
-
*
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
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
|
|
1385
|
-
|
|
1386
|
-
local
|
|
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
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
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
|
-
|
|
1402
|
-
|
|
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
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
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
|
|
1385
|
+
const PRESENCE_PRUNE_LUA = `${SLICED_LUA}${PRESENCE_STAMPS_LUA}
|
|
1415
1386
|
local now = tonumber(ARGV[1])
|
|
1416
|
-
local
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
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
|
-
|
|
1397
|
+
sliced('HDEL', KEYS[1], stale)
|
|
1398
|
+
return 1
|
|
1427
1399
|
`;
|
|
1428
1400
|
/**
|
|
1429
|
-
* KEYS: counter,
|
|
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'
|
|
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[
|
|
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[
|
|
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
|
|
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
|
|
1533
|
+
* KEYS: counter.
|
|
1537
1534
|
* ARGV: sessionId, nowMs, ifLastIndex or '', cause consumer or '', cause tag,
|
|
1538
|
-
* cause index, cause attempt, marker id
|
|
1539
|
-
* Returns ['ok', cleared, head, boundary] | ['superseded'] |
|
|
1540
|
-
*
|
|
1541
|
-
*
|
|
1542
|
-
*
|
|
1543
|
-
*
|
|
1544
|
-
*
|
|
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}
|
|
1547
|
-
local
|
|
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
|
|
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
|
-
|
|
1578
|
-
|
|
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
|
|
1589
|
-
* The next deletion batch:
|
|
1590
|
-
*
|
|
1591
|
-
*
|
|
1592
|
-
*
|
|
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
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
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
|
-
|
|
1617
|
-
return {}
|
|
1601
|
+
return batch
|
|
1618
1602
|
`;
|
|
1619
1603
|
/**
|
|
1620
|
-
* KEYS: counter
|
|
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
|
|
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
|
-
|
|
1636
|
-
indexes[#
|
|
1637
|
-
stream_ids[#
|
|
1638
|
-
end
|
|
1639
|
-
end
|
|
1640
|
-
if #
|
|
1641
|
-
local
|
|
1642
|
-
for i = 1, #
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
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,
|
|
1651
|
-
removed = redis.call('XDEL',
|
|
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('
|
|
1655
|
-
|
|
1656
|
-
|
|
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.
|
|
1823
|
-
* a read whose bounds predate
|
|
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
|
-
|
|
1854
|
-
const
|
|
1855
|
-
|
|
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) =>
|
|
1858
|
-
const snapHistoryKey = (s, r) =>
|
|
1859
|
-
const presenceKey = (s) =>
|
|
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
|
|
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
|
-
/**
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
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,
|
|
1930
|
+
/** The frontier and clear marker, in one MGET. */
|
|
1893
1931
|
const readBounds = async (sessionId) => {
|
|
1894
|
-
|
|
1895
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
2312
|
-
const
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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
|
-
|
|
2335
|
-
|
|
2336
|
-
|
|
2337
|
-
|
|
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
|
|
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
|
|
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
|
|
2484
|
+
const now = clock.now().getTime();
|
|
2485
|
+
const reply = await evalScript(CLEAR_LUA, sessionId, [], [
|
|
2376
2486
|
sessionId,
|
|
2377
|
-
|
|
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
|
|
2398
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
|
2635
|
-
if (!
|
|
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
|
-
|
|
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.
|
|
2751
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
3060
|
+
//# sourceMappingURL=store-redis-notify-CsJw_hfD.js.map
|