experimental-a2 0.16.0 → 0.17.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 +360 -0
- package/dist/{actor-ohPC-81x.d.ts → actor-DBbe5xeE.d.ts} +21 -11
- package/dist/actor-DBbe5xeE.d.ts.map +1 -0
- package/dist/actor-client.d.ts +2 -5
- package/dist/actor-client.d.ts.map +1 -1
- package/dist/actor-client.js +13 -4
- package/dist/actor-client.js.map +1 -1
- package/dist/actor-react.d.ts +5 -3
- package/dist/actor-react.d.ts.map +1 -1
- package/dist/actor-react.js +20 -10
- package/dist/actor-react.js.map +1 -1
- package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
- package/dist/actor-shared-t7w8xIKP.js.map +1 -0
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +42 -23
- package/dist/actor.js.map +1 -1
- package/dist/{ai-CrLYwNEx.js → ai-UrWfKeuv.js} +966 -541
- package/dist/ai-UrWfKeuv.js.map +1 -0
- package/dist/ai-client.d.ts +27 -0
- package/dist/ai-client.d.ts.map +1 -0
- package/dist/ai-client.js +29 -0
- package/dist/ai-client.js.map +1 -0
- package/dist/ai-react.d.ts +96 -0
- package/dist/ai-react.d.ts.map +1 -0
- package/dist/ai-react.js +142 -0
- package/dist/ai-react.js.map +1 -0
- package/dist/ai-server.browser.js +2 -2
- package/dist/ai-server.browser.js.map +1 -1
- package/dist/ai-server.d.ts +80 -36
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +466 -265
- package/dist/ai-server.js.map +1 -1
- package/dist/{ai-HJ9fHfYI.d.ts → ai-vpGtcaCB.d.ts} +115 -101
- package/dist/ai-vpGtcaCB.d.ts.map +1 -0
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BYzHjkwU.js → client-COaiaDub.js} +447 -80
- package/dist/client-COaiaDub.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-qRnRUzBS.d.ts} +14 -13
- package/dist/client-qRnRUzBS.d.ts.map +1 -0
- package/dist/client.d.ts +4 -2
- package/dist/client.js +1 -1
- package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
- package/dist/contract-BYKASlCV.js.map +1 -0
- package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
- package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
- package/dist/index.d.ts +6 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-BrLso5x_.d.ts} +7 -3
- package/dist/initial-snapshot-BrLso5x_.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-CbVvEwad.js} +23 -2
- package/dist/internal-CbVvEwad.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BbJ-g6Uu.d.ts +86 -0
- package/dist/react-BbJ-g6Uu.d.ts.map +1 -0
- package/dist/react.d.ts +2 -122
- package/dist/react.js +107 -100
- package/dist/react.js.map +1 -1
- package/dist/reducer-BAXc8JyF.js +87 -0
- package/dist/reducer-BAXc8JyF.js.map +1 -0
- package/dist/{reducer-otzHjuJj.d.ts → reducer-BYu6DT7h.d.ts} +45 -29
- package/dist/reducer-BYu6DT7h.d.ts.map +1 -0
- package/dist/reducer-runtime-DQwz_J59.js +28 -0
- package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +3 -3
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +33 -10
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -0
- package/dist/scheduler-vercel.d.ts +4 -9
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +25 -44
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DbD7IJVK.d.ts → server-CGM8WzRG.d.ts} +87 -26
- package/dist/server-CGM8WzRG.d.ts.map +1 -0
- package/dist/{server-BD5ckxZb.js → server-Rj64fNvX.js} +854 -202
- package/dist/server-Rj64fNvX.js.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/session-clear-Dshpuhff.d.ts +12 -0
- package/dist/session-clear-Dshpuhff.d.ts.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Gpkq7WBo.d.ts} +44 -3
- package/dist/store-Gpkq7WBo.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 +182 -69
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-4_y8lETm.js} +21 -4
- package/dist/store-polling-4_y8lETm.js.map +1 -0
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +399 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-Q1C-k2dd.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-Q1C-k2dd.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.d.ts.map +1 -1
- package/dist/store-redis-http.js +293 -11
- package/dist/store-redis-http.js.map +1 -1
- package/dist/store-redis-notify-tqWNnMRu.js +2374 -0
- package/dist/store-redis-notify-tqWNnMRu.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 +11 -11
- 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 +166 -32
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-Cx7AAPGq.js +23 -0
- package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
- package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-cZH-cibj.d.ts} +2 -2
- package/dist/telemetry-cZH-cibj.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-DjfHOvdW.js} +132 -10
- package/dist/wire-DjfHOvdW.js.map +1 -0
- package/docs/01-quickstart.mdx +2 -2
- package/docs/actors/01-introduction.mdx +4 -0
- package/docs/actors/04-routes.mdx +14 -1
- package/docs/concepts/01-contracts.mdx +95 -0
- package/docs/concepts/04-state.mdx +3 -3
- package/docs/guides/01-timers.mdx +22 -14
- package/docs/guides/02-cancellation.mdx +1 -1
- package/docs/guides/03-react.mdx +268 -328
- package/docs/guides/04-local-first.mdx +32 -73
- package/docs/guides/05-production.mdx +40 -27
- package/docs/guides/06-ai-agents.mdx +680 -580
- package/docs/guides/09-presence.mdx +3 -7
- package/docs/guides/10-transports.mdx +5 -5
- package/docs/index.mdx +10 -18
- package/docs/reference/01-api.mdx +662 -195
- package/docs/reference/02-errors.mdx +2 -1
- package/examples/playground/DEPLOY.md +52 -6
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +48 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +211 -0
- package/examples/playground/app/a0/app-view.tsx +55 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +23 -0
- package/examples/playground/app/a0/apps.test.ts +116 -0
- package/examples/playground/app/a0/apps.ts +87 -0
- package/examples/playground/app/a0/chat.tsx +364 -0
- package/examples/playground/app/a0/columns.tsx +109 -0
- package/examples/playground/app/a0/context-menu.tsx +116 -0
- package/examples/playground/app/a0/cookies.ts +46 -0
- package/examples/playground/app/a0/crash-on-request.ts +72 -0
- package/examples/playground/app/a0/crashes/route.ts +3 -0
- package/examples/playground/app/a0/crashes.test.ts +33 -0
- package/examples/playground/app/a0/crashes.ts +28 -0
- package/examples/playground/app/a0/devtools.tsx +164 -0
- package/examples/playground/app/a0/events/route.ts +6 -0
- package/examples/playground/app/a0/flags.ts +30 -0
- package/examples/playground/app/a0/layout.tsx +38 -0
- package/examples/playground/app/a0/list/route.ts +10 -0
- package/examples/playground/app/a0/model.test.ts +56 -0
- package/examples/playground/app/a0/model.ts +58 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/preview-pane.tsx +145 -0
- package/examples/playground/app/a0/preview.test.ts +53 -0
- package/examples/playground/app/a0/preview.ts +26 -0
- package/examples/playground/app/a0/sandbox-log.ts +69 -0
- package/examples/playground/app/a0/sandbox.ts +191 -0
- package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
- package/examples/playground/app/a0/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/server.ts +158 -0
- package/examples/playground/app/a0/session.ts +47 -0
- package/examples/playground/app/a0/shell.tsx +150 -0
- package/examples/playground/app/a0/short/events/route.ts +9 -0
- package/examples/playground/app/a0/splitter.tsx +120 -0
- package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
- package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
- package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
- package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
- package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
- package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
- package/examples/playground/app/agent/events/route.ts +5 -2
- package/examples/playground/app/agent/ingress.test.ts +98 -0
- package/examples/playground/app/agent/model-input.test.tsx +58 -0
- package/examples/playground/app/agent/model-input.tsx +115 -0
- package/examples/playground/app/agent/model.test.ts +73 -0
- package/examples/playground/app/agent/model.ts +58 -26
- package/examples/playground/app/agent/new-agent-session.tsx +37 -19
- package/examples/playground/app/agent/page.tsx +4 -4
- package/examples/playground/app/agent/server.ts +15 -14
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +168 -0
- package/examples/playground/app/agent/telemetry.ts +30 -0
- package/examples/playground/app/agent/trace.test.ts +23 -0
- package/examples/playground/app/agent/trace.ts +18 -0
- package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +11 -11
- package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
- package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
- package/examples/playground/app/canvas/model.ts +53 -53
- package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
- package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
- package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
- package/examples/playground/app/chat/[chatId]/session.ts +2 -19
- package/examples/playground/app/chat/agent/route.ts +9 -0
- package/examples/playground/app/chat/model.test.ts +46 -119
- package/examples/playground/app/chat/model.ts +82 -128
- package/examples/playground/app/chat/scheduler/route.ts +2 -2
- package/examples/playground/app/chat/server.test.ts +108 -0
- package/examples/playground/app/chat/server.ts +69 -73
- package/examples/playground/app/components/activity-feed.tsx +30 -1
- package/examples/playground/app/components/activity-history.test.tsx +35 -0
- package/examples/playground/app/components/demo-icons.tsx +89 -0
- package/examples/playground/app/components/demos.tsx +33 -0
- package/examples/playground/app/components/fast-link.test.tsx +58 -0
- package/examples/playground/app/components/fast-link.tsx +55 -0
- package/examples/playground/app/components/site-header.tsx +96 -0
- package/examples/playground/app/counter/counter-client.tsx +3 -3
- package/examples/playground/app/counter/model.test.ts +3 -3
- package/examples/playground/app/counter/model.ts +11 -21
- package/examples/playground/app/counter/page.tsx +2 -15
- package/examples/playground/app/counter/session.ts +2 -2
- package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
- package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
- package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
- package/examples/playground/app/documents/[documentId]/session.ts +2 -2
- package/examples/playground/app/documents/model.ts +27 -27
- package/examples/playground/app/globals.css +420 -207
- package/examples/playground/app/initial-state.test.tsx +54 -0
- package/examples/playground/app/layout.tsx +7 -23
- package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
- package/examples/playground/app/orders/[orderId]/session.ts +2 -2
- package/examples/playground/app/orders/model.ts +49 -49
- package/examples/playground/app/page.tsx +13 -102
- package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
- package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
- package/examples/playground/app/recovery/model.ts +30 -30
- package/examples/playground/instrumentation.ts +5 -0
- package/examples/playground/package.json +10 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +18 -0
- package/package.json +3 -1
- package/src/actor-client.ts +16 -4
- package/src/actor-react.ts +25 -11
- package/src/actor-shared.ts +21 -6
- package/src/actor.ts +76 -31
- package/src/ai-agent-actions.ts +219 -0
- package/src/ai-agent.ts +2343 -0
- package/src/ai-app-state.ts +91 -0
- package/src/ai-client-state.ts +39 -2
- package/src/ai-client.ts +62 -0
- package/src/ai-context-schema.ts +25 -5
- package/src/ai-context.ts +90 -40
- package/src/ai-control-server.ts +94 -55
- package/src/ai-control.ts +2 -0
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +382 -58
- package/src/ai-progress-batches.ts +26 -0
- package/src/ai-projector.ts +95 -14
- package/src/ai-react.ts +146 -0
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +20 -1951
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +343 -276
- package/src/bootstrap-stream.ts +72 -0
- package/src/bounded-cache.ts +55 -0
- package/src/client-render-session.ts +49 -0
- package/src/client.ts +688 -151
- package/src/contract.ts +92 -42
- package/src/deterministic-id.ts +37 -0
- package/src/equal-json.ts +32 -0
- package/src/errors.ts +13 -1
- package/src/index.ts +5 -1
- package/src/initial-snapshot.ts +39 -0
- package/src/internal.ts +45 -0
- package/src/postgres-notification-scope.ts +14 -3
- package/src/react.ts +171 -206
- package/src/redis-http-reply.ts +256 -0
- package/src/redis-http-stream.ts +73 -0
- package/src/redis-http-subscriptions.ts +13 -5
- package/src/reducer-runtime.ts +52 -0
- package/src/reducer.ts +92 -10
- package/src/scheduler-qstash.ts +49 -20
- package/src/scheduler-task.ts +34 -2
- package/src/scheduler-vercel.ts +32 -84
- package/src/server-fetch.ts +139 -35
- package/src/server.ts +957 -237
- package/src/session-clear.ts +23 -0
- package/src/session-socket.ts +154 -49
- package/src/sse.ts +71 -4
- package/src/store-memory.ts +220 -78
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +461 -114
- package/src/store-redis-core.ts +1260 -434
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +260 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +259 -45
- package/src/store.ts +46 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +13 -1
- package/src/wire.ts +241 -13
- package/dist/actor-ohPC-81x.d.ts.map +0 -1
- package/dist/actor-shared-B5tJfzt-.js.map +0 -1
- package/dist/ai-CrLYwNEx.js.map +0 -1
- package/dist/ai-HJ9fHfYI.d.ts.map +0 -1
- package/dist/client-BYzHjkwU.js.map +0 -1
- package/dist/client-CzyacQpJ.d.ts.map +0 -1
- package/dist/contract-CKRg_E4q.js.map +0 -1
- package/dist/errors-DCk6ch5n.js.map +0 -1
- package/dist/errors-DvhSXnxk.d.ts.map +0 -1
- package/dist/internal-Dq2qYxou.js.map +0 -1
- package/dist/react.d.ts.map +0 -1
- package/dist/reducer-DEMjEY_O.js +0 -29
- package/dist/reducer-DEMjEY_O.js.map +0 -1
- package/dist/reducer-otzHjuJj.d.ts.map +0 -1
- package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
- package/dist/server-BD5ckxZb.js.map +0 -1
- package/dist/server-DbD7IJVK.d.ts.map +0 -1
- package/dist/store-DyZM6fS5.d.ts.map +0 -1
- package/dist/store-polling-CmxUbV93.js.map +0 -1
- package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
- package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
- package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
- package/dist/validate-XKT4FSNn.js +0 -30
- package/dist/validate-XKT4FSNn.js.map +0 -1
- package/dist/wire-BO5wWCb1.js.map +0 -1
- package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
- package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
- package/examples/playground/app/agent/compaction-settings.ts +0 -49
- package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
- package/examples/playground/app/agent/compaction-timeline.ts +0 -208
|
@@ -0,0 +1,2374 @@
|
|
|
1
|
+
import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-DGI4jTQ5.js";
|
|
2
|
+
import { g as nullProtoRecord, r as NOTIFY_TIMINGS, v as stateTailReaders } from "./internal-CbVvEwad.js";
|
|
3
|
+
import { i as streamActivities, n as pollingStream, r as createStreamActivity, t as defaultSleep } from "./store-polling-4_y8lETm.js";
|
|
4
|
+
import { n as isSerializedEvent, r as parseSerializedEvent, t as bufferedEvents } from "./stream-buffer-Cx7AAPGq.js";
|
|
5
|
+
import { t as idempotentReplay } from "./idempotent-replay-JdgMQRRm.js";
|
|
6
|
+
import { t as decodeReturnedEventIds } from "./store-codec-DTG0Ftek.js";
|
|
7
|
+
import { t as pollPresence } from "./store-presence-polling-Q1C-k2dd.js";
|
|
8
|
+
//#region src/presence-recovery.ts
|
|
9
|
+
const key = ({ participant, field }) => JSON.stringify([participant, field]);
|
|
10
|
+
function presenceRecovery({ read, deliver, isActive }) {
|
|
11
|
+
const fields = /* @__PURE__ */ new Map();
|
|
12
|
+
const reading = /* @__PURE__ */ new Set();
|
|
13
|
+
let generation = 0;
|
|
14
|
+
let latest;
|
|
15
|
+
const remember = ({ participant, field, seen, at }) => {
|
|
16
|
+
const id = key({
|
|
17
|
+
participant,
|
|
18
|
+
field
|
|
19
|
+
});
|
|
20
|
+
const previous = fields.get(id);
|
|
21
|
+
if (!previous || previous.at.getTime() < at.getTime() || previous.at.getTime() === at.getTime() && previous.seen !== seen) fields.set(id, {
|
|
22
|
+
participant,
|
|
23
|
+
field,
|
|
24
|
+
seen,
|
|
25
|
+
at
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const snapshot = async ({ reconcile }) => {
|
|
29
|
+
const run = generation;
|
|
30
|
+
const before = new Map(fields);
|
|
31
|
+
const changed = /* @__PURE__ */ new Set();
|
|
32
|
+
reading.add(changed);
|
|
33
|
+
try {
|
|
34
|
+
const rows = await read();
|
|
35
|
+
if (!isActive()) return rows;
|
|
36
|
+
if (run !== generation) return latest;
|
|
37
|
+
const live = /* @__PURE__ */ new Set();
|
|
38
|
+
for (const row of rows) {
|
|
39
|
+
const id = key(row);
|
|
40
|
+
live.add(id);
|
|
41
|
+
if (changed.has(id)) continue;
|
|
42
|
+
remember(row);
|
|
43
|
+
if (reconcile) deliver({
|
|
44
|
+
participant: row.participant,
|
|
45
|
+
values: { [row.field]: row.value },
|
|
46
|
+
seen: row.seen,
|
|
47
|
+
at: row.at
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
if (reconcile) for (const [id, prior] of before) {
|
|
51
|
+
if (live.has(id) || changed.has(id) || fields.get(id) !== prior) continue;
|
|
52
|
+
fields.delete(id);
|
|
53
|
+
deliver({
|
|
54
|
+
participant: prior.participant,
|
|
55
|
+
values: { [prior.field]: null },
|
|
56
|
+
seen: prior.seen,
|
|
57
|
+
at: prior.at
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
return rows;
|
|
61
|
+
} finally {
|
|
62
|
+
reading.delete(changed);
|
|
63
|
+
if (reading.size === 0) latest = void 0;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
return {
|
|
67
|
+
async read() {
|
|
68
|
+
let pending = latest;
|
|
69
|
+
while (pending) {
|
|
70
|
+
await pending;
|
|
71
|
+
if (latest === pending) break;
|
|
72
|
+
pending = latest;
|
|
73
|
+
}
|
|
74
|
+
return snapshot({ reconcile: false });
|
|
75
|
+
},
|
|
76
|
+
async reconnect() {
|
|
77
|
+
generation += 1;
|
|
78
|
+
latest = snapshot({ reconcile: true });
|
|
79
|
+
await latest;
|
|
80
|
+
},
|
|
81
|
+
update(patch) {
|
|
82
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
83
|
+
if (value === void 0) continue;
|
|
84
|
+
const id = key({
|
|
85
|
+
participant: patch.participant,
|
|
86
|
+
field
|
|
87
|
+
});
|
|
88
|
+
const previous = fields.get(id);
|
|
89
|
+
if (previous && previous.at.getTime() > patch.at.getTime()) continue;
|
|
90
|
+
for (const changed of reading) changed.add(id);
|
|
91
|
+
if (value === null) fields.delete(id);
|
|
92
|
+
else remember({
|
|
93
|
+
participant: patch.participant,
|
|
94
|
+
field,
|
|
95
|
+
seen: patch.seen,
|
|
96
|
+
at: patch.at
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
const FEED_PAGE_SIZE = 1e3;
|
|
103
|
+
const READ_CONCURRENCY = 16;
|
|
104
|
+
const META_CHUNK_SIZE = 1e3;
|
|
105
|
+
/**
|
|
106
|
+
* Upstash bills every call a script makes, so scripts read the counter and
|
|
107
|
+
* split with one MGET, read metadata with one HMGET per hash (`read_fields`),
|
|
108
|
+
* and buffer hash writes (`put`) into one HSET per hash (`flush`). Buffered
|
|
109
|
+
* writes are invisible to reads until flushed, so a script must not read a
|
|
110
|
+
* field it has buffered. Per-event metadata lives in hashes of
|
|
111
|
+
* META_CHUNK_SIZE consecutive indexes, so no key grows with a session's
|
|
112
|
+
* history. Indexes at or below the split predate chunking and stay in the
|
|
113
|
+
* base hash, which also holds every session's lane pointers. An unset split
|
|
114
|
+
* means the current event count, so any script that adds events must write
|
|
115
|
+
* `split_write` pairs in its write phase. Including scripts bind COUNTER,
|
|
116
|
+
* META (the base hash), and SPLIT first.
|
|
117
|
+
*/
|
|
118
|
+
const META_LUA = `
|
|
119
|
+
local split, split_stored = nil, false
|
|
120
|
+
-- The latest clear marker lives beside the counter, {prefix}:{session}:clear,
|
|
121
|
+
-- and is read in the same MGET, so a session never cleared pays nothing.
|
|
122
|
+
local CLEAR = string.sub(COUNTER, 1, -6) .. 'clear'
|
|
123
|
+
local boundary = 0
|
|
124
|
+
local function use_split(stored, count, cleared)
|
|
125
|
+
split_stored = stored ~= false
|
|
126
|
+
split = tonumber(stored or count or '0')
|
|
127
|
+
boundary = tonumber(cleared or '0')
|
|
128
|
+
end
|
|
129
|
+
local function mkey(field)
|
|
130
|
+
if split == nil then
|
|
131
|
+
local values = redis.call('MGET', SPLIT, COUNTER, CLEAR)
|
|
132
|
+
use_split(values[1], values[2], values[3])
|
|
133
|
+
end
|
|
134
|
+
local idx = tonumber(field)
|
|
135
|
+
if idx <= split then return META end
|
|
136
|
+
return META .. ':' .. tostring(math.floor((idx - 1) / ${META_CHUNK_SIZE}))
|
|
137
|
+
end
|
|
138
|
+
local function split_write(args)
|
|
139
|
+
if not split_stored then
|
|
140
|
+
args[#args + 1] = SPLIT
|
|
141
|
+
args[#args + 1] = tostring(split)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
local function sliced(command, key, args)
|
|
145
|
+
local results = {}
|
|
146
|
+
for first = 1, #args, 1000 do
|
|
147
|
+
local last = math.min(first + 999, #args)
|
|
148
|
+
local reply
|
|
149
|
+
if key then
|
|
150
|
+
reply = redis.call(command, key, unpack(args, first, last))
|
|
151
|
+
else
|
|
152
|
+
reply = redis.call(command, unpack(args, first, last))
|
|
153
|
+
end
|
|
154
|
+
if type(reply) == 'table' then
|
|
155
|
+
for _, value in ipairs(reply) do results[#results + 1] = value end
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
return results
|
|
159
|
+
end
|
|
160
|
+
local function read_fields(fields, suffixes)
|
|
161
|
+
local groups, order = {}, {}
|
|
162
|
+
for _, field in ipairs(fields) do
|
|
163
|
+
local key = mkey(field)
|
|
164
|
+
local group = groups[key]
|
|
165
|
+
if not group then
|
|
166
|
+
group = { fields = {}, names = {} }
|
|
167
|
+
groups[key] = group
|
|
168
|
+
order[#order + 1] = key
|
|
169
|
+
end
|
|
170
|
+
group.fields[#group.fields + 1] = field
|
|
171
|
+
for _, suffix in ipairs(suffixes) do
|
|
172
|
+
group.names[#group.names + 1] = field .. ':' .. suffix
|
|
173
|
+
end
|
|
174
|
+
end
|
|
175
|
+
local rows = {}
|
|
176
|
+
for _, key in ipairs(order) do
|
|
177
|
+
local group = groups[key]
|
|
178
|
+
local values = sliced('HMGET', key, group.names)
|
|
179
|
+
for position, field in ipairs(group.fields) do
|
|
180
|
+
local row = {}
|
|
181
|
+
for column = 1, #suffixes do
|
|
182
|
+
row[column] = values[(position - 1) * #suffixes + column]
|
|
183
|
+
end
|
|
184
|
+
rows[field] = row
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
return rows
|
|
188
|
+
end
|
|
189
|
+
local writes, write_order = {}, {}
|
|
190
|
+
local function put(key, field, value)
|
|
191
|
+
local fields = writes[key]
|
|
192
|
+
if not fields then
|
|
193
|
+
fields = {}
|
|
194
|
+
writes[key] = fields
|
|
195
|
+
write_order[#write_order + 1] = key
|
|
196
|
+
end
|
|
197
|
+
fields[#fields + 1] = field
|
|
198
|
+
fields[#fields + 1] = value
|
|
199
|
+
end
|
|
200
|
+
local function flush()
|
|
201
|
+
for _, key in ipairs(write_order) do sliced('HSET', key, writes[key]) end
|
|
202
|
+
writes, write_order = {}, {}
|
|
203
|
+
end
|
|
204
|
+
`;
|
|
205
|
+
/**
|
|
206
|
+
* Lane-aware enqueueing for scripts that add events. Including scripts bind
|
|
207
|
+
* PENDING and READY. Lane tails are read once per lane and tracked in
|
|
208
|
+
* memory, so a batch in one lane links its events without re-reading. A
|
|
209
|
+
* lane emptied earlier in the script (`clear_lane`) is known to have no tail,
|
|
210
|
+
* and its pointers are deleted at `flush_queue` only if nothing re-filled it.
|
|
211
|
+
*/
|
|
212
|
+
const ENQUEUE_LUA = `
|
|
213
|
+
local function lane_field(lane, suffix)
|
|
214
|
+
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
215
|
+
end
|
|
216
|
+
local queued, ready_now, lane_tails, lane_order = {}, {}, {}, {}
|
|
217
|
+
local function enqueue(field, lane)
|
|
218
|
+
local score = tonumber(field)
|
|
219
|
+
queued[#queued + 1] = score
|
|
220
|
+
queued[#queued + 1] = field
|
|
221
|
+
if lane == '' then
|
|
222
|
+
ready_now[#ready_now + 1] = score
|
|
223
|
+
ready_now[#ready_now + 1] = field
|
|
224
|
+
return
|
|
225
|
+
end
|
|
226
|
+
put(mkey(field), field .. ':l', lane)
|
|
227
|
+
local tail = lane_tails[lane]
|
|
228
|
+
if tail == nil then
|
|
229
|
+
tail = redis.call('HGET', META, lane_field(lane, 'tail'))
|
|
230
|
+
if tail and tonumber(tail) <= boundary then tail = false end
|
|
231
|
+
lane_order[#lane_order + 1] = lane
|
|
232
|
+
end
|
|
233
|
+
if tail then
|
|
234
|
+
put(mkey(tail), tail .. ':next', field)
|
|
235
|
+
else
|
|
236
|
+
put(META, lane_field(lane, 'head'), field)
|
|
237
|
+
ready_now[#ready_now + 1] = score
|
|
238
|
+
ready_now[#ready_now + 1] = field
|
|
239
|
+
end
|
|
240
|
+
lane_tails[lane] = field
|
|
241
|
+
end
|
|
242
|
+
local function clear_lane(lane)
|
|
243
|
+
if lane_tails[lane] == nil then lane_order[#lane_order + 1] = lane end
|
|
244
|
+
lane_tails[lane] = false
|
|
245
|
+
end
|
|
246
|
+
local function flush_queue()
|
|
247
|
+
for _, lane in ipairs(lane_order) do
|
|
248
|
+
if lane_tails[lane] then
|
|
249
|
+
put(META, lane_field(lane, 'tail'), lane_tails[lane])
|
|
250
|
+
else
|
|
251
|
+
redis.call('HDEL', META, lane_field(lane, 'head'), lane_field(lane, 'tail'))
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
sliced('ZADD', PENDING, queued)
|
|
255
|
+
sliced('ZADD', READY, ready_now)
|
|
256
|
+
end
|
|
257
|
+
`;
|
|
258
|
+
/**
|
|
259
|
+
* Event-id ownership for scripts that add events. Each script reads the
|
|
260
|
+
* counter, split, clear boundary, and per-id keys with one MGET and applies
|
|
261
|
+
* the first three. Returns the owner record, or false, per event, and the
|
|
262
|
+
* counter. A record this session made below its latest clear is free.
|
|
263
|
+
*/
|
|
264
|
+
const ID_OWNERS_LUA = `
|
|
265
|
+
local function read_owners(key_offset, n, session)
|
|
266
|
+
local keys = { COUNTER, SPLIT, CLEAR }
|
|
267
|
+
for i = 1, n do keys[#keys + 1] = KEYS[key_offset + i] end
|
|
268
|
+
local values = sliced('MGET', nil, keys)
|
|
269
|
+
use_split(values[2], values[1], values[3])
|
|
270
|
+
local owners = {}
|
|
271
|
+
for i = 1, n do
|
|
272
|
+
local owner = values[3 + i]
|
|
273
|
+
if owner then
|
|
274
|
+
local rec = cjson.decode(owner)
|
|
275
|
+
if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
|
|
276
|
+
end
|
|
277
|
+
owners[i] = owner
|
|
278
|
+
end
|
|
279
|
+
return owners, values[1]
|
|
280
|
+
end
|
|
281
|
+
`;
|
|
282
|
+
const WAKE_PAYLOAD_LIMIT = 32768;
|
|
283
|
+
/**
|
|
284
|
+
* Wake-up messages carry the events they announce, so a subscribed reader
|
|
285
|
+
* that is caught up applies them without reading the stream:
|
|
286
|
+
* `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
|
|
287
|
+
* are the stored JSON text, inlined verbatim. A batch over
|
|
288
|
+
* WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
|
|
289
|
+
* with XRANGE.
|
|
290
|
+
*/
|
|
291
|
+
const WAKE_LUA = `
|
|
292
|
+
local wake_entries, wake_bytes = {}, 0
|
|
293
|
+
local function wake_add(field, id, typ, payload)
|
|
294
|
+
if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
|
|
295
|
+
wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
|
|
296
|
+
return
|
|
297
|
+
end
|
|
298
|
+
local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
|
|
299
|
+
cjson.encode(typ) .. ',' .. payload .. ']'
|
|
300
|
+
wake_bytes = wake_bytes + #entry + 1
|
|
301
|
+
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
302
|
+
wake_entries[#wake_entries + 1] = entry
|
|
303
|
+
end
|
|
304
|
+
end
|
|
305
|
+
local function wake(channel, frontier, created_at)
|
|
306
|
+
local message = '{"f":' .. frontier .. '}'
|
|
307
|
+
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
308
|
+
message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
|
|
309
|
+
table.concat(wake_entries, ',') .. ']}'
|
|
310
|
+
end
|
|
311
|
+
redis.pcall('PUBLISH', channel, message)
|
|
312
|
+
end
|
|
313
|
+
`;
|
|
314
|
+
const RELEASE_PINS_LUA = `
|
|
315
|
+
local function release_snapshot_pins(pins, counts)
|
|
316
|
+
local refs = redis.call('SMEMBERS', pins)
|
|
317
|
+
if #refs == 0 then return end
|
|
318
|
+
for _, ref in ipairs(refs) do
|
|
319
|
+
local count = redis.call('HINCRBY', counts, ref, -1)
|
|
320
|
+
if count <= 0 then
|
|
321
|
+
redis.call('HDEL', counts, ref)
|
|
322
|
+
local checkpoint = cjson.decode(ref)
|
|
323
|
+
redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
redis.call('DEL', pins)
|
|
327
|
+
end
|
|
328
|
+
`;
|
|
329
|
+
/**
|
|
330
|
+
* KEYS: store stream, counter, metadata, pending, ready, metadata split,
|
|
331
|
+
* then one id ownership key per event.
|
|
332
|
+
* ARGV: sessionId, nowMs, n, then per event:
|
|
333
|
+
* id, type, payloadJson, causeJson, lane, settled; then notify channel and
|
|
334
|
+
* origin index or ''.
|
|
335
|
+
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
336
|
+
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
337
|
+
* ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx] |
|
|
338
|
+
* ['cleared', originIndex].
|
|
339
|
+
*/
|
|
340
|
+
const APPEND_LUA = `
|
|
341
|
+
local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[6], KEYS[4], KEYS[5]
|
|
342
|
+
${META_LUA}
|
|
343
|
+
${ENQUEUE_LUA}
|
|
344
|
+
${ID_OWNERS_LUA}
|
|
345
|
+
${WAKE_LUA}
|
|
346
|
+
local function has_pending()
|
|
347
|
+
return redis.call('ZCARD', PENDING) > 0 and '1' or '0'
|
|
348
|
+
end
|
|
349
|
+
local n = tonumber(ARGV[3])
|
|
350
|
+
if n == 0 then return { 'empty', has_pending() } end
|
|
351
|
+
local owners, count = read_owners(6, n, ARGV[1])
|
|
352
|
+
local base = tonumber(count or '0')
|
|
353
|
+
local dups = {}
|
|
354
|
+
for i = 1, n do
|
|
355
|
+
if owners[i] then
|
|
356
|
+
local rec = cjson.decode(owners[i])
|
|
357
|
+
if rec[1] ~= ARGV[1] then return { 'foreign', ARGV[3 + (i - 1) * 6 + 1] } end
|
|
358
|
+
dups[#dups + 1] = tostring(rec[2])
|
|
359
|
+
end
|
|
360
|
+
end
|
|
361
|
+
if #dups == n then
|
|
362
|
+
local res = { 'dup', has_pending() }
|
|
363
|
+
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
364
|
+
return res
|
|
365
|
+
end
|
|
366
|
+
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
367
|
+
local origin = ARGV[5 + n * 6]
|
|
368
|
+
if origin and origin ~= '' and tonumber(origin) <= boundary then
|
|
369
|
+
return { 'cleared', origin }
|
|
370
|
+
end
|
|
371
|
+
local causes, parents = {}, {}
|
|
372
|
+
for i = 1, n do
|
|
373
|
+
local cause = ARGV[3 + (i - 1) * 6 + 4]
|
|
374
|
+
if cause ~= '' then
|
|
375
|
+
local parsed = cjson.decode(cause)
|
|
376
|
+
local pidx = tonumber(parsed.index)
|
|
377
|
+
causes[#causes + 1] = parsed
|
|
378
|
+
if pidx >= 1 and pidx <= base then parents[#parents + 1] = tostring(pidx) end
|
|
379
|
+
end
|
|
380
|
+
end
|
|
381
|
+
local parent_rows = read_fields(parents, { 'd', 'f', 'p' })
|
|
382
|
+
local parent_pending = false
|
|
383
|
+
for _, parsed in ipairs(causes) do
|
|
384
|
+
local pidx = tonumber(parsed.index)
|
|
385
|
+
if pidx <= boundary then return { 'superseded', tostring(pidx) } end
|
|
386
|
+
if pidx < 1 or pidx > base then
|
|
387
|
+
return { 'no_parent', tostring(parsed.index) }
|
|
388
|
+
end
|
|
389
|
+
local row = parent_rows[tostring(pidx)]
|
|
390
|
+
if tonumber(row[1] or '0') ~= tonumber(parsed.attempt) or row[2] then
|
|
391
|
+
return { 'superseded', tostring(pidx) }
|
|
392
|
+
end
|
|
393
|
+
-- A claimed, unprocessed parent is still in the pending set.
|
|
394
|
+
if not row[3] then parent_pending = true end
|
|
395
|
+
end
|
|
396
|
+
local owner_writes = {}
|
|
397
|
+
for i = 1, n do
|
|
398
|
+
local o = 3 + (i - 1) * 6
|
|
399
|
+
local field = tostring(base + i)
|
|
400
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
401
|
+
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
402
|
+
'created_at', ARGV[2], 'cause', ARGV[o + 4], 'lane', ARGV[o + 5])
|
|
403
|
+
owner_writes[#owner_writes + 1] = KEYS[6 + i]
|
|
404
|
+
owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], base + i })
|
|
405
|
+
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
406
|
+
if ARGV[o + 6] == '1' then
|
|
407
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
408
|
+
else
|
|
409
|
+
enqueue(field, ARGV[o + 5])
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
owner_writes[#owner_writes + 1] = COUNTER
|
|
413
|
+
owner_writes[#owner_writes + 1] = tostring(base + n)
|
|
414
|
+
split_write(owner_writes)
|
|
415
|
+
sliced('MSET', nil, owner_writes)
|
|
416
|
+
flush_queue()
|
|
417
|
+
flush()
|
|
418
|
+
local pending_after = (#queued > 0 or parent_pending) and '1' or has_pending()
|
|
419
|
+
wake(ARGV[4 + n * 6], tostring(base + n), ARGV[2])
|
|
420
|
+
return { 'ok', tostring(base + 1), pending_after }
|
|
421
|
+
`;
|
|
422
|
+
/**
|
|
423
|
+
* KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts,
|
|
424
|
+
* metadata split.
|
|
425
|
+
* ARGV: idx, attempt, error, maxFailures, nowMs.
|
|
426
|
+
*/
|
|
427
|
+
const FAIL_ATTEMPT_LUA = `
|
|
428
|
+
local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[7]
|
|
429
|
+
${META_LUA}
|
|
430
|
+
${RELEASE_PINS_LUA}
|
|
431
|
+
local idx = tonumber(ARGV[1])
|
|
432
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
433
|
+
local total = tonumber(counts[1] or '0')
|
|
434
|
+
use_split(counts[2], counts[1], counts[3])
|
|
435
|
+
if idx <= boundary then return { 'cleared', '0' } end
|
|
436
|
+
if idx < 1 or idx > total then return { 'missing', '0' } end
|
|
437
|
+
local field = ARGV[1]
|
|
438
|
+
local attempt = tonumber(ARGV[2])
|
|
439
|
+
local row = read_fields({ field }, { 'a', 'd', 'p', 'f', 'lfa', 'ch' })[field]
|
|
440
|
+
local failures = tonumber(row[1] or '0')
|
|
441
|
+
if row[3] or tonumber(row[2] or '0') ~= attempt then
|
|
442
|
+
return { 'superseded', tostring(failures) }
|
|
443
|
+
end
|
|
444
|
+
if row[4] then return { 'dead_lettered', tostring(failures) } end
|
|
445
|
+
if row[5] and tonumber(row[5]) == attempt and not row[6] then
|
|
446
|
+
return { 'failed', tostring(failures) }
|
|
447
|
+
end
|
|
448
|
+
if not row[6] then return { 'superseded', tostring(failures) } end
|
|
449
|
+
failures = failures + 1
|
|
450
|
+
local dead = failures >= tonumber(ARGV[4])
|
|
451
|
+
local meta_key = mkey(field)
|
|
452
|
+
put(meta_key, field .. ':a', tostring(failures))
|
|
453
|
+
put(meta_key, field .. ':e', ARGV[3])
|
|
454
|
+
put(meta_key, field .. ':lf', ARGV[5])
|
|
455
|
+
put(meta_key, field .. ':lfa', ARGV[2])
|
|
456
|
+
if dead then put(meta_key, field .. ':f', ARGV[5]) end
|
|
457
|
+
flush()
|
|
458
|
+
redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
|
|
459
|
+
if dead then
|
|
460
|
+
redis.call('ZREM', KEYS[4], field)
|
|
461
|
+
release_snapshot_pins(KEYS[5], KEYS[6])
|
|
462
|
+
return { 'dead_lettered', tostring(failures) }
|
|
463
|
+
end
|
|
464
|
+
return { 'failed', tostring(failures) }
|
|
465
|
+
`;
|
|
466
|
+
/**
|
|
467
|
+
* KEYS: store, counter, meta, pending, ready, metadata split.
|
|
468
|
+
* ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
|
|
469
|
+
* The ready set is a subset of the pending set, so an empty ready set is
|
|
470
|
+
* enough to report 'settled'. A claimed result ends with the clear boundary.
|
|
471
|
+
*/
|
|
472
|
+
const CLAIM_AVAILABLE_LUA = `
|
|
473
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[6]
|
|
474
|
+
${META_LUA}
|
|
475
|
+
local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
|
|
476
|
+
local excluded = {}
|
|
477
|
+
for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
|
|
478
|
+
local candidates = {}
|
|
479
|
+
for _, field in ipairs(ready) do
|
|
480
|
+
if not excluded[field] then candidates[#candidates + 1] = field end
|
|
481
|
+
end
|
|
482
|
+
local rows = read_fields(candidates, { 'ce', 'd', 'fc', 'a', 'e', 'lf', 'lfa' })
|
|
483
|
+
local claimed = {}
|
|
484
|
+
local retry_at = nil
|
|
485
|
+
for _, field in ipairs(candidates) do
|
|
486
|
+
local row = rows[field]
|
|
487
|
+
local expires = tonumber(row[1] or '0')
|
|
488
|
+
if expires > tonumber(ARGV[2]) then
|
|
489
|
+
if not retry_at or expires < retry_at then retry_at = expires end
|
|
490
|
+
else
|
|
491
|
+
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
492
|
+
if #entries == 0 then return { 'missing', field } end
|
|
493
|
+
local attempt = tostring(tonumber(row[2] or '0') + 1)
|
|
494
|
+
local first_claimed = row[3] or ARGV[2]
|
|
495
|
+
local meta_key = mkey(field)
|
|
496
|
+
put(meta_key, field .. ':d', attempt)
|
|
497
|
+
put(meta_key, field .. ':fc', first_claimed)
|
|
498
|
+
put(meta_key, field .. ':lc', ARGV[2])
|
|
499
|
+
put(meta_key, field .. ':ch', ARGV[1])
|
|
500
|
+
put(meta_key, field .. ':ce', ARGV[3])
|
|
501
|
+
claimed[#claimed + 1] = {
|
|
502
|
+
attempt,
|
|
503
|
+
row[4] or '0',
|
|
504
|
+
row[5] or false,
|
|
505
|
+
first_claimed,
|
|
506
|
+
ARGV[2],
|
|
507
|
+
row[6] or false,
|
|
508
|
+
row[7] or false,
|
|
509
|
+
ARGV[1],
|
|
510
|
+
ARGV[3],
|
|
511
|
+
entries[1]
|
|
512
|
+
}
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
flush()
|
|
516
|
+
if #claimed > 0 then
|
|
517
|
+
local result = { 'claimed', tostring(#claimed) }
|
|
518
|
+
for _, claim in ipairs(claimed) do
|
|
519
|
+
for _, value in ipairs(claim) do result[#result + 1] = value end
|
|
520
|
+
end
|
|
521
|
+
result[#result + 1] = tostring(boundary)
|
|
522
|
+
return result
|
|
523
|
+
end
|
|
524
|
+
if retry_at then return { 'busy', tostring(retry_at) } end
|
|
525
|
+
return { 'settled' }
|
|
526
|
+
`;
|
|
527
|
+
/**
|
|
528
|
+
* KEYS: counter, meta, metadata split.
|
|
529
|
+
* ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
|
|
530
|
+
* Returns [renewedCount, ...renewed, ...superseded].
|
|
531
|
+
*/
|
|
532
|
+
const RENEW_CLAIMS_LUA = `
|
|
533
|
+
local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[3]
|
|
534
|
+
${META_LUA}
|
|
535
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
536
|
+
local total = tonumber(counts[1] or '0')
|
|
537
|
+
use_split(counts[2], counts[1], counts[3])
|
|
538
|
+
local claims, fields, cleared = {}, {}, {}
|
|
539
|
+
for i = 1, tonumber(ARGV[4]) do
|
|
540
|
+
local field = ARGV[4 + (i - 1) * 2 + 1]
|
|
541
|
+
local idx = tonumber(field)
|
|
542
|
+
if idx <= boundary then
|
|
543
|
+
cleared[#cleared + 1] = field
|
|
544
|
+
elseif idx >= 1 and idx <= total then
|
|
545
|
+
claims[#claims + 1] = { field = field, attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2]) }
|
|
546
|
+
fields[#fields + 1] = field
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
local rows = read_fields(fields, { 'd', 'ce', 'p', 'f', 'ch' })
|
|
550
|
+
local renewed = {}
|
|
551
|
+
local superseded = {}
|
|
552
|
+
for _, claim in ipairs(claims) do
|
|
553
|
+
local row = rows[claim.field]
|
|
554
|
+
if tonumber(row[1] or '0') > claim.attempt then
|
|
555
|
+
superseded[#superseded + 1] = claim.field
|
|
556
|
+
elseif tonumber(ARGV[3]) > tonumber(ARGV[2]) and
|
|
557
|
+
not row[3] and not row[4] and row[5] == ARGV[1] and
|
|
558
|
+
tonumber(row[2] or '0') > tonumber(ARGV[2]) then
|
|
559
|
+
put(mkey(claim.field), claim.field .. ':ce', ARGV[3])
|
|
560
|
+
renewed[#renewed + 1] = claim.field
|
|
561
|
+
end
|
|
562
|
+
end
|
|
563
|
+
flush()
|
|
564
|
+
for _, field in ipairs(cleared) do superseded[#superseded + 1] = field end
|
|
565
|
+
local result = { tostring(#renewed) }
|
|
566
|
+
for i = 1, #renewed do result[#result + 1] = renewed[i] end
|
|
567
|
+
for i = 1, #superseded do result[#result + 1] = superseded[i] end
|
|
568
|
+
return result
|
|
569
|
+
`;
|
|
570
|
+
/**
|
|
571
|
+
* KEYS: store, counter, meta, pending, ready, event snapshot pins, snapshot
|
|
572
|
+
* pin counts, metadata split, then one id ownership key per child.
|
|
573
|
+
* ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
|
|
574
|
+
* id, type, payloadJson, lane, settled; finally notify channel.
|
|
575
|
+
*/
|
|
576
|
+
const COMPLETE_ATTEMPT_LUA = `
|
|
577
|
+
local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[8], KEYS[4], KEYS[5]
|
|
578
|
+
${META_LUA}
|
|
579
|
+
${ENQUEUE_LUA}
|
|
580
|
+
${ID_OWNERS_LUA}
|
|
581
|
+
${RELEASE_PINS_LUA}
|
|
582
|
+
${WAKE_LUA}
|
|
583
|
+
local function settle(field, lane, next_field)
|
|
584
|
+
redis.call('ZREM', PENDING, field)
|
|
585
|
+
redis.call('ZREM', READY, field)
|
|
586
|
+
if lane == '' then return end
|
|
587
|
+
local head_key = lane_field(lane, 'head')
|
|
588
|
+
if redis.call('HGET', META, head_key) ~= field then return end
|
|
589
|
+
while next_field do
|
|
590
|
+
local row = read_fields({ next_field }, { 'p', 'f', 'next' })[next_field]
|
|
591
|
+
if not row[1] then
|
|
592
|
+
redis.call('HSET', META, head_key, next_field)
|
|
593
|
+
if not row[2] then
|
|
594
|
+
redis.call('ZADD', READY, tonumber(next_field), next_field)
|
|
595
|
+
end
|
|
596
|
+
return
|
|
597
|
+
end
|
|
598
|
+
next_field = row[3]
|
|
599
|
+
end
|
|
600
|
+
clear_lane(lane)
|
|
601
|
+
end
|
|
602
|
+
local parent = ARGV[3]
|
|
603
|
+
local attempt = tonumber(ARGV[4])
|
|
604
|
+
local n = tonumber(ARGV[5])
|
|
605
|
+
local owners, count = read_owners(8, n, ARGV[1])
|
|
606
|
+
local total = tonumber(count or '0')
|
|
607
|
+
if tonumber(parent) <= boundary then return { 'superseded' } end
|
|
608
|
+
if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
|
|
609
|
+
local row = read_fields({ parent }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'lfa', 'l', 'next' })[parent]
|
|
610
|
+
if row[2] then
|
|
611
|
+
if tonumber(row[3] or '0') ~= attempt then return { 'superseded' } end
|
|
612
|
+
if not row[4] then return { 'partial' } end
|
|
613
|
+
local recorded_ids = cjson.decode(row[4])
|
|
614
|
+
if #recorded_ids ~= n then return { 'partial' } end
|
|
615
|
+
for i = 1, n do
|
|
616
|
+
if recorded_ids[i] ~= ARGV[5 + (i - 1) * 5 + 1] then return { 'partial' } end
|
|
617
|
+
end
|
|
618
|
+
local indexes = {}
|
|
619
|
+
for i = 1, n do
|
|
620
|
+
if not owners[i] then return { 'partial' } end
|
|
621
|
+
local rec = cjson.decode(owners[i])
|
|
622
|
+
if rec[1] ~= ARGV[1] then return { 'partial' } end
|
|
623
|
+
local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
|
|
624
|
+
if #entries == 0 then return { 'partial' } end
|
|
625
|
+
local values = entries[1][2]
|
|
626
|
+
local cause = nil
|
|
627
|
+
for j = 1, #values, 2 do
|
|
628
|
+
if values[j] == 'cause' then cause = values[j + 1] break end
|
|
629
|
+
end
|
|
630
|
+
if not cause or cause == '' then return { 'partial' } end
|
|
631
|
+
local decoded = cjson.decode(cause)
|
|
632
|
+
if tonumber(decoded['index']) ~= tonumber(parent) or
|
|
633
|
+
tonumber(decoded['attempt']) ~= attempt then return { 'partial' } end
|
|
634
|
+
indexes[#indexes + 1] = tostring(rec[2])
|
|
635
|
+
end
|
|
636
|
+
local result = { 'duplicate' }
|
|
637
|
+
for _, idx in ipairs(indexes) do result[#result + 1] = idx end
|
|
638
|
+
return result
|
|
639
|
+
end
|
|
640
|
+
if tonumber(row[1] or '0') ~= attempt then return { 'superseded' } end
|
|
641
|
+
if not row[5] or row[6] or tonumber(row[7] or '0') == attempt then
|
|
642
|
+
return { 'superseded' }
|
|
643
|
+
end
|
|
644
|
+
for i = 1, n do
|
|
645
|
+
if owners[i] then return { 'partial' } end
|
|
646
|
+
end
|
|
647
|
+
local returned_ids = {}
|
|
648
|
+
for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
|
|
649
|
+
local returned_json = '[]'
|
|
650
|
+
if n > 0 then returned_json = cjson.encode(returned_ids) end
|
|
651
|
+
local parent_key = mkey(parent)
|
|
652
|
+
put(parent_key, parent .. ':p', ARGV[2])
|
|
653
|
+
put(parent_key, parent .. ':pa', ARGV[4])
|
|
654
|
+
put(parent_key, parent .. ':ri', returned_json)
|
|
655
|
+
redis.call('HDEL', parent_key, parent .. ':ch', parent .. ':ce')
|
|
656
|
+
settle(parent, row[8] or '', row[9])
|
|
657
|
+
release_snapshot_pins(KEYS[6], KEYS[7])
|
|
658
|
+
local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
|
|
659
|
+
local owner_writes = {}
|
|
660
|
+
for i = 1, n do
|
|
661
|
+
local o = 5 + (i - 1) * 5
|
|
662
|
+
local field = tostring(total + i)
|
|
663
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
664
|
+
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
665
|
+
'created_at', ARGV[2], 'cause', cause, 'lane', ARGV[o + 4])
|
|
666
|
+
owner_writes[#owner_writes + 1] = KEYS[8 + i]
|
|
667
|
+
owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], total + i })
|
|
668
|
+
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
669
|
+
if ARGV[o + 5] == '1' then
|
|
670
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
671
|
+
else
|
|
672
|
+
enqueue(field, ARGV[o + 4])
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
if n > 0 then
|
|
676
|
+
owner_writes[#owner_writes + 1] = COUNTER
|
|
677
|
+
owner_writes[#owner_writes + 1] = tostring(total + n)
|
|
678
|
+
split_write(owner_writes)
|
|
679
|
+
sliced('MSET', nil, owner_writes)
|
|
680
|
+
end
|
|
681
|
+
flush_queue()
|
|
682
|
+
flush()
|
|
683
|
+
if n > 0 then wake(ARGV[6 + n * 5], tostring(total + n), ARGV[2]) end
|
|
684
|
+
return { 'ok', tostring(total + 1) }
|
|
685
|
+
`;
|
|
686
|
+
/**
|
|
687
|
+
* KEYS: head, history, event counter, event meta, pin counts, metadata split.
|
|
688
|
+
* ARGV: nowMs, event pin key prefix, write count, then per write:
|
|
689
|
+
* index, snapshot JSON, pin count, pin event indexes; finally the reducer
|
|
690
|
+
* name. Writes from before the latest clear are dropped, and a head from
|
|
691
|
+
* before it is replaced as if absent.
|
|
692
|
+
*/
|
|
693
|
+
const PUT_SNAPSHOTS_LUA = `
|
|
694
|
+
local COUNTER, META, SPLIT = KEYS[3], KEYS[4], KEYS[6]
|
|
695
|
+
${META_LUA}
|
|
696
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
697
|
+
local total = tonumber(counts[1] or '0')
|
|
698
|
+
use_split(counts[2], counts[1], counts[3])
|
|
699
|
+
local pinned = {}
|
|
700
|
+
local offset = 4
|
|
701
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
702
|
+
local pin_count = tonumber(ARGV[offset + 2])
|
|
703
|
+
for p = 1, pin_count do
|
|
704
|
+
local event_index = tonumber(ARGV[offset + 2 + p])
|
|
705
|
+
if event_index > boundary and event_index <= total then
|
|
706
|
+
pinned[#pinned + 1] = tostring(event_index)
|
|
707
|
+
end
|
|
708
|
+
end
|
|
709
|
+
offset = offset + 3 + pin_count
|
|
710
|
+
end
|
|
711
|
+
local status = read_fields(pinned, { 'p', 'f' })
|
|
712
|
+
local snapshots = {}
|
|
713
|
+
offset = 4
|
|
714
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
715
|
+
local index = tonumber(ARGV[offset])
|
|
716
|
+
local snapshot = ARGV[offset + 1]
|
|
717
|
+
local pin_count = tonumber(ARGV[offset + 2])
|
|
718
|
+
local ref = cjson.encode({ KEYS[2], tostring(index) })
|
|
719
|
+
for p = 1, pin_count do
|
|
720
|
+
local event_index = tonumber(ARGV[offset + 2 + p])
|
|
721
|
+
local row = status[tostring(event_index)]
|
|
722
|
+
if index >= boundary and event_index > boundary and event_index <= total and
|
|
723
|
+
not row[1] and not row[2] then
|
|
724
|
+
local pin_key = ARGV[2] .. tostring(event_index)
|
|
725
|
+
if redis.call('SADD', pin_key, ref) == 1 then
|
|
726
|
+
redis.call('HINCRBY', KEYS[5], ref, 1)
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
end
|
|
730
|
+
if index >= boundary then
|
|
731
|
+
snapshots[#snapshots + 1] = { index = index, snapshot = snapshot, ref = ref }
|
|
732
|
+
end
|
|
733
|
+
offset = offset + 3 + pin_count
|
|
734
|
+
end
|
|
735
|
+
if #snapshots == 0 then return 1 end
|
|
736
|
+
|
|
737
|
+
local head = redis.call('GET', KEYS[1])
|
|
738
|
+
local head_index = nil
|
|
739
|
+
local registered = false
|
|
740
|
+
if head then
|
|
741
|
+
local record = cjson.decode(head)
|
|
742
|
+
head_index = tonumber(record['index'])
|
|
743
|
+
registered = record['r'] == 1
|
|
744
|
+
end
|
|
745
|
+
if head_index and head_index < boundary then
|
|
746
|
+
head, head_index, registered = nil, nil, false
|
|
747
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', '(' .. boundary)
|
|
748
|
+
end
|
|
749
|
+
-- Deletion finds each reducer's keys through this set instead of a scan.
|
|
750
|
+
-- Heads written since carry the mark, so a reducer registers once.
|
|
751
|
+
if not registered then
|
|
752
|
+
redis.call('SADD', string.sub(COUNTER, 1, -6) .. 'snap-reducers', ARGV[#ARGV])
|
|
753
|
+
end
|
|
754
|
+
local latest = snapshots[#snapshots]
|
|
755
|
+
local writes_head = not head_index or latest.index >= head_index
|
|
756
|
+
local advances = not head_index or latest.index > head_index
|
|
757
|
+
local final_head_index = advances and latest.index or head_index
|
|
758
|
+
|
|
759
|
+
if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEYS[2], tostring(head_index) })) or '0') > 0 then
|
|
760
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], head_index, head_index)
|
|
761
|
+
redis.call('ZADD', KEYS[2], head_index, head)
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
for _, write in ipairs(snapshots) do
|
|
765
|
+
if write.index < final_head_index then
|
|
766
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
|
|
767
|
+
if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
|
|
768
|
+
redis.call('ZADD', KEYS[2], write.index, write.snapshot)
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
end
|
|
772
|
+
|
|
773
|
+
if writes_head then
|
|
774
|
+
redis.call('SET', KEYS[1], latest.snapshot)
|
|
775
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], latest.index, latest.index)
|
|
776
|
+
end
|
|
777
|
+
return 1
|
|
778
|
+
`;
|
|
779
|
+
/**
|
|
780
|
+
* KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
|
|
781
|
+
* hashField, valueJson ('' deletes), patchFieldJson; finally channel and
|
|
782
|
+
* message prefix. Wire fragments stay JSON text to preserve arrays
|
|
783
|
+
* and numeric precision through Lua. Field-wise LWW by the sender's
|
|
784
|
+
* `at`: a stored entry with a strictly newer stamp survives; ties go
|
|
785
|
+
* to the incoming write (memory-tier parity). Returns the applied
|
|
786
|
+
* 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
|
|
787
|
+
* sound because core sends one uniform ttlMs; the per-entry `e` stamp
|
|
788
|
+
* is the fine bound (the injected storage clock at the applied set
|
|
789
|
+
* plus ttlMs — never the sender stamp), pruned on read against the
|
|
790
|
+
* same clock.
|
|
791
|
+
*/
|
|
792
|
+
const PRESENCE_SET_LUA = `
|
|
793
|
+
local at = tonumber(ARGV[1])
|
|
794
|
+
local applied = {}
|
|
795
|
+
local published = {}
|
|
796
|
+
local n = tonumber(ARGV[3])
|
|
797
|
+
for i = 1, n do
|
|
798
|
+
local field = ARGV[3 + (i - 1) * 3 + 1]
|
|
799
|
+
local value = ARGV[3 + (i - 1) * 3 + 2]
|
|
800
|
+
local existing = redis.call('HGET', KEYS[1], field)
|
|
801
|
+
if not existing or tonumber(cjson.decode(existing)['a']) <= at then
|
|
802
|
+
if value == '' then
|
|
803
|
+
redis.call('HDEL', KEYS[1], field)
|
|
804
|
+
else
|
|
805
|
+
redis.call('HSET', KEYS[1], field, value)
|
|
806
|
+
end
|
|
807
|
+
applied[#applied + 1] = tostring(i)
|
|
808
|
+
published[#published + 1] = ARGV[3 + (i - 1) * 3 + 3]
|
|
809
|
+
end
|
|
810
|
+
end
|
|
811
|
+
if #applied > 0 then
|
|
812
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
813
|
+
redis.pcall('PUBLISH', ARGV[4 + n * 3], ARGV[5 + n * 3] .. table.concat(published, ',') .. '}}')
|
|
814
|
+
end
|
|
815
|
+
return applied
|
|
816
|
+
`;
|
|
817
|
+
/**
|
|
818
|
+
* KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
|
|
819
|
+
* time-travel expiry against a real server). Returns the live
|
|
820
|
+
* [field, value, ...] pairs; entries at or past their fine-bound
|
|
821
|
+
* expiry are HDELed in the same atomic step, so a stale reader can
|
|
822
|
+
* never sweep a concurrently refreshed field.
|
|
823
|
+
*/
|
|
824
|
+
const PRESENCE_READ_LUA = `
|
|
825
|
+
local now = tonumber(ARGV[1])
|
|
826
|
+
local rows = redis.call('HGETALL', KEYS[1])
|
|
827
|
+
local live = {}
|
|
828
|
+
for i = 1, #rows, 2 do
|
|
829
|
+
if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
|
|
830
|
+
redis.call('HDEL', KEYS[1], rows[i])
|
|
831
|
+
else
|
|
832
|
+
live[#live + 1] = rows[i]
|
|
833
|
+
live[#live + 1] = rows[i + 1]
|
|
834
|
+
end
|
|
835
|
+
end
|
|
836
|
+
return live
|
|
837
|
+
`;
|
|
838
|
+
/**
|
|
839
|
+
* KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
|
|
840
|
+
* Returns one atomic multiversion-cache-plus-log-frontier read, with the
|
|
841
|
+
* clear boundary; checkpoints from before it are ignored.
|
|
842
|
+
*/
|
|
843
|
+
const READ_STATE_LUA = `
|
|
844
|
+
local counts = redis.call('MGET', KEYS[3], string.sub(KEYS[3], 1, -6) .. 'clear')
|
|
845
|
+
local boundary = tonumber(counts[2] or '0')
|
|
846
|
+
local head = redis.call('GET', KEYS[1])
|
|
847
|
+
local head_index = head and cjson.decode(head)['index'] or nil
|
|
848
|
+
if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
|
|
849
|
+
return redis.error_reply('invalid snapshot head index')
|
|
850
|
+
end
|
|
851
|
+
if head_index and head_index < boundary then
|
|
852
|
+
head = nil
|
|
853
|
+
head_index = nil
|
|
854
|
+
end
|
|
855
|
+
local cap = ARGV[1]
|
|
856
|
+
local snapshot = nil
|
|
857
|
+
if head_index and (cap == '' or head_index <= tonumber(cap)) then
|
|
858
|
+
snapshot = head
|
|
859
|
+
else
|
|
860
|
+
local upper = cap == '' and '+inf' or cap
|
|
861
|
+
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, boundary, 'LIMIT', 0, 1)
|
|
862
|
+
if #historical > 0 then snapshot = historical[1] end
|
|
863
|
+
end
|
|
864
|
+
return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
|
|
865
|
+
`;
|
|
866
|
+
/**
|
|
867
|
+
* Removes every registered reducer's checkpoints below the latest clear, and
|
|
868
|
+
* the pin counts that name them. Snapshot writes register each reducer in
|
|
869
|
+
* {prefix}:{session}:snap-reducers, so no key scan is needed.
|
|
870
|
+
*/
|
|
871
|
+
const SWEEP_CHECKPOINTS_LUA = `
|
|
872
|
+
local function sweep_checkpoints(session, boundary)
|
|
873
|
+
for _, reducer in ipairs(redis.call('SMEMBERS', session .. 'snap-reducers')) do
|
|
874
|
+
local head_key = session .. 'snap:' .. reducer
|
|
875
|
+
local head = redis.call('GET', head_key)
|
|
876
|
+
if head and tonumber(cjson.decode(head)['index']) < boundary then
|
|
877
|
+
redis.call('DEL', head_key)
|
|
878
|
+
end
|
|
879
|
+
redis.call('ZREMRANGEBYSCORE', session .. 'snap-history:' .. reducer, '-inf', '(' .. boundary)
|
|
880
|
+
end
|
|
881
|
+
local counts_key = session .. 'snapshot-pin-counts'
|
|
882
|
+
local counts = redis.call('HGETALL', counts_key)
|
|
883
|
+
local stale = {}
|
|
884
|
+
for i = 1, #counts, 2 do
|
|
885
|
+
if tonumber(cjson.decode(counts[i])[2]) < boundary then stale[#stale + 1] = counts[i] end
|
|
886
|
+
end
|
|
887
|
+
if #stale > 0 then redis.call('HDEL', counts_key, unpack(stale)) end
|
|
888
|
+
end
|
|
889
|
+
`;
|
|
890
|
+
/**
|
|
891
|
+
* KEYS: store stream, counter, metadata, metadata split, marker id ownership
|
|
892
|
+
* key, pending, ready.
|
|
893
|
+
* ARGV: sessionId, nowMs, ifLastIndex or '', cause index or '', cause
|
|
894
|
+
* attempt, marker id, notify channel.
|
|
895
|
+
* Returns ['ok', cleared, head, boundary] | ['no_parent', idx] |
|
|
896
|
+
* ['superseded', idx] | ['foreign']. The old lifetime's log moves aside in one
|
|
897
|
+
* RENAME, so every read of the log starts at the marker, and its queues are
|
|
898
|
+
* unlinked, so claims and pending checks see only the new lifetime; deletion
|
|
899
|
+
* drains the moved log.
|
|
900
|
+
*/
|
|
901
|
+
const CLEAR_LUA = `
|
|
902
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
|
|
903
|
+
${META_LUA}
|
|
904
|
+
${WAKE_LUA}
|
|
905
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR, KEYS[5])
|
|
906
|
+
local head = tonumber(counts[1] or '0')
|
|
907
|
+
use_split(counts[2], counts[1], counts[3])
|
|
908
|
+
if ARGV[4] ~= '' then
|
|
909
|
+
local field = ARGV[4]
|
|
910
|
+
local pidx = tonumber(field)
|
|
911
|
+
if pidx <= boundary then return { 'superseded', field } end
|
|
912
|
+
if pidx < 1 or pidx > head then return { 'no_parent', field } end
|
|
913
|
+
local row = read_fields({ field }, { 'd', 'f' })[field]
|
|
914
|
+
if tonumber(row[1] or '0') ~= tonumber(ARGV[5]) or row[2] then
|
|
915
|
+
return { 'superseded', field }
|
|
916
|
+
end
|
|
917
|
+
end
|
|
918
|
+
local expected = ARGV[3]
|
|
919
|
+
if expected ~= '' and boundary == tonumber(expected) + 1 then
|
|
920
|
+
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
921
|
+
end
|
|
922
|
+
if expected ~= '' and tonumber(expected) ~= head then
|
|
923
|
+
return { 'ok', '0', tostring(head), tostring(boundary) }
|
|
924
|
+
end
|
|
925
|
+
if head == 0 or boundary == head then
|
|
926
|
+
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
927
|
+
end
|
|
928
|
+
if counts[4] then return { 'foreign' } end
|
|
929
|
+
local field = tostring(head + 1)
|
|
930
|
+
local session = string.sub(COUNTER, 1, -6)
|
|
931
|
+
local moved = session .. 'cleared:' .. field
|
|
932
|
+
redis.call('RENAME', KEYS[1], moved)
|
|
933
|
+
redis.call('UNLINK', KEYS[6], KEYS[7])
|
|
934
|
+
redis.call('SADD', session .. 'cleared', moved)
|
|
935
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
936
|
+
'id', ARGV[6], 'type', '${CLEAR_EVENT_TYPE}', 'payload', 'null',
|
|
937
|
+
'created_at', ARGV[2], 'cause', '', 'lane', '')
|
|
938
|
+
local writes = { KEYS[5], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
|
|
939
|
+
split_write(writes)
|
|
940
|
+
redis.call('MSET', unpack(writes))
|
|
941
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
942
|
+
flush()
|
|
943
|
+
wake_add(field, ARGV[6], '${CLEAR_EVENT_TYPE}', 'null')
|
|
944
|
+
wake(ARGV[7], field, ARGV[2])
|
|
945
|
+
return { 'ok', '1', field, field }
|
|
946
|
+
`;
|
|
947
|
+
/**
|
|
948
|
+
* KEYS: counter. ARGV: page size.
|
|
949
|
+
* The next deletion batch: a moved log and up to a page of its [index, id]
|
|
950
|
+
* pairs, without payloads. Empty moved logs are dropped; with none left it
|
|
951
|
+
* sweeps the old checkpoints and returns nothing, so a retry finishes an
|
|
952
|
+
* interrupted deletion.
|
|
953
|
+
*/
|
|
954
|
+
const RECLAIM_BATCH_LUA = `
|
|
955
|
+
${SWEEP_CHECKPOINTS_LUA}
|
|
956
|
+
local session = string.sub(KEYS[1], 1, -6)
|
|
957
|
+
local boundary = tonumber(redis.call('GET', session .. 'clear') or '0')
|
|
958
|
+
if boundary == 0 then return {} end
|
|
959
|
+
for _, log in ipairs(redis.call('SMEMBERS', session .. 'cleared')) do
|
|
960
|
+
local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
|
|
961
|
+
if #entries > 0 then
|
|
962
|
+
local batch = { log }
|
|
963
|
+
for _, entry in ipairs(entries) do
|
|
964
|
+
local fields = entry[2]
|
|
965
|
+
for i = 1, #fields, 2 do
|
|
966
|
+
if fields[i] == 'id' then
|
|
967
|
+
batch[#batch + 1] = string.match(entry[1], '^(%d+)')
|
|
968
|
+
batch[#batch + 1] = fields[i + 1]
|
|
969
|
+
break
|
|
970
|
+
end
|
|
971
|
+
end
|
|
972
|
+
end
|
|
973
|
+
return batch
|
|
974
|
+
end
|
|
975
|
+
redis.call('DEL', log)
|
|
976
|
+
redis.call('SREM', session .. 'cleared', log)
|
|
977
|
+
end
|
|
978
|
+
sweep_checkpoints(session, boundary)
|
|
979
|
+
return {}
|
|
980
|
+
`;
|
|
981
|
+
/**
|
|
982
|
+
* KEYS: moved log, counter, metadata, metadata split, then one id ownership
|
|
983
|
+
* key per entry.
|
|
984
|
+
* ARGV: sessionId, event pin key prefix, n, then per entry: index, id.
|
|
985
|
+
* Deletes the listed entries below the latest clear and everything that
|
|
986
|
+
* names them. An id record survives when it names another event, so an id
|
|
987
|
+
* reused in the new lifetime keeps its owner. Returns [deleted, done].
|
|
988
|
+
*/
|
|
989
|
+
const RECLAIM_LUA = `
|
|
990
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
|
|
991
|
+
${META_LUA}
|
|
992
|
+
${SWEEP_CHECKPOINTS_LUA}
|
|
993
|
+
local function lane_field(lane, suffix)
|
|
994
|
+
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
995
|
+
end
|
|
996
|
+
local session = string.sub(COUNTER, 1, -6)
|
|
997
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
998
|
+
use_split(counts[2], counts[1], counts[3])
|
|
999
|
+
local fields, id_keys, indexes, stream_ids, pin_keys, deleted = {}, {}, {}, {}, {}, {}
|
|
1000
|
+
-- A concurrent reclaim may list the same batch; only XDEL says who removed it.
|
|
1001
|
+
local removed = 0
|
|
1002
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
1003
|
+
local field = ARGV[3 + (i - 1) * 2 + 1]
|
|
1004
|
+
if tonumber(field) < boundary then
|
|
1005
|
+
fields[#fields + 1] = field
|
|
1006
|
+
id_keys[#fields] = KEYS[4 + i]
|
|
1007
|
+
indexes[#fields] = tonumber(field)
|
|
1008
|
+
stream_ids[#fields] = field .. '-0'
|
|
1009
|
+
pin_keys[#fields] = ARGV[2] .. field
|
|
1010
|
+
deleted[field] = true
|
|
1011
|
+
end
|
|
1012
|
+
end
|
|
1013
|
+
if #fields > 0 then
|
|
1014
|
+
local lanes, seen, pointers = read_fields(fields, { 'l' }), {}, {}
|
|
1015
|
+
for _, field in ipairs(fields) do
|
|
1016
|
+
local lane = lanes[field][1]
|
|
1017
|
+
if lane and lane ~= '' and not seen[lane] then
|
|
1018
|
+
seen[lane] = true
|
|
1019
|
+
pointers[#pointers + 1] = lane_field(lane, 'head')
|
|
1020
|
+
pointers[#pointers + 1] = lane_field(lane, 'tail')
|
|
1021
|
+
end
|
|
1022
|
+
end
|
|
1023
|
+
if #pointers > 0 then
|
|
1024
|
+
local values = redis.call('HMGET', META, unpack(pointers))
|
|
1025
|
+
local stale = {}
|
|
1026
|
+
for i = 1, #pointers do
|
|
1027
|
+
if values[i] and deleted[values[i]] then stale[#stale + 1] = pointers[i] end
|
|
1028
|
+
end
|
|
1029
|
+
if #stale > 0 then redis.call('HDEL', META, unpack(stale)) end
|
|
1030
|
+
end
|
|
1031
|
+
local suffixes = { 'l', 'next', 'p', 'pa', 'ri', 'ch', 'ce', 'f', 'fc', 'lc', 'lf', 'lfa', 'd', 'a', 'e' }
|
|
1032
|
+
local columns, order = {}, {}
|
|
1033
|
+
for _, field in ipairs(fields) do
|
|
1034
|
+
local key = mkey(field)
|
|
1035
|
+
if not columns[key] then
|
|
1036
|
+
columns[key] = {}
|
|
1037
|
+
order[#order + 1] = key
|
|
1038
|
+
end
|
|
1039
|
+
for _, suffix in ipairs(suffixes) do
|
|
1040
|
+
columns[key][#columns[key] + 1] = field .. ':' .. suffix
|
|
1041
|
+
end
|
|
1042
|
+
end
|
|
1043
|
+
for _, key in ipairs(order) do sliced('HDEL', key, columns[key]) end
|
|
1044
|
+
sliced('DEL', nil, pin_keys)
|
|
1045
|
+
local owners, stale_ids = sliced('MGET', nil, id_keys), {}
|
|
1046
|
+
for i = 1, #fields do
|
|
1047
|
+
if owners[i] then
|
|
1048
|
+
local rec = cjson.decode(owners[i])
|
|
1049
|
+
if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
|
|
1050
|
+
stale_ids[#stale_ids + 1] = id_keys[i]
|
|
1051
|
+
end
|
|
1052
|
+
end
|
|
1053
|
+
end
|
|
1054
|
+
if #stale_ids > 0 then sliced('DEL', nil, stale_ids) end
|
|
1055
|
+
removed = redis.call('XDEL', KEYS[1], unpack(stream_ids))
|
|
1056
|
+
end
|
|
1057
|
+
local done = 0
|
|
1058
|
+
if redis.call('XLEN', KEYS[1]) == 0 then
|
|
1059
|
+
redis.call('DEL', KEYS[1])
|
|
1060
|
+
redis.call('SREM', session .. 'cleared', KEYS[1])
|
|
1061
|
+
if redis.call('SCARD', session .. 'cleared') == 0 then
|
|
1062
|
+
sweep_checkpoints(session, boundary)
|
|
1063
|
+
done = 1
|
|
1064
|
+
end
|
|
1065
|
+
end
|
|
1066
|
+
return { removed, done }
|
|
1067
|
+
`;
|
|
1068
|
+
const fieldMap = (fields) => {
|
|
1069
|
+
const map = {};
|
|
1070
|
+
for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
|
|
1071
|
+
return map;
|
|
1072
|
+
};
|
|
1073
|
+
const STORED_EVENT_META_SUFFIXES = [
|
|
1074
|
+
"p",
|
|
1075
|
+
"pa",
|
|
1076
|
+
"ri",
|
|
1077
|
+
"ch",
|
|
1078
|
+
"ce",
|
|
1079
|
+
"f",
|
|
1080
|
+
"fc",
|
|
1081
|
+
"lc",
|
|
1082
|
+
"lf",
|
|
1083
|
+
"lfa",
|
|
1084
|
+
"d",
|
|
1085
|
+
"a",
|
|
1086
|
+
"e"
|
|
1087
|
+
];
|
|
1088
|
+
const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
|
|
1089
|
+
const toEvent = (sessionId, entry) => {
|
|
1090
|
+
const fields = fieldMap(entry[1]);
|
|
1091
|
+
return {
|
|
1092
|
+
id: fields["id"],
|
|
1093
|
+
type: fields["type"],
|
|
1094
|
+
payload: JSON.parse(fields["payload"]),
|
|
1095
|
+
index: entryIndex(entry[0]),
|
|
1096
|
+
sessionId,
|
|
1097
|
+
createdAt: new Date(Number(fields["created_at"]))
|
|
1098
|
+
};
|
|
1099
|
+
};
|
|
1100
|
+
const fieldBytes = (name) => new TextEncoder().encode(name);
|
|
1101
|
+
const ID_FIELD = fieldBytes("id");
|
|
1102
|
+
const TYPE_FIELD = fieldBytes("type");
|
|
1103
|
+
const PAYLOAD_FIELD = fieldBytes("payload");
|
|
1104
|
+
const CREATED_AT_FIELD = fieldBytes("created_at");
|
|
1105
|
+
const utf8 = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
1106
|
+
const isField = (name, field) => {
|
|
1107
|
+
if (name.length !== field.length) return false;
|
|
1108
|
+
for (let at = 0; at < field.length; at++) if (name[at] !== field[at]) return false;
|
|
1109
|
+
return true;
|
|
1110
|
+
};
|
|
1111
|
+
const bytesToSerializedEvent = (sessionId, entry) => {
|
|
1112
|
+
const values = entry[1];
|
|
1113
|
+
let id;
|
|
1114
|
+
let type;
|
|
1115
|
+
let payloadJson;
|
|
1116
|
+
let createdAt;
|
|
1117
|
+
for (let at = 0; at + 1 < values.length; at += 2) {
|
|
1118
|
+
const name = values[at];
|
|
1119
|
+
if (isField(name, ID_FIELD)) id = utf8.decode(values[at + 1]);
|
|
1120
|
+
else if (isField(name, TYPE_FIELD)) type = utf8.decode(values[at + 1]);
|
|
1121
|
+
else if (isField(name, PAYLOAD_FIELD)) payloadJson = utf8.decode(values[at + 1]);
|
|
1122
|
+
else if (isField(name, CREATED_AT_FIELD)) createdAt = utf8.decode(values[at + 1]);
|
|
1123
|
+
}
|
|
1124
|
+
return {
|
|
1125
|
+
id,
|
|
1126
|
+
type,
|
|
1127
|
+
payloadJson,
|
|
1128
|
+
index: entryIndex(utf8.decode(entry[0])),
|
|
1129
|
+
sessionId,
|
|
1130
|
+
createdAtMs: Number(createdAt)
|
|
1131
|
+
};
|
|
1132
|
+
};
|
|
1133
|
+
const toSerializedEvent = (sessionId, entry) => {
|
|
1134
|
+
const fields = fieldMap(entry[1]);
|
|
1135
|
+
return {
|
|
1136
|
+
id: fields["id"],
|
|
1137
|
+
type: fields["type"],
|
|
1138
|
+
payloadJson: fields["payload"],
|
|
1139
|
+
index: entryIndex(entry[0]),
|
|
1140
|
+
sessionId,
|
|
1141
|
+
createdAtMs: Number(fields["created_at"])
|
|
1142
|
+
};
|
|
1143
|
+
};
|
|
1144
|
+
const json = (value) => JSON.stringify(value) ?? "null";
|
|
1145
|
+
const toCause = (raw) => {
|
|
1146
|
+
if (raw === "" || raw === void 0) return null;
|
|
1147
|
+
const value = JSON.parse(raw);
|
|
1148
|
+
if (!Number.isInteger(value.index) || Number(value.index) < 1 || !Number.isInteger(value.attempt) || Number(value.attempt) < 1) throw new TypeError("stored event has an invalid cause");
|
|
1149
|
+
if (value.batchSize !== void 0 && (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)) throw new TypeError("stored event has an invalid cause");
|
|
1150
|
+
return {
|
|
1151
|
+
index: Number(value.index),
|
|
1152
|
+
attempt: Number(value.attempt),
|
|
1153
|
+
...value.batchSize === void 0 ? {} : { batchSize: Number(value.batchSize) }
|
|
1154
|
+
};
|
|
1155
|
+
};
|
|
1156
|
+
const toClaimAvailableResult = (sessionId, reply) => {
|
|
1157
|
+
const outcome = String(reply[0]);
|
|
1158
|
+
if (outcome === "claimed") {
|
|
1159
|
+
const count = Number(reply[1]);
|
|
1160
|
+
const events = [];
|
|
1161
|
+
for (let i = 0; i < count; i++) {
|
|
1162
|
+
const offset = 2 + i * 10;
|
|
1163
|
+
const entry = reply[offset + 9];
|
|
1164
|
+
const event = toEvent(sessionId, entry);
|
|
1165
|
+
const fields = fieldMap(entry[1]);
|
|
1166
|
+
events.push(Object.assign(event, {
|
|
1167
|
+
cause: toCause(fields["cause"]),
|
|
1168
|
+
lane: fields["lane"] === "" ? null : fields["lane"],
|
|
1169
|
+
processedAt: null,
|
|
1170
|
+
processedByAttempt: null,
|
|
1171
|
+
returnedEventIds: null,
|
|
1172
|
+
firstClaimedAt: reply[offset + 3] === null ? null : new Date(Number(reply[offset + 3])),
|
|
1173
|
+
lastClaimedAt: new Date(Number(reply[offset + 4])),
|
|
1174
|
+
attemptCount: Number(reply[offset]),
|
|
1175
|
+
claimHolder: String(reply[offset + 7]),
|
|
1176
|
+
claimExpiresAt: new Date(Number(reply[offset + 8])),
|
|
1177
|
+
failureCount: Number(reply[offset + 1]),
|
|
1178
|
+
lastFailedAt: reply[offset + 5] === null ? null : new Date(Number(reply[offset + 5])),
|
|
1179
|
+
lastFailedAttempt: reply[offset + 6] === null ? null : Number(reply[offset + 6]),
|
|
1180
|
+
lastError: reply[offset + 2] === null ? null : String(reply[offset + 2]),
|
|
1181
|
+
failedAt: null
|
|
1182
|
+
}));
|
|
1183
|
+
}
|
|
1184
|
+
const clearIndex = Number(reply[2 + count * 10] ?? 0);
|
|
1185
|
+
if (clearIndex > 0) for (const event of events) event.clearIndex = clearIndex;
|
|
1186
|
+
return {
|
|
1187
|
+
outcome: "claimed",
|
|
1188
|
+
events
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
if (outcome === "busy") return {
|
|
1192
|
+
outcome,
|
|
1193
|
+
retryAt: new Date(Number(reply[1]))
|
|
1194
|
+
};
|
|
1195
|
+
if (outcome === "settled") return { outcome };
|
|
1196
|
+
throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
|
|
1197
|
+
};
|
|
1198
|
+
const toIndex = ({ raw, sessionId, name }) => {
|
|
1199
|
+
const index = Number(raw ?? 0);
|
|
1200
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new A2Error("STORE_UNAVAILABLE", `redis ${name} for session '${sessionId}' is invalid`);
|
|
1201
|
+
return index;
|
|
1202
|
+
};
|
|
1203
|
+
/**
|
|
1204
|
+
* A range read that met a gap. A clear moves the old lifetime's log aside, so
|
|
1205
|
+
* a read whose bounds predate it finds nothing where it expected events;
|
|
1206
|
+
* anywhere else a gap is corruption.
|
|
1207
|
+
*/
|
|
1208
|
+
const rangeGap = (sessionId, message) => Object.assign(new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ${message}`), { rangeGap: true });
|
|
1209
|
+
const isRangeGap = (error) => error instanceof A2Error && error.rangeGap === true;
|
|
1210
|
+
/**
|
|
1211
|
+
* Run a whole read, bounds included. A gap means a clear raced it, so the
|
|
1212
|
+
* read runs once more from fresh bounds. A second gap, from another clear or
|
|
1213
|
+
* from corruption, surfaces as the retryable STORE_UNAVAILABLE.
|
|
1214
|
+
*/
|
|
1215
|
+
const retryAfterGap = async (read) => {
|
|
1216
|
+
try {
|
|
1217
|
+
return await read();
|
|
1218
|
+
} catch (error) {
|
|
1219
|
+
if (!isRangeGap(error)) throw error;
|
|
1220
|
+
return read();
|
|
1221
|
+
}
|
|
1222
|
+
};
|
|
1223
|
+
const wrap = async (fn) => {
|
|
1224
|
+
try {
|
|
1225
|
+
return await fn();
|
|
1226
|
+
} catch (err) {
|
|
1227
|
+
if (err instanceof A2Error || err instanceof TypeError) throw err;
|
|
1228
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
1229
|
+
}
|
|
1230
|
+
};
|
|
1231
|
+
function createRedisStoreCore(options) {
|
|
1232
|
+
const { call, callBytes, clock } = options;
|
|
1233
|
+
const generateId = options.ids;
|
|
1234
|
+
const prefix = options.keyPrefix;
|
|
1235
|
+
const storeKey = (s) => `${prefix}:${s}:log`;
|
|
1236
|
+
const countKey = (s) => `${prefix}:${s}:count`;
|
|
1237
|
+
const metaKey = (s) => `${prefix}:${s}:meta`;
|
|
1238
|
+
const metaChunkKey = (s, index) => `${metaKey(s)}:${Math.floor((index - 1) / META_CHUNK_SIZE)}`;
|
|
1239
|
+
const metaSplitKey = (s) => `${prefix}:${s}:meta-split`;
|
|
1240
|
+
const pendingKey = (s) => `${prefix}:${s}:pending`;
|
|
1241
|
+
const readyKey = (s) => `${prefix}:${s}:ready`;
|
|
1242
|
+
const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
|
|
1243
|
+
const snapHistoryKey = (s, r) => `${prefix}:${s}:snap-history:${r}`;
|
|
1244
|
+
const snapshotEventPinsKey = (s, index) => `${prefix}:${s}:snapshot-pins:${index}`;
|
|
1245
|
+
const snapshotEventPinsPrefix = (s) => `${prefix}:${s}:snapshot-pins:`;
|
|
1246
|
+
const snapshotPinCountsKey = (s) => `${prefix}:${s}:snapshot-pin-counts`;
|
|
1247
|
+
const presenceKey = (s) => `${prefix}:${s}:presence`;
|
|
1248
|
+
const clearKey = (s) => `${prefix}:${s}:clear`;
|
|
1249
|
+
const idKey = (id) => `${prefix}:ids:${JSON.stringify(id).replaceAll(":", "\\u003a")}`;
|
|
1250
|
+
const evalScript = (script, keys, args) => call("EVAL", script, keys.length, ...keys, ...args);
|
|
1251
|
+
const readRangeBounds = async (sessionId) => {
|
|
1252
|
+
const [count, split, cleared] = await call("MGET", countKey(sessionId), metaSplitKey(sessionId), clearKey(sessionId));
|
|
1253
|
+
const frontier = toIndex({
|
|
1254
|
+
raw: count,
|
|
1255
|
+
sessionId,
|
|
1256
|
+
name: "event count"
|
|
1257
|
+
});
|
|
1258
|
+
return {
|
|
1259
|
+
frontier,
|
|
1260
|
+
metaSplit: split === null ? frontier : toIndex({
|
|
1261
|
+
raw: split,
|
|
1262
|
+
sessionId,
|
|
1263
|
+
name: "metadata split"
|
|
1264
|
+
}),
|
|
1265
|
+
clearIndex: toIndex({
|
|
1266
|
+
raw: cleared,
|
|
1267
|
+
sessionId,
|
|
1268
|
+
name: "clear boundary"
|
|
1269
|
+
})
|
|
1270
|
+
};
|
|
1271
|
+
};
|
|
1272
|
+
const readRangePage = async ({ sessionId, afterIndex, throughIndex, metaSplit }) => {
|
|
1273
|
+
if (throughIndex <= afterIndex) return [];
|
|
1274
|
+
const entries = await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, `${throughIndex}-0`, "COUNT", 256);
|
|
1275
|
+
if (entries.length === 0) return [];
|
|
1276
|
+
const fieldsByKey = /* @__PURE__ */ new Map();
|
|
1277
|
+
for (const entry of entries) {
|
|
1278
|
+
const index = entryIndex(entry[0]);
|
|
1279
|
+
const key = index <= metaSplit ? metaKey(sessionId) : metaChunkKey(sessionId, index);
|
|
1280
|
+
const fields = fieldsByKey.get(key) ?? [];
|
|
1281
|
+
fields.push(...STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`));
|
|
1282
|
+
fieldsByKey.set(key, fields);
|
|
1283
|
+
}
|
|
1284
|
+
const lookups = [...fieldsByKey];
|
|
1285
|
+
const results = await Promise.allSettled(lookups.map(([key, fields]) => call("HMGET", key, ...fields)));
|
|
1286
|
+
const meta = {};
|
|
1287
|
+
for (let position = 0; position < lookups.length; position += 1) {
|
|
1288
|
+
const result = results[position];
|
|
1289
|
+
if (result.status === "rejected") throw result.reason;
|
|
1290
|
+
const fields = lookups[position][1];
|
|
1291
|
+
const values = result.value ?? [];
|
|
1292
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
1293
|
+
const value = values[i];
|
|
1294
|
+
if (value !== null && value !== void 0) meta[fields[i]] = value;
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
return entries.map((entry) => {
|
|
1298
|
+
const event = toEvent(sessionId, entry);
|
|
1299
|
+
const idx = event.index;
|
|
1300
|
+
const fields = fieldMap(entry[1]);
|
|
1301
|
+
const processedAt = meta[`${idx}:p`];
|
|
1302
|
+
const processedByAttempt = meta[`${idx}:pa`];
|
|
1303
|
+
const returnedEventIds = meta[`${idx}:ri`];
|
|
1304
|
+
const claimHolder = meta[`${idx}:ch`];
|
|
1305
|
+
const claimExpiresAt = meta[`${idx}:ce`];
|
|
1306
|
+
const failedAt = meta[`${idx}:f`];
|
|
1307
|
+
const firstClaimedAt = meta[`${idx}:fc`];
|
|
1308
|
+
const lastClaimedAt = meta[`${idx}:lc`];
|
|
1309
|
+
const lastFailedAt = meta[`${idx}:lf`];
|
|
1310
|
+
const lastFailedAttempt = meta[`${idx}:lfa`];
|
|
1311
|
+
return Object.assign(event, {
|
|
1312
|
+
cause: toCause(fields["cause"]),
|
|
1313
|
+
lane: fields["lane"] === "" ? null : fields["lane"],
|
|
1314
|
+
processedAt: processedAt === void 0 ? null : new Date(Number(processedAt)),
|
|
1315
|
+
processedByAttempt: processedByAttempt === void 0 ? null : Number(processedByAttempt),
|
|
1316
|
+
returnedEventIds: returnedEventIds === void 0 ? null : decodeReturnedEventIds(returnedEventIds),
|
|
1317
|
+
firstClaimedAt: firstClaimedAt === void 0 ? null : new Date(Number(firstClaimedAt)),
|
|
1318
|
+
lastClaimedAt: lastClaimedAt === void 0 ? null : new Date(Number(lastClaimedAt)),
|
|
1319
|
+
attemptCount: Number(meta[`${idx}:d`] ?? 0),
|
|
1320
|
+
claimHolder: claimHolder ?? null,
|
|
1321
|
+
claimExpiresAt: claimExpiresAt === void 0 ? null : new Date(Number(claimExpiresAt)),
|
|
1322
|
+
failureCount: Number(meta[`${idx}:a`] ?? 0),
|
|
1323
|
+
lastFailedAt: lastFailedAt === void 0 ? null : new Date(Number(lastFailedAt)),
|
|
1324
|
+
lastFailedAttempt: lastFailedAttempt === void 0 ? null : Number(lastFailedAttempt),
|
|
1325
|
+
lastError: meta[`${idx}:e`] ?? null,
|
|
1326
|
+
failedAt: failedAt === void 0 ? null : new Date(Number(failedAt))
|
|
1327
|
+
});
|
|
1328
|
+
});
|
|
1329
|
+
};
|
|
1330
|
+
const readEventPages = async (sessionId, afterIndex, throughIndex) => {
|
|
1331
|
+
if (throughIndex <= afterIndex) return [];
|
|
1332
|
+
const events = [];
|
|
1333
|
+
let nextIndex = afterIndex + 1;
|
|
1334
|
+
while (nextIndex <= throughIndex) {
|
|
1335
|
+
const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${throughIndex}-0`, "COUNT", 256);
|
|
1336
|
+
if (entries.length === 0) throw rangeGap(sessionId, `ended before index ${throughIndex}`);
|
|
1337
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
1338
|
+
const index = entryIndex(entries[position][0]);
|
|
1339
|
+
if (index !== nextIndex + position || index > throughIndex) throw rangeGap(sessionId, "is not contiguous");
|
|
1340
|
+
}
|
|
1341
|
+
events.push(...entries.map((entry) => toEvent(sessionId, entry)));
|
|
1342
|
+
nextIndex = events.at(-1).index + 1;
|
|
1343
|
+
}
|
|
1344
|
+
return events;
|
|
1345
|
+
};
|
|
1346
|
+
const readEventRange = async (sessionId, afterIndex, throughIndex) => {
|
|
1347
|
+
const events = [];
|
|
1348
|
+
for (let start = afterIndex; start < throughIndex; start += 256 * READ_CONCURRENCY) {
|
|
1349
|
+
const ranges = Array.from({ length: Math.min(READ_CONCURRENCY, Math.ceil((throughIndex - start) / 256)) }, (_, position) => {
|
|
1350
|
+
const after = start + position * 256;
|
|
1351
|
+
return readEventPages(sessionId, after, Math.min(after + 256, throughIndex));
|
|
1352
|
+
});
|
|
1353
|
+
const results = await Promise.allSettled(ranges);
|
|
1354
|
+
for (const result of results) {
|
|
1355
|
+
if (result.status === "rejected") throw result.reason;
|
|
1356
|
+
events.push(...result.value);
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
return events;
|
|
1360
|
+
};
|
|
1361
|
+
/** Bounded XRANGE pages restore the full-read contract on capped providers. */
|
|
1362
|
+
const readRange = async (sessionId, afterIndex, throughIndex) => (await readBoundedRange(sessionId, afterIndex, throughIndex)).rows;
|
|
1363
|
+
/** `readRange`, with the clear boundary its rows start at. */
|
|
1364
|
+
const readBoundedRange = async (sessionId, afterIndex, throughIndex) => {
|
|
1365
|
+
if (throughIndex !== void 0 && throughIndex <= afterIndex) return {
|
|
1366
|
+
rows: [],
|
|
1367
|
+
clearIndex: 0
|
|
1368
|
+
};
|
|
1369
|
+
const { frontier: currentFrontier, metaSplit, clearIndex } = await readRangeBounds(sessionId);
|
|
1370
|
+
const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier);
|
|
1371
|
+
const after = Math.max(afterIndex, clearIndex - 1);
|
|
1372
|
+
if (frontier <= after) return {
|
|
1373
|
+
rows: [],
|
|
1374
|
+
clearIndex
|
|
1375
|
+
};
|
|
1376
|
+
const rows = [];
|
|
1377
|
+
let nextIndex = after + 1;
|
|
1378
|
+
while (nextIndex <= frontier) {
|
|
1379
|
+
const page = await readRangePage({
|
|
1380
|
+
sessionId,
|
|
1381
|
+
afterIndex: nextIndex - 1,
|
|
1382
|
+
throughIndex: frontier,
|
|
1383
|
+
metaSplit
|
|
1384
|
+
});
|
|
1385
|
+
if (page.length === 0) throw rangeGap(sessionId, `ended before index ${frontier}`);
|
|
1386
|
+
for (let position = 0; position < page.length; position += 1) if (page[position].index !== nextIndex + position) throw rangeGap(sessionId, "is not contiguous");
|
|
1387
|
+
const lastIndex = page.at(-1).index;
|
|
1388
|
+
if (lastIndex < nextIndex || lastIndex > frontier) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' did not advance`);
|
|
1389
|
+
rows.push(...page);
|
|
1390
|
+
nextIndex = lastIndex + 1;
|
|
1391
|
+
}
|
|
1392
|
+
return {
|
|
1393
|
+
rows,
|
|
1394
|
+
clearIndex
|
|
1395
|
+
};
|
|
1396
|
+
};
|
|
1397
|
+
const readStream = options.readStream;
|
|
1398
|
+
return {
|
|
1399
|
+
...readStream ? { streamEvents: async function* ({ sessionId, afterIndex, signal }) {
|
|
1400
|
+
try {
|
|
1401
|
+
for await (const entries of readStream({
|
|
1402
|
+
key: storeKey(sessionId),
|
|
1403
|
+
from: `${afterIndex + 1}-0`,
|
|
1404
|
+
signal
|
|
1405
|
+
})) {
|
|
1406
|
+
signal.throwIfAborted();
|
|
1407
|
+
yield entries.map((entry) => toSerializedEvent(sessionId, entry));
|
|
1408
|
+
}
|
|
1409
|
+
} catch (cause) {
|
|
1410
|
+
if (cause instanceof A2Error) throw cause;
|
|
1411
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis stream read failed", { cause });
|
|
1412
|
+
}
|
|
1413
|
+
} } : {},
|
|
1414
|
+
async append(sessionId, events, appendOptions) {
|
|
1415
|
+
return wrap(async () => {
|
|
1416
|
+
const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
|
|
1417
|
+
if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
1418
|
+
const now = clock.now().getTime();
|
|
1419
|
+
const withIds = events.map((e) => ({
|
|
1420
|
+
id: e.id ?? generateId(),
|
|
1421
|
+
type: e.type,
|
|
1422
|
+
payload: e.payload,
|
|
1423
|
+
cause: e.cause ? { ...e.cause } : null,
|
|
1424
|
+
lane: e.lane ?? null,
|
|
1425
|
+
settled: e.settled === true
|
|
1426
|
+
}));
|
|
1427
|
+
const args = [
|
|
1428
|
+
sessionId,
|
|
1429
|
+
now,
|
|
1430
|
+
withIds.length,
|
|
1431
|
+
...withIds.flatMap((e) => [
|
|
1432
|
+
e.id,
|
|
1433
|
+
e.type,
|
|
1434
|
+
json(e.payload),
|
|
1435
|
+
e.cause ? json(e.cause) : "",
|
|
1436
|
+
e.lane ?? "",
|
|
1437
|
+
e.settled ? "1" : "0"
|
|
1438
|
+
]),
|
|
1439
|
+
`${prefix}:${sessionId}:notify`,
|
|
1440
|
+
appendOptions?.originIndex ?? ""
|
|
1441
|
+
];
|
|
1442
|
+
const reply = await evalScript(APPEND_LUA, [
|
|
1443
|
+
storeKey(sessionId),
|
|
1444
|
+
countKey(sessionId),
|
|
1445
|
+
metaKey(sessionId),
|
|
1446
|
+
pendingKey(sessionId),
|
|
1447
|
+
readyKey(sessionId),
|
|
1448
|
+
metaSplitKey(sessionId),
|
|
1449
|
+
...withIds.map((e) => idKey(e.id))
|
|
1450
|
+
], args);
|
|
1451
|
+
const [verdict] = reply;
|
|
1452
|
+
if (verdict === "empty") return {
|
|
1453
|
+
events: [],
|
|
1454
|
+
hasPending: reply[1] === "1"
|
|
1455
|
+
};
|
|
1456
|
+
if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
|
|
1457
|
+
if (verdict === "no_parent") throw new TypeError(`no event at index ${reply[1]} in session '${sessionId}'`);
|
|
1458
|
+
if (verdict === "cleared") throw sessionClearedError(`scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`);
|
|
1459
|
+
if (verdict === "superseded") throw new A2Error("SUPERSEDED_ATTEMPT", `a newer attempt owns event ${reply[1]} in session '${sessionId}'`);
|
|
1460
|
+
if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
|
|
1461
|
+
if (verdict === "dup") {
|
|
1462
|
+
const hasPending = reply[1] === "1";
|
|
1463
|
+
const firstIndex = Math.min(...reply.slice(2).map(Number));
|
|
1464
|
+
const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
|
|
1465
|
+
if (firstIndex < clearIndex) throw new A2Error("STORE_UNAVAILABLE", `session '${sessionId}' was cleared while an append retry replayed; retry it`);
|
|
1466
|
+
return {
|
|
1467
|
+
events: idempotentReplay(events, existing),
|
|
1468
|
+
hasPending
|
|
1469
|
+
};
|
|
1470
|
+
}
|
|
1471
|
+
const base = Number(reply[1]) - 1;
|
|
1472
|
+
return {
|
|
1473
|
+
events: withIds.map((e, i) => ({
|
|
1474
|
+
id: e.id,
|
|
1475
|
+
type: e.type,
|
|
1476
|
+
payload: structuredClone(e.payload),
|
|
1477
|
+
index: base + 1 + i,
|
|
1478
|
+
sessionId,
|
|
1479
|
+
createdAt: new Date(now),
|
|
1480
|
+
cause: e.cause,
|
|
1481
|
+
lane: e.lane,
|
|
1482
|
+
processedAt: e.settled ? new Date(now) : null,
|
|
1483
|
+
processedByAttempt: null,
|
|
1484
|
+
returnedEventIds: null,
|
|
1485
|
+
firstClaimedAt: null,
|
|
1486
|
+
lastClaimedAt: null,
|
|
1487
|
+
attemptCount: 0,
|
|
1488
|
+
claimHolder: null,
|
|
1489
|
+
claimExpiresAt: null,
|
|
1490
|
+
failureCount: 0,
|
|
1491
|
+
lastFailedAt: null,
|
|
1492
|
+
lastFailedAttempt: null,
|
|
1493
|
+
lastError: null,
|
|
1494
|
+
failedAt: null
|
|
1495
|
+
})),
|
|
1496
|
+
hasPending: reply[2] === "1"
|
|
1497
|
+
};
|
|
1498
|
+
});
|
|
1499
|
+
},
|
|
1500
|
+
async read(sessionId, opts) {
|
|
1501
|
+
return wrap(() => retryAfterGap(() => readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex)));
|
|
1502
|
+
},
|
|
1503
|
+
async readHistory(sessionId, opts) {
|
|
1504
|
+
const after = opts?.afterIndex ?? 0;
|
|
1505
|
+
if (opts?.throughIndex !== void 0 && opts.throughIndex <= after) return [];
|
|
1506
|
+
return wrap(() => retryAfterGap(async () => {
|
|
1507
|
+
const [count, cleared] = await call("MGET", countKey(sessionId), clearKey(sessionId));
|
|
1508
|
+
const frontier = toIndex({
|
|
1509
|
+
raw: count,
|
|
1510
|
+
sessionId,
|
|
1511
|
+
name: "event count"
|
|
1512
|
+
});
|
|
1513
|
+
const clearIndex = toIndex({
|
|
1514
|
+
raw: cleared,
|
|
1515
|
+
sessionId,
|
|
1516
|
+
name: "clear boundary"
|
|
1517
|
+
});
|
|
1518
|
+
return readEventRange(sessionId, Math.max(after, clearIndex - 1), Math.min(opts?.throughIndex ?? frontier, frontier));
|
|
1519
|
+
}));
|
|
1520
|
+
},
|
|
1521
|
+
async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, excludeIndexes }) {
|
|
1522
|
+
return wrap(async () => {
|
|
1523
|
+
const now = clock.now().getTime();
|
|
1524
|
+
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
1525
|
+
const excluded = excludeIndexes ?? [];
|
|
1526
|
+
const reply = await evalScript(CLAIM_AVAILABLE_LUA, [
|
|
1527
|
+
storeKey(sessionId),
|
|
1528
|
+
countKey(sessionId),
|
|
1529
|
+
metaKey(sessionId),
|
|
1530
|
+
pendingKey(sessionId),
|
|
1531
|
+
readyKey(sessionId),
|
|
1532
|
+
metaSplitKey(sessionId)
|
|
1533
|
+
], [
|
|
1534
|
+
holder,
|
|
1535
|
+
now,
|
|
1536
|
+
expiresAt,
|
|
1537
|
+
excluded.length,
|
|
1538
|
+
...excluded
|
|
1539
|
+
]);
|
|
1540
|
+
return toClaimAvailableResult(sessionId, reply);
|
|
1541
|
+
});
|
|
1542
|
+
},
|
|
1543
|
+
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
1544
|
+
return wrap(async () => {
|
|
1545
|
+
const now = clock.now().getTime();
|
|
1546
|
+
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
1547
|
+
const reply = await evalScript(RENEW_CLAIMS_LUA, [
|
|
1548
|
+
countKey(sessionId),
|
|
1549
|
+
metaKey(sessionId),
|
|
1550
|
+
metaSplitKey(sessionId)
|
|
1551
|
+
], [
|
|
1552
|
+
holder,
|
|
1553
|
+
now,
|
|
1554
|
+
expiresAt,
|
|
1555
|
+
claims.length,
|
|
1556
|
+
...claims.flatMap((claim) => [claim.index, claim.attempt])
|
|
1557
|
+
]);
|
|
1558
|
+
const renewedCount = Number(reply[0]);
|
|
1559
|
+
const values = reply.slice(1).map(Number);
|
|
1560
|
+
return {
|
|
1561
|
+
renewed: values.slice(0, renewedCount).toSorted((a, b) => a - b),
|
|
1562
|
+
superseded: values.slice(renewedCount).toSorted((a, b) => a - b)
|
|
1563
|
+
};
|
|
1564
|
+
});
|
|
1565
|
+
},
|
|
1566
|
+
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
1567
|
+
return wrap(async () => {
|
|
1568
|
+
const ids = events.map((event) => event.id);
|
|
1569
|
+
if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
1570
|
+
const now = clock.now().getTime();
|
|
1571
|
+
const reply = await evalScript(COMPLETE_ATTEMPT_LUA, [
|
|
1572
|
+
storeKey(sessionId),
|
|
1573
|
+
countKey(sessionId),
|
|
1574
|
+
metaKey(sessionId),
|
|
1575
|
+
pendingKey(sessionId),
|
|
1576
|
+
readyKey(sessionId),
|
|
1577
|
+
snapshotEventPinsKey(sessionId, index),
|
|
1578
|
+
snapshotPinCountsKey(sessionId),
|
|
1579
|
+
metaSplitKey(sessionId),
|
|
1580
|
+
...ids.map((id) => idKey(id))
|
|
1581
|
+
], [
|
|
1582
|
+
sessionId,
|
|
1583
|
+
now,
|
|
1584
|
+
index,
|
|
1585
|
+
attempt,
|
|
1586
|
+
events.length,
|
|
1587
|
+
...events.flatMap((event) => [
|
|
1588
|
+
event.id,
|
|
1589
|
+
event.type,
|
|
1590
|
+
json(event.payload),
|
|
1591
|
+
event.lane ?? "",
|
|
1592
|
+
event.settled === true ? "1" : "0"
|
|
1593
|
+
]),
|
|
1594
|
+
`${prefix}:${sessionId}:notify`
|
|
1595
|
+
]);
|
|
1596
|
+
const outcome = reply[0];
|
|
1597
|
+
if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
1598
|
+
if (outcome === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event ids do not match the completed attempt");
|
|
1599
|
+
if (outcome === "superseded") return { outcome };
|
|
1600
|
+
const cause = {
|
|
1601
|
+
index,
|
|
1602
|
+
attempt
|
|
1603
|
+
};
|
|
1604
|
+
if (outcome === "ok") {
|
|
1605
|
+
const base = Number(reply[1]) - 1;
|
|
1606
|
+
return {
|
|
1607
|
+
outcome: "completed",
|
|
1608
|
+
events: events.map((event, i) => ({
|
|
1609
|
+
id: event.id,
|
|
1610
|
+
type: event.type,
|
|
1611
|
+
payload: structuredClone(event.payload),
|
|
1612
|
+
index: base + 1 + i,
|
|
1613
|
+
sessionId,
|
|
1614
|
+
createdAt: new Date(now),
|
|
1615
|
+
cause,
|
|
1616
|
+
lane: event.lane ?? null,
|
|
1617
|
+
processedAt: event.settled === true ? new Date(now) : null,
|
|
1618
|
+
processedByAttempt: null,
|
|
1619
|
+
returnedEventIds: null,
|
|
1620
|
+
firstClaimedAt: null,
|
|
1621
|
+
lastClaimedAt: null,
|
|
1622
|
+
attemptCount: 0,
|
|
1623
|
+
claimHolder: null,
|
|
1624
|
+
claimExpiresAt: null,
|
|
1625
|
+
failureCount: 0,
|
|
1626
|
+
lastFailedAt: null,
|
|
1627
|
+
lastFailedAttempt: null,
|
|
1628
|
+
lastError: null,
|
|
1629
|
+
failedAt: null
|
|
1630
|
+
}))
|
|
1631
|
+
};
|
|
1632
|
+
}
|
|
1633
|
+
if (outcome === "duplicate") {
|
|
1634
|
+
if (events.length === 0) return {
|
|
1635
|
+
outcome: "completed",
|
|
1636
|
+
events: []
|
|
1637
|
+
};
|
|
1638
|
+
const firstIndex = Math.min(...reply.slice(1).map(Number));
|
|
1639
|
+
const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
|
|
1640
|
+
if (index <= clearIndex) return { outcome: "superseded" };
|
|
1641
|
+
return {
|
|
1642
|
+
outcome: "completed",
|
|
1643
|
+
events: idempotentReplay(events, existing)
|
|
1644
|
+
};
|
|
1645
|
+
}
|
|
1646
|
+
throw new TypeError(`unexpected completion outcome '${outcome}'`);
|
|
1647
|
+
});
|
|
1648
|
+
},
|
|
1649
|
+
async failAttempt({ sessionId, index, attempt, error, maxFailures }) {
|
|
1650
|
+
return wrap(async () => {
|
|
1651
|
+
const reply = await evalScript(FAIL_ATTEMPT_LUA, [
|
|
1652
|
+
countKey(sessionId),
|
|
1653
|
+
metaKey(sessionId),
|
|
1654
|
+
pendingKey(sessionId),
|
|
1655
|
+
readyKey(sessionId),
|
|
1656
|
+
snapshotEventPinsKey(sessionId, index),
|
|
1657
|
+
snapshotPinCountsKey(sessionId),
|
|
1658
|
+
metaSplitKey(sessionId)
|
|
1659
|
+
], [
|
|
1660
|
+
index,
|
|
1661
|
+
attempt,
|
|
1662
|
+
error,
|
|
1663
|
+
maxFailures,
|
|
1664
|
+
clock.now().getTime()
|
|
1665
|
+
]);
|
|
1666
|
+
const outcome = reply[0];
|
|
1667
|
+
if (outcome === "cleared") return {
|
|
1668
|
+
outcome: "superseded",
|
|
1669
|
+
failureCount: 0
|
|
1670
|
+
};
|
|
1671
|
+
if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
1672
|
+
return {
|
|
1673
|
+
outcome,
|
|
1674
|
+
failureCount: Number(reply[1])
|
|
1675
|
+
};
|
|
1676
|
+
});
|
|
1677
|
+
},
|
|
1678
|
+
async readState(sessionId, reducerName, stateOptions) {
|
|
1679
|
+
return wrap(() => retryAfterGap(async () => {
|
|
1680
|
+
const reply = await evalScript(READ_STATE_LUA, [
|
|
1681
|
+
snapKey(sessionId, reducerName),
|
|
1682
|
+
snapHistoryKey(sessionId, reducerName),
|
|
1683
|
+
countKey(sessionId)
|
|
1684
|
+
], [stateOptions?.snapshotThroughIndex ?? stateOptions?.throughIndex ?? ""]);
|
|
1685
|
+
const rawHead = reply[0];
|
|
1686
|
+
const raw = reply[1];
|
|
1687
|
+
const headIndex = rawHead === "" ? null : Number(rawHead);
|
|
1688
|
+
const parsed = raw === "" ? null : JSON.parse(raw);
|
|
1689
|
+
const currentFrontier = Number(reply[2]);
|
|
1690
|
+
if (!Number.isSafeInteger(currentFrontier) || currentFrontier < 0 || headIndex !== null && (!Number.isSafeInteger(headIndex) || headIndex < 0) || parsed !== null && (!Number.isSafeInteger(parsed.index) || parsed.index < 0)) throw new A2Error("STORE_UNAVAILABLE", `redis state frontier for session '${sessionId}' is invalid`);
|
|
1691
|
+
const frontier = Math.min(currentFrontier, stateOptions?.throughIndex ?? currentFrontier);
|
|
1692
|
+
const snapshot = parsed;
|
|
1693
|
+
const clearIndex = Number(reply[3] ?? 0);
|
|
1694
|
+
const events = await readEventRange(sessionId, Math.max(snapshot?.index ?? 0, clearIndex - 1), frontier);
|
|
1695
|
+
return {
|
|
1696
|
+
...clearIndex > 0 ? { clearIndex } : {},
|
|
1697
|
+
headIndex,
|
|
1698
|
+
snapshot: snapshot ? {
|
|
1699
|
+
index: snapshot.index,
|
|
1700
|
+
state: snapshot.state
|
|
1701
|
+
} : null,
|
|
1702
|
+
events
|
|
1703
|
+
};
|
|
1704
|
+
}));
|
|
1705
|
+
},
|
|
1706
|
+
async putSnapshots(sessionId, reducerName, writes) {
|
|
1707
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
1708
|
+
for (const write of writes) {
|
|
1709
|
+
const existing = byIndex.get(write.index);
|
|
1710
|
+
if (existing) {
|
|
1711
|
+
existing.state = write.state;
|
|
1712
|
+
for (const eventIndex of write.pinEventIndexes ?? []) existing.pinEventIndexes.add(eventIndex);
|
|
1713
|
+
} else byIndex.set(write.index, {
|
|
1714
|
+
index: write.index,
|
|
1715
|
+
state: write.state,
|
|
1716
|
+
pinEventIndexes: new Set(write.pinEventIndexes ?? [])
|
|
1717
|
+
});
|
|
1718
|
+
}
|
|
1719
|
+
const normalized = [...byIndex.values()].toSorted((a, b) => a.index - b.index);
|
|
1720
|
+
if (normalized.length === 0) return;
|
|
1721
|
+
await wrap(async () => {
|
|
1722
|
+
const args = [
|
|
1723
|
+
clock.now().getTime(),
|
|
1724
|
+
snapshotEventPinsPrefix(sessionId),
|
|
1725
|
+
normalized.length
|
|
1726
|
+
];
|
|
1727
|
+
for (const write of normalized) args.push(write.index, json({
|
|
1728
|
+
index: write.index,
|
|
1729
|
+
state: write.state,
|
|
1730
|
+
updatedAt: clock.now().getTime(),
|
|
1731
|
+
r: 1
|
|
1732
|
+
}), write.pinEventIndexes.size, ...write.pinEventIndexes);
|
|
1733
|
+
await evalScript(PUT_SNAPSHOTS_LUA, [
|
|
1734
|
+
snapKey(sessionId, reducerName),
|
|
1735
|
+
snapHistoryKey(sessionId, reducerName),
|
|
1736
|
+
countKey(sessionId),
|
|
1737
|
+
metaKey(sessionId),
|
|
1738
|
+
snapshotPinCountsKey(sessionId),
|
|
1739
|
+
metaSplitKey(sessionId)
|
|
1740
|
+
], [...args, reducerName]);
|
|
1741
|
+
});
|
|
1742
|
+
},
|
|
1743
|
+
presence: {
|
|
1744
|
+
async set(ns, participant, values, meta) {
|
|
1745
|
+
return wrap(async () => {
|
|
1746
|
+
const entries = Object.entries(values);
|
|
1747
|
+
if (entries.length === 0) return null;
|
|
1748
|
+
const atMs = meta.at.getTime();
|
|
1749
|
+
const expiresAtMs = clock.now().getTime() + meta.ttlMs;
|
|
1750
|
+
const applied = await evalScript(PRESENCE_SET_LUA, [presenceKey(ns)], [
|
|
1751
|
+
atMs,
|
|
1752
|
+
meta.ttlMs,
|
|
1753
|
+
entries.length,
|
|
1754
|
+
...entries.flatMap(([field, value]) => [
|
|
1755
|
+
JSON.stringify([participant, field]),
|
|
1756
|
+
value === null ? "" : json({
|
|
1757
|
+
v: value,
|
|
1758
|
+
s: meta.seen,
|
|
1759
|
+
a: atMs,
|
|
1760
|
+
e: expiresAtMs
|
|
1761
|
+
}),
|
|
1762
|
+
`${json(field)}:${json(value)}`
|
|
1763
|
+
]),
|
|
1764
|
+
presenceKey(ns),
|
|
1765
|
+
`${json({
|
|
1766
|
+
src: options.presenceSource,
|
|
1767
|
+
participant,
|
|
1768
|
+
seen: meta.seen,
|
|
1769
|
+
at: atMs
|
|
1770
|
+
}).slice(0, -1)},"values":{`
|
|
1771
|
+
]);
|
|
1772
|
+
if (applied.length === 0) return null;
|
|
1773
|
+
const appliedValues = nullProtoRecord();
|
|
1774
|
+
for (const position of applied) {
|
|
1775
|
+
const [field, value] = entries[Number(position) - 1];
|
|
1776
|
+
appliedValues[field] = structuredClone(value);
|
|
1777
|
+
}
|
|
1778
|
+
return {
|
|
1779
|
+
participant,
|
|
1780
|
+
values: appliedValues,
|
|
1781
|
+
seen: meta.seen,
|
|
1782
|
+
at: new Date(atMs)
|
|
1783
|
+
};
|
|
1784
|
+
});
|
|
1785
|
+
},
|
|
1786
|
+
async read(ns) {
|
|
1787
|
+
return wrap(async () => {
|
|
1788
|
+
const reply = await evalScript(PRESENCE_READ_LUA, [presenceKey(ns)], [clock.now().getTime()]);
|
|
1789
|
+
const rows = [];
|
|
1790
|
+
for (let i = 0; i + 1 < reply.length; i += 2) {
|
|
1791
|
+
const [participant, field] = JSON.parse(reply[i]);
|
|
1792
|
+
const entry = JSON.parse(reply[i + 1]);
|
|
1793
|
+
rows.push({
|
|
1794
|
+
participant,
|
|
1795
|
+
field,
|
|
1796
|
+
value: entry.v,
|
|
1797
|
+
seen: entry.s,
|
|
1798
|
+
at: new Date(entry.a),
|
|
1799
|
+
expiresAt: new Date(entry.e)
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
return rows;
|
|
1803
|
+
});
|
|
1804
|
+
}
|
|
1805
|
+
},
|
|
1806
|
+
async readRange(sessionId, afterIndex, throughIndex) {
|
|
1807
|
+
return wrap(() => readRange(sessionId, afterIndex, throughIndex));
|
|
1808
|
+
},
|
|
1809
|
+
async readTail(sessionId, fromIndex, throughIndex) {
|
|
1810
|
+
return wrap(async () => {
|
|
1811
|
+
const page = (await call("XREVRANGE", storeKey(sessionId), throughIndex === void 0 ? "+" : `${throughIndex}-0`, `${fromIndex}-0`, "COUNT", 256)).toReversed().map((entry) => toEvent(sessionId, entry));
|
|
1812
|
+
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`);
|
|
1813
|
+
const oldest = page[0]?.index;
|
|
1814
|
+
return oldest === void 0 || oldest <= fromIndex ? page : null;
|
|
1815
|
+
});
|
|
1816
|
+
},
|
|
1817
|
+
async clear({ sessionId, ifLastIndex, cause }) {
|
|
1818
|
+
return wrap(async () => {
|
|
1819
|
+
const markerId = generateId();
|
|
1820
|
+
const reply = await evalScript(CLEAR_LUA, [
|
|
1821
|
+
storeKey(sessionId),
|
|
1822
|
+
countKey(sessionId),
|
|
1823
|
+
metaKey(sessionId),
|
|
1824
|
+
metaSplitKey(sessionId),
|
|
1825
|
+
idKey(markerId),
|
|
1826
|
+
pendingKey(sessionId),
|
|
1827
|
+
readyKey(sessionId)
|
|
1828
|
+
], [
|
|
1829
|
+
sessionId,
|
|
1830
|
+
clock.now().getTime(),
|
|
1831
|
+
ifLastIndex ?? "",
|
|
1832
|
+
cause?.index ?? "",
|
|
1833
|
+
cause?.attempt ?? "",
|
|
1834
|
+
markerId,
|
|
1835
|
+
`${prefix}:${sessionId}:notify`
|
|
1836
|
+
]);
|
|
1837
|
+
if (reply[0] === "superseded") throw supersededAttemptError(sessionId, cause);
|
|
1838
|
+
if (reply[0] === "no_parent") throw new TypeError(`no event at index ${reply[1]} in session '${sessionId}'`);
|
|
1839
|
+
if (reply[0] === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `the clear marker id '${markerId}' already exists`);
|
|
1840
|
+
return {
|
|
1841
|
+
cleared: reply[1] === "1",
|
|
1842
|
+
index: Number(reply[2]),
|
|
1843
|
+
clearIndex: Number(reply[3])
|
|
1844
|
+
};
|
|
1845
|
+
});
|
|
1846
|
+
},
|
|
1847
|
+
async reclaim(sessionId) {
|
|
1848
|
+
return wrap(async () => {
|
|
1849
|
+
const [log, ...pairs] = await evalScript(RECLAIM_BATCH_LUA, [countKey(sessionId)], [256]);
|
|
1850
|
+
if (log === void 0) return {
|
|
1851
|
+
deleted: 0,
|
|
1852
|
+
done: true
|
|
1853
|
+
};
|
|
1854
|
+
const ids = [];
|
|
1855
|
+
for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]);
|
|
1856
|
+
const [deleted, done] = await evalScript(RECLAIM_LUA, [
|
|
1857
|
+
log,
|
|
1858
|
+
countKey(sessionId),
|
|
1859
|
+
metaKey(sessionId),
|
|
1860
|
+
metaSplitKey(sessionId),
|
|
1861
|
+
...ids.map((id) => idKey(id))
|
|
1862
|
+
], [
|
|
1863
|
+
sessionId,
|
|
1864
|
+
snapshotEventPinsPrefix(sessionId),
|
|
1865
|
+
ids.length,
|
|
1866
|
+
...pairs
|
|
1867
|
+
]);
|
|
1868
|
+
return {
|
|
1869
|
+
deleted: Number(deleted),
|
|
1870
|
+
done: Number(done) === 1
|
|
1871
|
+
};
|
|
1872
|
+
});
|
|
1873
|
+
},
|
|
1874
|
+
async readEvents(sessionId, afterIndex) {
|
|
1875
|
+
return wrap(async () => {
|
|
1876
|
+
return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+", "COUNT", 256)).map((entry) => toEvent(sessionId, entry));
|
|
1877
|
+
});
|
|
1878
|
+
},
|
|
1879
|
+
async readFeedPage(sessionId, afterIndex) {
|
|
1880
|
+
return wrap(async () => {
|
|
1881
|
+
const range = [
|
|
1882
|
+
storeKey(sessionId),
|
|
1883
|
+
`${afterIndex + 1}-0`,
|
|
1884
|
+
"+",
|
|
1885
|
+
"COUNT",
|
|
1886
|
+
FEED_PAGE_SIZE
|
|
1887
|
+
];
|
|
1888
|
+
if (callBytes) return (await callBytes("XRANGE", ...range)).map((entry) => bytesToSerializedEvent(sessionId, entry));
|
|
1889
|
+
return (await call("XRANGE", ...range)).map((entry) => toSerializedEvent(sessionId, entry));
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
}
|
|
1894
|
+
//#endregion
|
|
1895
|
+
//#region src/store-redis-notify.ts
|
|
1896
|
+
/** A feed that falls this far behind drops its inbox and catches up. */
|
|
1897
|
+
const INBOX_BYTES = 524288;
|
|
1898
|
+
/**
|
|
1899
|
+
* Parses a wake-up message: `{"f":frontier}` or a bare frontier number
|
|
1900
|
+
* announces events without carrying them; `{"f","t","e"}` carries them.
|
|
1901
|
+
* Returns null for anything else, which callers treat as a hint to catch up.
|
|
1902
|
+
*/
|
|
1903
|
+
const parseWake = (message) => {
|
|
1904
|
+
let parsed;
|
|
1905
|
+
try {
|
|
1906
|
+
parsed = JSON.parse(message);
|
|
1907
|
+
} catch {
|
|
1908
|
+
return null;
|
|
1909
|
+
}
|
|
1910
|
+
if (typeof parsed === "number") return Number.isSafeInteger(parsed) ? {
|
|
1911
|
+
frontier: parsed,
|
|
1912
|
+
createdAt: 0,
|
|
1913
|
+
entries: []
|
|
1914
|
+
} : null;
|
|
1915
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
1916
|
+
const { f, t, e } = parsed;
|
|
1917
|
+
if (typeof f !== "number" || !Number.isSafeInteger(f)) return null;
|
|
1918
|
+
if (e === void 0) return {
|
|
1919
|
+
frontier: f,
|
|
1920
|
+
createdAt: 0,
|
|
1921
|
+
entries: []
|
|
1922
|
+
};
|
|
1923
|
+
if (!Array.isArray(e) || typeof t !== "number" || !Number.isFinite(t)) return null;
|
|
1924
|
+
for (const entry of e) if (!Array.isArray(entry) || entry.length !== 4 || !Number.isSafeInteger(entry[0]) || typeof entry[1] !== "string" || typeof entry[2] !== "string") return null;
|
|
1925
|
+
return {
|
|
1926
|
+
frontier: f,
|
|
1927
|
+
createdAt: t,
|
|
1928
|
+
entries: e
|
|
1929
|
+
};
|
|
1930
|
+
};
|
|
1931
|
+
function createRedisNotifyStore(options) {
|
|
1932
|
+
const { client, clock, ids, keyPrefix: prefix } = options;
|
|
1933
|
+
const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
|
|
1934
|
+
const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
|
|
1935
|
+
const instanceId = crypto.randomUUID();
|
|
1936
|
+
const core = createRedisStoreCore({
|
|
1937
|
+
call: async (command, ...args) => {
|
|
1938
|
+
return (await client()).call(command, ...args);
|
|
1939
|
+
},
|
|
1940
|
+
clock,
|
|
1941
|
+
ids,
|
|
1942
|
+
keyPrefix: prefix,
|
|
1943
|
+
presenceSource: instanceId,
|
|
1944
|
+
...options.callBytes ? { callBytes: options.callBytes } : {},
|
|
1945
|
+
...options.readStream ? { readStream: options.readStream } : {}
|
|
1946
|
+
});
|
|
1947
|
+
let severed = false;
|
|
1948
|
+
let subscriber = null;
|
|
1949
|
+
/** channel → live event feeds, registered before they subscribe. */
|
|
1950
|
+
const feeds = /* @__PURE__ */ new Map();
|
|
1951
|
+
/** channel → refcounted subscription held for each live iterator. */
|
|
1952
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
1953
|
+
const presenceSubscriptions = /* @__PURE__ */ new Map();
|
|
1954
|
+
const pollingStreams = /* @__PURE__ */ new Set();
|
|
1955
|
+
options.onReconnect?.((channel) => {
|
|
1956
|
+
for (const feed of feeds.get(channel) ?? []) {
|
|
1957
|
+
feed.dirty = true;
|
|
1958
|
+
feed.wake?.();
|
|
1959
|
+
}
|
|
1960
|
+
presenceSubscriptions.get(channel)?.recovery?.reconnect().catch(() => {});
|
|
1961
|
+
});
|
|
1962
|
+
const deliverRemotePresence = ({ subscription, message }) => {
|
|
1963
|
+
let parsed;
|
|
1964
|
+
try {
|
|
1965
|
+
parsed = JSON.parse(message);
|
|
1966
|
+
} catch {
|
|
1967
|
+
return;
|
|
1968
|
+
}
|
|
1969
|
+
if (parsed.src === instanceId) return;
|
|
1970
|
+
const patch = {
|
|
1971
|
+
participant: parsed.participant,
|
|
1972
|
+
values: parsed.values,
|
|
1973
|
+
seen: parsed.seen,
|
|
1974
|
+
at: new Date(parsed.at)
|
|
1975
|
+
};
|
|
1976
|
+
subscription.recovery?.update(patch);
|
|
1977
|
+
for (const listener of subscription.listeners) listener(patch);
|
|
1978
|
+
};
|
|
1979
|
+
const getSubscriber = () => {
|
|
1980
|
+
subscriber ??= (async () => {
|
|
1981
|
+
const conn = (await client()).duplicate();
|
|
1982
|
+
conn.on("message", (channel, message) => {
|
|
1983
|
+
for (const feed of feeds.get(channel) ?? []) {
|
|
1984
|
+
if (feed.bytes + message.length <= INBOX_BYTES) {
|
|
1985
|
+
feed.inbox.push(message);
|
|
1986
|
+
feed.bytes += message.length;
|
|
1987
|
+
} else {
|
|
1988
|
+
feed.inbox.length = 0;
|
|
1989
|
+
feed.bytes = 0;
|
|
1990
|
+
feed.dirty = true;
|
|
1991
|
+
}
|
|
1992
|
+
feed.wake?.();
|
|
1993
|
+
}
|
|
1994
|
+
const subscription = presenceSubscriptions.get(channel);
|
|
1995
|
+
if (subscription) deliverRemotePresence({
|
|
1996
|
+
subscription,
|
|
1997
|
+
message
|
|
1998
|
+
});
|
|
1999
|
+
});
|
|
2000
|
+
return conn;
|
|
2001
|
+
})();
|
|
2002
|
+
return subscriber;
|
|
2003
|
+
};
|
|
2004
|
+
/**
|
|
2005
|
+
* The subscriber, gated on connection readiness — for channel
|
|
2006
|
+
* commands only (`close` uses the ungated promise, so it can never
|
|
2007
|
+
* hang on an unreachable server). SUBSCRIBE carries redis's
|
|
2008
|
+
* ok-loading flag, so ioredis writes it mid-handshake, ahead of the
|
|
2009
|
+
* ready check's INFO — which then finds the connection already in
|
|
2010
|
+
* subscriber mode and fails the check (a spurious error event plus
|
|
2011
|
+
* a reconnect). ECHO has no such flag: it resolves only once the
|
|
2012
|
+
* connection is ready, so every channel command chained behind it
|
|
2013
|
+
* lands after the check.
|
|
2014
|
+
*/
|
|
2015
|
+
let subscriberGate = null;
|
|
2016
|
+
const gatedSubscriber = () => {
|
|
2017
|
+
subscriberGate ??= getSubscriber().then(async (conn) => {
|
|
2018
|
+
await conn.call("echo", "ready");
|
|
2019
|
+
return conn;
|
|
2020
|
+
});
|
|
2021
|
+
return subscriberGate;
|
|
2022
|
+
};
|
|
2023
|
+
const acquireChannel = async (channel) => {
|
|
2024
|
+
const existing = subscriptions.get(channel);
|
|
2025
|
+
if (existing) {
|
|
2026
|
+
existing.refs += 1;
|
|
2027
|
+
try {
|
|
2028
|
+
await existing.ready;
|
|
2029
|
+
} catch (err) {
|
|
2030
|
+
existing.refs -= 1;
|
|
2031
|
+
throw err;
|
|
2032
|
+
}
|
|
2033
|
+
return;
|
|
2034
|
+
}
|
|
2035
|
+
const lease = {
|
|
2036
|
+
refs: 1,
|
|
2037
|
+
ready: gatedSubscriber().then(async (conn) => {
|
|
2038
|
+
await conn.call("subscribe", channel);
|
|
2039
|
+
if (subscriptions.get(channel) !== lease || severed) return;
|
|
2040
|
+
const presence = presenceSubscriptions.get(channel);
|
|
2041
|
+
if (options.polling?.() && presence) {
|
|
2042
|
+
presence.recovery = void 0;
|
|
2043
|
+
const ns = channel.slice(prefix.length + 1, -9);
|
|
2044
|
+
presence.poll = pollPresence({
|
|
2045
|
+
read: () => core.presence.read(ns),
|
|
2046
|
+
deliver: (patch) => {
|
|
2047
|
+
for (const listener of presence.listeners) listener(patch);
|
|
2048
|
+
}
|
|
2049
|
+
});
|
|
2050
|
+
await presence.poll.ready;
|
|
2051
|
+
}
|
|
2052
|
+
})
|
|
2053
|
+
};
|
|
2054
|
+
subscriptions.set(channel, lease);
|
|
2055
|
+
try {
|
|
2056
|
+
await lease.ready;
|
|
2057
|
+
} catch (err) {
|
|
2058
|
+
if (subscriptions.get(channel) === lease) {
|
|
2059
|
+
subscriptions.delete(channel);
|
|
2060
|
+
const presence = presenceSubscriptions.get(channel);
|
|
2061
|
+
presence?.poll?.stop();
|
|
2062
|
+
if (presence) presence.poll = void 0;
|
|
2063
|
+
}
|
|
2064
|
+
throw err;
|
|
2065
|
+
}
|
|
2066
|
+
};
|
|
2067
|
+
const releaseChannel = (channel) => {
|
|
2068
|
+
const lease = subscriptions.get(channel);
|
|
2069
|
+
if (!lease) return;
|
|
2070
|
+
lease.refs -= 1;
|
|
2071
|
+
if (lease.refs > 0) return;
|
|
2072
|
+
subscriptions.delete(channel);
|
|
2073
|
+
gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
|
|
2074
|
+
};
|
|
2075
|
+
const store = {
|
|
2076
|
+
append: core.append,
|
|
2077
|
+
read: core.read,
|
|
2078
|
+
readHistory: core.readHistory,
|
|
2079
|
+
claimAvailable: core.claimAvailable,
|
|
2080
|
+
renewClaims: core.renewClaims,
|
|
2081
|
+
completeAttempt: core.completeAttempt,
|
|
2082
|
+
failAttempt: core.failAttempt,
|
|
2083
|
+
readState: core.readState,
|
|
2084
|
+
putSnapshots: core.putSnapshots,
|
|
2085
|
+
clear: core.clear,
|
|
2086
|
+
reclaim: core.reclaim,
|
|
2087
|
+
presence: {
|
|
2088
|
+
async set(ns, participant, values, meta) {
|
|
2089
|
+
const patch = await core.presence.set(ns, participant, values, meta);
|
|
2090
|
+
if (!patch) return;
|
|
2091
|
+
const channel = presenceChannel(ns);
|
|
2092
|
+
const subscription = presenceSubscriptions.get(channel);
|
|
2093
|
+
if (subscription) {
|
|
2094
|
+
subscription.poll?.update(patch);
|
|
2095
|
+
subscription.recovery?.update(patch);
|
|
2096
|
+
for (const listener of subscription.listeners) listener(patch);
|
|
2097
|
+
}
|
|
2098
|
+
},
|
|
2099
|
+
async read(ns) {
|
|
2100
|
+
const channel = presenceChannel(ns);
|
|
2101
|
+
await subscriptions.get(channel)?.ready;
|
|
2102
|
+
return presenceSubscriptions.get(channel)?.recovery?.read() ?? core.presence.read(ns);
|
|
2103
|
+
},
|
|
2104
|
+
subscribe(ns, onPatch) {
|
|
2105
|
+
const channel = presenceChannel(ns);
|
|
2106
|
+
let subscription = presenceSubscriptions.get(channel);
|
|
2107
|
+
if (!subscription) {
|
|
2108
|
+
subscription = { listeners: /* @__PURE__ */ new Set() };
|
|
2109
|
+
presenceSubscriptions.set(channel, subscription);
|
|
2110
|
+
if (options.onReconnect && !options.polling?.()) {
|
|
2111
|
+
const current = subscription;
|
|
2112
|
+
subscription.recovery = presenceRecovery({
|
|
2113
|
+
read: () => core.presence.read(ns),
|
|
2114
|
+
deliver: (patch) => {
|
|
2115
|
+
for (const listener of current.listeners) listener(patch);
|
|
2116
|
+
},
|
|
2117
|
+
isActive: () => !severed && presenceSubscriptions.get(channel) === current
|
|
2118
|
+
});
|
|
2119
|
+
}
|
|
2120
|
+
}
|
|
2121
|
+
subscription.listeners.add(onPatch);
|
|
2122
|
+
acquireChannel(channel).catch(() => {});
|
|
2123
|
+
let stopped = false;
|
|
2124
|
+
return () => {
|
|
2125
|
+
if (stopped) return;
|
|
2126
|
+
stopped = true;
|
|
2127
|
+
subscription.listeners.delete(onPatch);
|
|
2128
|
+
if (subscription.listeners.size === 0) {
|
|
2129
|
+
subscription.poll?.stop();
|
|
2130
|
+
presenceSubscriptions.delete(channel);
|
|
2131
|
+
}
|
|
2132
|
+
releaseChannel(channel);
|
|
2133
|
+
};
|
|
2134
|
+
}
|
|
2135
|
+
},
|
|
2136
|
+
stream(sessionId, opts) {
|
|
2137
|
+
const startAfter = opts?.startAfter ?? 0;
|
|
2138
|
+
const channel = notifyChannel(sessionId);
|
|
2139
|
+
return { [Symbol.asyncIterator]() {
|
|
2140
|
+
let last = startAfter;
|
|
2141
|
+
let buffer = [];
|
|
2142
|
+
let closed = false;
|
|
2143
|
+
let subscribed = false;
|
|
2144
|
+
let pending = null;
|
|
2145
|
+
let poller;
|
|
2146
|
+
let stopActivity;
|
|
2147
|
+
let catchup;
|
|
2148
|
+
let stoppingRead;
|
|
2149
|
+
const stopRead = () => {
|
|
2150
|
+
const reading = catchup;
|
|
2151
|
+
catchup = void 0;
|
|
2152
|
+
if (reading) {
|
|
2153
|
+
reading.controller.abort();
|
|
2154
|
+
stoppingRead = reading.iterator.return(void 0).then(() => void 0).catch(() => {});
|
|
2155
|
+
}
|
|
2156
|
+
return stoppingRead ?? Promise.resolve();
|
|
2157
|
+
};
|
|
2158
|
+
const nextRead = async (after) => {
|
|
2159
|
+
if (!catchup) {
|
|
2160
|
+
const controller = new AbortController();
|
|
2161
|
+
catchup = {
|
|
2162
|
+
controller,
|
|
2163
|
+
delivered: false,
|
|
2164
|
+
iterator: core.streamEvents({
|
|
2165
|
+
sessionId,
|
|
2166
|
+
afterIndex: after,
|
|
2167
|
+
signal: controller.signal
|
|
2168
|
+
})[Symbol.asyncIterator]()
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
const reading = catchup;
|
|
2172
|
+
const result = await reading.iterator.next();
|
|
2173
|
+
if (result.done && catchup === reading) {
|
|
2174
|
+
catchup = void 0;
|
|
2175
|
+
if (reading.delivered) feed.dirty = true;
|
|
2176
|
+
} else if (!result.done) reading.delivered = true;
|
|
2177
|
+
return result;
|
|
2178
|
+
};
|
|
2179
|
+
const feed = {
|
|
2180
|
+
inbox: [],
|
|
2181
|
+
bytes: 0,
|
|
2182
|
+
dirty: true,
|
|
2183
|
+
wake: null,
|
|
2184
|
+
stopRead
|
|
2185
|
+
};
|
|
2186
|
+
const activity = createStreamActivity(() => {
|
|
2187
|
+
if (poller) streamActivities.get(poller)?.activate();
|
|
2188
|
+
});
|
|
2189
|
+
const release = () => {
|
|
2190
|
+
stopRead();
|
|
2191
|
+
stopActivity?.();
|
|
2192
|
+
if (poller) {
|
|
2193
|
+
poller.return?.();
|
|
2194
|
+
pollingStreams.delete(poller);
|
|
2195
|
+
}
|
|
2196
|
+
if (!subscribed) return;
|
|
2197
|
+
subscribed = false;
|
|
2198
|
+
const set = feeds.get(channel);
|
|
2199
|
+
set?.delete(feed);
|
|
2200
|
+
if (set?.size === 0) feeds.delete(channel);
|
|
2201
|
+
releaseChannel(channel);
|
|
2202
|
+
};
|
|
2203
|
+
/**
|
|
2204
|
+
* Moves carried events that continue this feed's sequence into
|
|
2205
|
+
* the buffer. A gap, a message without events, or an unreadable
|
|
2206
|
+
* message marks the feed dirty so it catches up with XRANGE.
|
|
2207
|
+
*/
|
|
2208
|
+
const applyInbox = () => {
|
|
2209
|
+
feed.bytes = 0;
|
|
2210
|
+
for (const message of feed.inbox.splice(0)) {
|
|
2211
|
+
const wake = parseWake(message);
|
|
2212
|
+
if (!wake) {
|
|
2213
|
+
feed.dirty = true;
|
|
2214
|
+
continue;
|
|
2215
|
+
}
|
|
2216
|
+
for (const [index, id, type, payload] of wake.entries) {
|
|
2217
|
+
const expected = (buffer.at(-1)?.index ?? last) + 1;
|
|
2218
|
+
if (index < expected) continue;
|
|
2219
|
+
if (index > expected) {
|
|
2220
|
+
feed.dirty = true;
|
|
2221
|
+
break;
|
|
2222
|
+
}
|
|
2223
|
+
buffer.push({
|
|
2224
|
+
id,
|
|
2225
|
+
type,
|
|
2226
|
+
payload,
|
|
2227
|
+
index,
|
|
2228
|
+
sessionId,
|
|
2229
|
+
createdAt: new Date(wake.createdAt)
|
|
2230
|
+
});
|
|
2231
|
+
}
|
|
2232
|
+
if (wake.frontier > (buffer.at(-1)?.index ?? last)) feed.dirty = true;
|
|
2233
|
+
}
|
|
2234
|
+
};
|
|
2235
|
+
const iterator = {
|
|
2236
|
+
async next() {
|
|
2237
|
+
for (;;) {
|
|
2238
|
+
if (closed) {
|
|
2239
|
+
release();
|
|
2240
|
+
return {
|
|
2241
|
+
value: void 0,
|
|
2242
|
+
done: true
|
|
2243
|
+
};
|
|
2244
|
+
}
|
|
2245
|
+
const row = buffer.shift();
|
|
2246
|
+
if (row) {
|
|
2247
|
+
let event;
|
|
2248
|
+
try {
|
|
2249
|
+
event = isSerializedEvent(row) ? parseSerializedEvent(row) : row;
|
|
2250
|
+
} catch (cause) {
|
|
2251
|
+
buffer = [];
|
|
2252
|
+
feed.dirty = true;
|
|
2253
|
+
release();
|
|
2254
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis stream read failed", { cause });
|
|
2255
|
+
}
|
|
2256
|
+
last = row.index;
|
|
2257
|
+
return {
|
|
2258
|
+
value: event,
|
|
2259
|
+
done: false
|
|
2260
|
+
};
|
|
2261
|
+
}
|
|
2262
|
+
if (severed) {
|
|
2263
|
+
release();
|
|
2264
|
+
throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
2265
|
+
}
|
|
2266
|
+
try {
|
|
2267
|
+
if (!subscribed) {
|
|
2268
|
+
subscribed = true;
|
|
2269
|
+
let set = feeds.get(channel);
|
|
2270
|
+
if (!set) {
|
|
2271
|
+
set = /* @__PURE__ */ new Set();
|
|
2272
|
+
feeds.set(channel, set);
|
|
2273
|
+
}
|
|
2274
|
+
set.add(feed);
|
|
2275
|
+
await acquireChannel(channel);
|
|
2276
|
+
if (closed || severed) continue;
|
|
2277
|
+
}
|
|
2278
|
+
if (options.polling?.()) {
|
|
2279
|
+
if (!poller) {
|
|
2280
|
+
poller = pollingStream((after) => core.readEvents(sessionId, after), { startAfter: last })[Symbol.asyncIterator]();
|
|
2281
|
+
pollingStreams.add(poller);
|
|
2282
|
+
stopActivity = streamActivities.get(poller)?.subscribe(activity.setIdle);
|
|
2283
|
+
}
|
|
2284
|
+
const result = await poller.next();
|
|
2285
|
+
if (!result.done) presenceSubscriptions.get(presenceChannel(sessionId))?.poll?.wake();
|
|
2286
|
+
if (severed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
2287
|
+
return result;
|
|
2288
|
+
}
|
|
2289
|
+
if (catchup) {
|
|
2290
|
+
const result = await nextRead(last);
|
|
2291
|
+
if (closed || severed) continue;
|
|
2292
|
+
if (!result.done) buffer = result.value;
|
|
2293
|
+
continue;
|
|
2294
|
+
}
|
|
2295
|
+
applyInbox();
|
|
2296
|
+
if (buffer.length > 0) continue;
|
|
2297
|
+
if (feed.dirty) {
|
|
2298
|
+
feed.dirty = false;
|
|
2299
|
+
if (core.streamEvents) {
|
|
2300
|
+
const result = await nextRead(last);
|
|
2301
|
+
if (closed || severed) continue;
|
|
2302
|
+
if (!result.done) buffer = result.value;
|
|
2303
|
+
continue;
|
|
2304
|
+
}
|
|
2305
|
+
buffer = await core.readFeedPage(sessionId, last);
|
|
2306
|
+
if (buffer.length >= 1e3) feed.dirty = true;
|
|
2307
|
+
continue;
|
|
2308
|
+
}
|
|
2309
|
+
const woken = new Promise((resolve) => {
|
|
2310
|
+
feed.wake = () => resolve("wake");
|
|
2311
|
+
});
|
|
2312
|
+
pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
|
|
2313
|
+
const outcome = await Promise.race([woken, pending.promise.then(() => "timer")]);
|
|
2314
|
+
pending.cancel();
|
|
2315
|
+
pending = null;
|
|
2316
|
+
if (outcome === "timer") feed.dirty = true;
|
|
2317
|
+
} catch (err) {
|
|
2318
|
+
release();
|
|
2319
|
+
if (closed) return {
|
|
2320
|
+
value: void 0,
|
|
2321
|
+
done: true
|
|
2322
|
+
};
|
|
2323
|
+
if (err instanceof A2Error) throw err;
|
|
2324
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
2325
|
+
} finally {
|
|
2326
|
+
feed.wake = null;
|
|
2327
|
+
}
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
async return() {
|
|
2331
|
+
closed = true;
|
|
2332
|
+
pending?.cancel();
|
|
2333
|
+
feed.wake?.();
|
|
2334
|
+
release();
|
|
2335
|
+
await stopRead();
|
|
2336
|
+
return {
|
|
2337
|
+
value: void 0,
|
|
2338
|
+
done: true
|
|
2339
|
+
};
|
|
2340
|
+
}
|
|
2341
|
+
};
|
|
2342
|
+
streamActivities.set(iterator, activity);
|
|
2343
|
+
bufferedEvents.set(iterator, () => {
|
|
2344
|
+
if (closed) return void 0;
|
|
2345
|
+
const row = buffer.shift();
|
|
2346
|
+
if (row) last = row.index;
|
|
2347
|
+
return row;
|
|
2348
|
+
});
|
|
2349
|
+
return iterator;
|
|
2350
|
+
} };
|
|
2351
|
+
},
|
|
2352
|
+
async close() {
|
|
2353
|
+
severed = true;
|
|
2354
|
+
for (const subscription of presenceSubscriptions.values()) subscription.poll?.stop();
|
|
2355
|
+
presenceSubscriptions.clear();
|
|
2356
|
+
for (const poller of pollingStreams) poller.return?.();
|
|
2357
|
+
pollingStreams.clear();
|
|
2358
|
+
const reads = [];
|
|
2359
|
+
for (const set of feeds.values()) for (const feed of set) {
|
|
2360
|
+
feed.wake?.();
|
|
2361
|
+
reads.push(feed.stopRead());
|
|
2362
|
+
}
|
|
2363
|
+
await Promise.allSettled(reads);
|
|
2364
|
+
if (subscriber) (await subscriber.catch(() => null))?.disconnect();
|
|
2365
|
+
await options.closeClient();
|
|
2366
|
+
}
|
|
2367
|
+
};
|
|
2368
|
+
stateTailReaders.set(store, core.readTail);
|
|
2369
|
+
return store;
|
|
2370
|
+
}
|
|
2371
|
+
//#endregion
|
|
2372
|
+
export { createRedisNotifyStore as t };
|
|
2373
|
+
|
|
2374
|
+
//# sourceMappingURL=store-redis-notify-tqWNnMRu.js.map
|