experimental-a2 0.16.1 → 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 +352 -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-DgOBltJ_.js → ai-UrWfKeuv.js} +621 -216
- 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 +432 -258
- package/dist/ai-server.js.map +1 -1
- package/dist/{ai-CrEf6p_W.d.ts → ai-vpGtcaCB.d.ts} +114 -100
- 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 +671 -581
- 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 +656 -194
- 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 +6 -3
- 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 +92 -72
- package/src/ai-coordinator.ts +23 -22
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +262 -64
- 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 -1964
- package/src/ai-stored-state.ts +48 -163
- package/src/ai.ts +339 -155
- 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-CrEf6p_W.d.ts.map +0 -1
- package/dist/ai-DgOBltJ_.js.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
package/src/store-redis-core.ts
CHANGED
|
@@ -6,8 +6,11 @@
|
|
|
6
6
|
*
|
|
7
7
|
* The stream IS the store: each event is a stream entry whose ID is its
|
|
8
8
|
* dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read.
|
|
9
|
-
* Mutable drain/failure bookkeeping lives in
|
|
10
|
-
* entries are immutable); every write path is one atomic
|
|
9
|
+
* Mutable drain/failure bookkeeping lives in side hashes of fixed index
|
|
10
|
+
* ranges (stream entries are immutable); every write path is one atomic
|
|
11
|
+
* Lua script.
|
|
12
|
+
* Global event-id ownership is one key per id, so no key grows with
|
|
13
|
+
* the number of sessions.
|
|
11
14
|
* The conformance suite in test/conformance is the executable
|
|
12
15
|
* contract, run against a real spawned `redis-server`.
|
|
13
16
|
*
|
|
@@ -17,10 +20,12 @@
|
|
|
17
20
|
*/
|
|
18
21
|
|
|
19
22
|
import type { PresencePatch } from './contract.ts'
|
|
20
|
-
import { A2Error } from './errors.ts'
|
|
23
|
+
import { A2Error, supersededAttemptError } from './errors.ts'
|
|
24
|
+
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
21
25
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
22
26
|
import { nullProtoRecord } from './internal.ts'
|
|
23
27
|
import { decodeReturnedEventIds } from './store-codec.ts'
|
|
28
|
+
import type { SerializedEvent } from './stream-buffer.ts'
|
|
24
29
|
import type {
|
|
25
30
|
A2Store,
|
|
26
31
|
Clock,
|
|
@@ -41,12 +46,25 @@ export type RedisCall = (
|
|
|
41
46
|
|
|
42
47
|
export type RedisStoreCoreOptions = {
|
|
43
48
|
call: RedisCall
|
|
49
|
+
/**
|
|
50
|
+
* Issues a command whose bulk replies arrive as raw bytes. Live feed
|
|
51
|
+
* catch-up pages use it to decode only the fields a row needs.
|
|
52
|
+
*/
|
|
53
|
+
callBytes?: RedisCall
|
|
44
54
|
clock: Clock
|
|
45
55
|
ids: IdSource
|
|
46
56
|
keyPrefix: string
|
|
47
57
|
presenceSource: string
|
|
58
|
+
readStream?: RedisStreamRead
|
|
48
59
|
}
|
|
49
60
|
|
|
61
|
+
/** Stream entries from `from` onward, in nonempty ordered batches. */
|
|
62
|
+
export type RedisStreamRead = (options: {
|
|
63
|
+
key: string
|
|
64
|
+
from: string
|
|
65
|
+
signal: AbortSignal
|
|
66
|
+
}) => AsyncGenerator<StreamEntry[], void>
|
|
67
|
+
|
|
50
68
|
export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
|
|
51
69
|
/**
|
|
52
70
|
* The storage half of the presence capability, shared by both
|
|
@@ -72,233 +90,464 @@ export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
|
|
|
72
90
|
/** One bounded XRANGE page of public events for live feeds. */
|
|
73
91
|
readEvents(sessionId: string, afterIndex: number): Promise<Event[]>
|
|
74
92
|
/**
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
|
|
93
|
+
* One bounded XRANGE page of catch-up rows for a live feed without a
|
|
94
|
+
* streaming reader, payloads left as stored JSON. The reply is held whole,
|
|
95
|
+
* so a page retains up to `FEED_PAGE_SIZE` entries.
|
|
96
|
+
*/
|
|
97
|
+
readFeedPage(
|
|
98
|
+
sessionId: string,
|
|
99
|
+
afterIndex: number,
|
|
100
|
+
): Promise<SerializedEvent[]>
|
|
101
|
+
/** Catch-up events after `afterIndex`, in nonempty ordered batches. */
|
|
102
|
+
streamEvents?: (options: {
|
|
103
|
+
sessionId: string
|
|
104
|
+
afterIndex: number
|
|
105
|
+
signal: AbortSignal
|
|
106
|
+
}) => AsyncGenerator<SerializedEvent[], void>
|
|
107
|
+
/**
|
|
108
|
+
* Events from `fromIndex` through `throughIndex` or the newest entry in
|
|
109
|
+
* one newest-first page, or null when the range doesn't fit in one.
|
|
79
110
|
*/
|
|
80
|
-
|
|
111
|
+
readTail(
|
|
112
|
+
sessionId: string,
|
|
113
|
+
fromIndex: number,
|
|
114
|
+
throughIndex?: number,
|
|
115
|
+
): Promise<Event[] | null>
|
|
81
116
|
}
|
|
82
117
|
|
|
83
|
-
const READ_PAGE_SIZE = 256
|
|
118
|
+
export const READ_PAGE_SIZE = 256
|
|
119
|
+
export const FEED_PAGE_SIZE = 1000
|
|
84
120
|
const READ_CONCURRENCY = 16
|
|
85
121
|
|
|
86
122
|
// ── Lua scripts (each runs atomically) ───────────────────────────────
|
|
87
123
|
|
|
124
|
+
const META_CHUNK_SIZE = 1000
|
|
125
|
+
|
|
88
126
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
127
|
+
* Upstash bills every call a script makes, so scripts read the counter and
|
|
128
|
+
* split with one MGET, read metadata with one HMGET per hash (`read_fields`),
|
|
129
|
+
* and buffer hash writes (`put`) into one HSET per hash (`flush`). Buffered
|
|
130
|
+
* writes are invisible to reads until flushed, so a script must not read a
|
|
131
|
+
* field it has buffered. Per-event metadata lives in hashes of
|
|
132
|
+
* META_CHUNK_SIZE consecutive indexes, so no key grows with a session's
|
|
133
|
+
* history. Indexes at or below the split predate chunking and stay in the
|
|
134
|
+
* base hash, which also holds every session's lane pointers. An unset split
|
|
135
|
+
* means the current event count, so any script that adds events must write
|
|
136
|
+
* `split_write` pairs in its write phase. Including scripts bind COUNTER,
|
|
137
|
+
* META (the base hash), and SPLIT first.
|
|
96
138
|
*/
|
|
97
|
-
const
|
|
139
|
+
const META_LUA = `
|
|
140
|
+
local split, split_stored = nil, false
|
|
141
|
+
-- The latest clear marker lives beside the counter, {prefix}:{session}:clear,
|
|
142
|
+
-- and is read in the same MGET, so a session never cleared pays nothing.
|
|
143
|
+
local CLEAR = string.sub(COUNTER, 1, -6) .. 'clear'
|
|
144
|
+
local boundary = 0
|
|
145
|
+
local function use_split(stored, count, cleared)
|
|
146
|
+
split_stored = stored ~= false
|
|
147
|
+
split = tonumber(stored or count or '0')
|
|
148
|
+
boundary = tonumber(cleared or '0')
|
|
149
|
+
end
|
|
150
|
+
local function mkey(field)
|
|
151
|
+
if split == nil then
|
|
152
|
+
local values = redis.call('MGET', SPLIT, COUNTER, CLEAR)
|
|
153
|
+
use_split(values[1], values[2], values[3])
|
|
154
|
+
end
|
|
155
|
+
local idx = tonumber(field)
|
|
156
|
+
if idx <= split then return META end
|
|
157
|
+
return META .. ':' .. tostring(math.floor((idx - 1) / ${META_CHUNK_SIZE}))
|
|
158
|
+
end
|
|
159
|
+
local function split_write(args)
|
|
160
|
+
if not split_stored then
|
|
161
|
+
args[#args + 1] = SPLIT
|
|
162
|
+
args[#args + 1] = tostring(split)
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
local function sliced(command, key, args)
|
|
166
|
+
local results = {}
|
|
167
|
+
for first = 1, #args, 1000 do
|
|
168
|
+
local last = math.min(first + 999, #args)
|
|
169
|
+
local reply
|
|
170
|
+
if key then
|
|
171
|
+
reply = redis.call(command, key, unpack(args, first, last))
|
|
172
|
+
else
|
|
173
|
+
reply = redis.call(command, unpack(args, first, last))
|
|
174
|
+
end
|
|
175
|
+
if type(reply) == 'table' then
|
|
176
|
+
for _, value in ipairs(reply) do results[#results + 1] = value end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
return results
|
|
180
|
+
end
|
|
181
|
+
local function read_fields(fields, suffixes)
|
|
182
|
+
local groups, order = {}, {}
|
|
183
|
+
for _, field in ipairs(fields) do
|
|
184
|
+
local key = mkey(field)
|
|
185
|
+
local group = groups[key]
|
|
186
|
+
if not group then
|
|
187
|
+
group = { fields = {}, names = {} }
|
|
188
|
+
groups[key] = group
|
|
189
|
+
order[#order + 1] = key
|
|
190
|
+
end
|
|
191
|
+
group.fields[#group.fields + 1] = field
|
|
192
|
+
for _, suffix in ipairs(suffixes) do
|
|
193
|
+
group.names[#group.names + 1] = field .. ':' .. suffix
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
local rows = {}
|
|
197
|
+
for _, key in ipairs(order) do
|
|
198
|
+
local group = groups[key]
|
|
199
|
+
local values = sliced('HMGET', key, group.names)
|
|
200
|
+
for position, field in ipairs(group.fields) do
|
|
201
|
+
local row = {}
|
|
202
|
+
for column = 1, #suffixes do
|
|
203
|
+
row[column] = values[(position - 1) * #suffixes + column]
|
|
204
|
+
end
|
|
205
|
+
rows[field] = row
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
return rows
|
|
209
|
+
end
|
|
210
|
+
local writes, write_order = {}, {}
|
|
211
|
+
local function put(key, field, value)
|
|
212
|
+
local fields = writes[key]
|
|
213
|
+
if not fields then
|
|
214
|
+
fields = {}
|
|
215
|
+
writes[key] = fields
|
|
216
|
+
write_order[#write_order + 1] = key
|
|
217
|
+
end
|
|
218
|
+
fields[#fields + 1] = field
|
|
219
|
+
fields[#fields + 1] = value
|
|
220
|
+
end
|
|
221
|
+
local function flush()
|
|
222
|
+
for _, key in ipairs(write_order) do sliced('HSET', key, writes[key]) end
|
|
223
|
+
writes, write_order = {}, {}
|
|
224
|
+
end
|
|
225
|
+
`
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Lane-aware enqueueing for scripts that add events. Including scripts bind
|
|
229
|
+
* PENDING and READY. Lane tails are read once per lane and tracked in
|
|
230
|
+
* memory, so a batch in one lane links its events without re-reading. A
|
|
231
|
+
* lane emptied earlier in the script (`clear_lane`) is known to have no tail,
|
|
232
|
+
* and its pointers are deleted at `flush_queue` only if nothing re-filled it.
|
|
233
|
+
*/
|
|
234
|
+
const ENQUEUE_LUA = `
|
|
98
235
|
local function lane_field(lane, suffix)
|
|
99
236
|
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
100
237
|
end
|
|
101
|
-
local
|
|
102
|
-
|
|
238
|
+
local queued, ready_now, lane_tails, lane_order = {}, {}, {}, {}
|
|
239
|
+
local function enqueue(field, lane)
|
|
240
|
+
local score = tonumber(field)
|
|
241
|
+
queued[#queued + 1] = score
|
|
242
|
+
queued[#queued + 1] = field
|
|
103
243
|
if lane == '' then
|
|
104
|
-
|
|
244
|
+
ready_now[#ready_now + 1] = score
|
|
245
|
+
ready_now[#ready_now + 1] = field
|
|
105
246
|
return
|
|
106
247
|
end
|
|
107
|
-
|
|
108
|
-
local tail =
|
|
248
|
+
put(mkey(field), field .. ':l', lane)
|
|
249
|
+
local tail = lane_tails[lane]
|
|
250
|
+
if tail == nil then
|
|
251
|
+
tail = redis.call('HGET', META, lane_field(lane, 'tail'))
|
|
252
|
+
if tail and tonumber(tail) <= boundary then tail = false end
|
|
253
|
+
lane_order[#lane_order + 1] = lane
|
|
254
|
+
end
|
|
109
255
|
if tail then
|
|
110
|
-
|
|
256
|
+
put(mkey(tail), tail .. ':next', field)
|
|
111
257
|
else
|
|
112
|
-
|
|
113
|
-
|
|
258
|
+
put(META, lane_field(lane, 'head'), field)
|
|
259
|
+
ready_now[#ready_now + 1] = score
|
|
260
|
+
ready_now[#ready_now + 1] = field
|
|
261
|
+
end
|
|
262
|
+
lane_tails[lane] = field
|
|
263
|
+
end
|
|
264
|
+
local function clear_lane(lane)
|
|
265
|
+
if lane_tails[lane] == nil then lane_order[#lane_order + 1] = lane end
|
|
266
|
+
lane_tails[lane] = false
|
|
267
|
+
end
|
|
268
|
+
local function flush_queue()
|
|
269
|
+
for _, lane in ipairs(lane_order) do
|
|
270
|
+
if lane_tails[lane] then
|
|
271
|
+
put(META, lane_field(lane, 'tail'), lane_tails[lane])
|
|
272
|
+
else
|
|
273
|
+
redis.call('HDEL', META, lane_field(lane, 'head'), lane_field(lane, 'tail'))
|
|
274
|
+
end
|
|
114
275
|
end
|
|
115
|
-
|
|
276
|
+
sliced('ZADD', PENDING, queued)
|
|
277
|
+
sliced('ZADD', READY, ready_now)
|
|
278
|
+
end
|
|
279
|
+
`
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Event-id ownership for scripts that add events. Each script reads the
|
|
283
|
+
* counter, split, clear boundary, and per-id keys with one MGET and applies
|
|
284
|
+
* the first three. Returns the owner record, or false, per event, and the
|
|
285
|
+
* counter. A record this session made below its latest clear is free.
|
|
286
|
+
*/
|
|
287
|
+
const ID_OWNERS_LUA = `
|
|
288
|
+
local function read_owners(key_offset, n, session)
|
|
289
|
+
local keys = { COUNTER, SPLIT, CLEAR }
|
|
290
|
+
for i = 1, n do keys[#keys + 1] = KEYS[key_offset + i] end
|
|
291
|
+
local values = sliced('MGET', nil, keys)
|
|
292
|
+
use_split(values[2], values[1], values[3])
|
|
293
|
+
local owners = {}
|
|
294
|
+
for i = 1, n do
|
|
295
|
+
local owner = values[3 + i]
|
|
296
|
+
if owner then
|
|
297
|
+
local rec = cjson.decode(owner)
|
|
298
|
+
if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
|
|
299
|
+
end
|
|
300
|
+
owners[i] = owner
|
|
301
|
+
end
|
|
302
|
+
return owners, values[1]
|
|
303
|
+
end
|
|
304
|
+
`
|
|
305
|
+
|
|
306
|
+
const WAKE_PAYLOAD_LIMIT = 32_768
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Wake-up messages carry the events they announce, so a subscribed reader
|
|
310
|
+
* that is caught up applies them without reading the stream:
|
|
311
|
+
* `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
|
|
312
|
+
* are the stored JSON text, inlined verbatim. A batch over
|
|
313
|
+
* WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
|
|
314
|
+
* with XRANGE.
|
|
315
|
+
*/
|
|
316
|
+
const WAKE_LUA = `
|
|
317
|
+
local wake_entries, wake_bytes = {}, 0
|
|
318
|
+
local function wake_add(field, id, typ, payload)
|
|
319
|
+
if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
|
|
320
|
+
wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
|
|
321
|
+
return
|
|
322
|
+
end
|
|
323
|
+
local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
|
|
324
|
+
cjson.encode(typ) .. ',' .. payload .. ']'
|
|
325
|
+
wake_bytes = wake_bytes + #entry + 1
|
|
326
|
+
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
327
|
+
wake_entries[#wake_entries + 1] = entry
|
|
328
|
+
end
|
|
329
|
+
end
|
|
330
|
+
local function wake(channel, frontier, created_at)
|
|
331
|
+
local message = '{"f":' .. frontier .. '}'
|
|
332
|
+
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
333
|
+
message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
|
|
334
|
+
table.concat(wake_entries, ',') .. ']}'
|
|
335
|
+
end
|
|
336
|
+
redis.pcall('PUBLISH', channel, message)
|
|
337
|
+
end
|
|
338
|
+
`
|
|
339
|
+
|
|
340
|
+
const RELEASE_PINS_LUA = `
|
|
341
|
+
local function release_snapshot_pins(pins, counts)
|
|
342
|
+
local refs = redis.call('SMEMBERS', pins)
|
|
343
|
+
if #refs == 0 then return end
|
|
344
|
+
for _, ref in ipairs(refs) do
|
|
345
|
+
local count = redis.call('HINCRBY', counts, ref, -1)
|
|
346
|
+
if count <= 0 then
|
|
347
|
+
redis.call('HDEL', counts, ref)
|
|
348
|
+
local checkpoint = cjson.decode(ref)
|
|
349
|
+
redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
redis.call('DEL', pins)
|
|
353
|
+
end
|
|
354
|
+
`
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* KEYS: store stream, counter, metadata, pending, ready, metadata split,
|
|
358
|
+
* then one id ownership key per event.
|
|
359
|
+
* ARGV: sessionId, nowMs, n, then per event:
|
|
360
|
+
* id, type, payloadJson, causeJson, lane, settled; then notify channel and
|
|
361
|
+
* origin index or ''.
|
|
362
|
+
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
363
|
+
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
364
|
+
* ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx] |
|
|
365
|
+
* ['cleared', originIndex].
|
|
366
|
+
*/
|
|
367
|
+
const APPEND_LUA = `
|
|
368
|
+
local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[6], KEYS[4], KEYS[5]
|
|
369
|
+
${META_LUA}
|
|
370
|
+
${ENQUEUE_LUA}
|
|
371
|
+
${ID_OWNERS_LUA}
|
|
372
|
+
${WAKE_LUA}
|
|
373
|
+
local function has_pending()
|
|
374
|
+
return redis.call('ZCARD', PENDING) > 0 and '1' or '0'
|
|
116
375
|
end
|
|
117
376
|
local n = tonumber(ARGV[3])
|
|
118
|
-
|
|
119
|
-
local
|
|
120
|
-
|
|
377
|
+
if n == 0 then return { 'empty', has_pending() } end
|
|
378
|
+
local owners, count = read_owners(6, n, ARGV[1])
|
|
379
|
+
local base = tonumber(count or '0')
|
|
121
380
|
local dups = {}
|
|
122
381
|
for i = 1, n do
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
local rec = cjson.decode(existing)
|
|
127
|
-
if rec[1] ~= ARGV[1] then return { 'foreign', id } end
|
|
382
|
+
if owners[i] then
|
|
383
|
+
local rec = cjson.decode(owners[i])
|
|
384
|
+
if rec[1] ~= ARGV[1] then return { 'foreign', ARGV[3 + (i - 1) * 6 + 1] } end
|
|
128
385
|
dups[#dups + 1] = tostring(rec[2])
|
|
129
386
|
end
|
|
130
387
|
end
|
|
131
388
|
if #dups == n then
|
|
132
|
-
local res = { 'dup', has_pending }
|
|
389
|
+
local res = { 'dup', has_pending() }
|
|
133
390
|
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
134
391
|
return res
|
|
135
392
|
end
|
|
136
393
|
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
394
|
+
local origin = ARGV[5 + n * 6]
|
|
395
|
+
if origin and origin ~= '' and tonumber(origin) <= boundary then
|
|
396
|
+
return { 'cleared', origin }
|
|
397
|
+
end
|
|
398
|
+
local causes, parents = {}, {}
|
|
137
399
|
for i = 1, n do
|
|
138
400
|
local cause = ARGV[3 + (i - 1) * 6 + 4]
|
|
139
401
|
if cause ~= '' then
|
|
140
402
|
local parsed = cjson.decode(cause)
|
|
141
403
|
local pidx = tonumber(parsed.index)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
404
|
+
causes[#causes + 1] = parsed
|
|
405
|
+
if pidx >= 1 and pidx <= base then parents[#parents + 1] = tostring(pidx) end
|
|
406
|
+
end
|
|
407
|
+
end
|
|
408
|
+
local parent_rows = read_fields(parents, { 'd', 'f', 'p' })
|
|
409
|
+
local parent_pending = false
|
|
410
|
+
for _, parsed in ipairs(causes) do
|
|
411
|
+
local pidx = tonumber(parsed.index)
|
|
412
|
+
if pidx <= boundary then return { 'superseded', tostring(pidx) } end
|
|
413
|
+
if pidx < 1 or pidx > base then
|
|
414
|
+
return { 'no_parent', tostring(parsed.index) }
|
|
151
415
|
end
|
|
416
|
+
local row = parent_rows[tostring(pidx)]
|
|
417
|
+
if tonumber(row[1] or '0') ~= tonumber(parsed.attempt) or row[2] then
|
|
418
|
+
return { 'superseded', tostring(pidx) }
|
|
419
|
+
end
|
|
420
|
+
-- A claimed, unprocessed parent is still in the pending set.
|
|
421
|
+
if not row[3] then parent_pending = true end
|
|
152
422
|
end
|
|
423
|
+
local owner_writes = {}
|
|
153
424
|
for i = 1, n do
|
|
154
|
-
local
|
|
155
|
-
local
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
|
|
165
|
-
redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
|
|
166
|
-
if settled == '1' then
|
|
167
|
-
redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
|
|
425
|
+
local o = 3 + (i - 1) * 6
|
|
426
|
+
local field = tostring(base + i)
|
|
427
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
428
|
+
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
429
|
+
'created_at', ARGV[2], 'cause', ARGV[o + 4], 'lane', ARGV[o + 5])
|
|
430
|
+
owner_writes[#owner_writes + 1] = KEYS[6 + i]
|
|
431
|
+
owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], base + i })
|
|
432
|
+
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
433
|
+
if ARGV[o + 6] == '1' then
|
|
434
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
168
435
|
else
|
|
169
|
-
enqueue(
|
|
436
|
+
enqueue(field, ARGV[o + 5])
|
|
170
437
|
end
|
|
171
438
|
end
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
439
|
+
owner_writes[#owner_writes + 1] = COUNTER
|
|
440
|
+
owner_writes[#owner_writes + 1] = tostring(base + n)
|
|
441
|
+
split_write(owner_writes)
|
|
442
|
+
sliced('MSET', nil, owner_writes)
|
|
443
|
+
flush_queue()
|
|
444
|
+
flush()
|
|
445
|
+
local pending_after = (#queued > 0 or parent_pending) and '1' or has_pending()
|
|
446
|
+
wake(ARGV[4 + n * 6], tostring(base + n), ARGV[2])
|
|
447
|
+
return { 'ok', tostring(base + 1), pending_after }
|
|
178
448
|
`
|
|
179
449
|
|
|
180
450
|
/**
|
|
181
|
-
* KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts
|
|
451
|
+
* KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts,
|
|
452
|
+
* metadata split.
|
|
182
453
|
* ARGV: idx, attempt, error, maxFailures, nowMs.
|
|
183
454
|
*/
|
|
184
455
|
const FAIL_ATTEMPT_LUA = `
|
|
185
|
-
local
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
local count = redis.call('HINCRBY', KEYS[6], ref, -1)
|
|
189
|
-
if count <= 0 then
|
|
190
|
-
redis.call('HDEL', KEYS[6], ref)
|
|
191
|
-
local checkpoint = cjson.decode(ref)
|
|
192
|
-
redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
|
|
193
|
-
end
|
|
194
|
-
end
|
|
195
|
-
redis.call('DEL', KEYS[5])
|
|
196
|
-
if redis.call('HLEN', KEYS[6]) == 0 then redis.call('DEL', KEYS[6]) end
|
|
197
|
-
end
|
|
198
|
-
local function lane_field(lane, suffix)
|
|
199
|
-
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
200
|
-
end
|
|
201
|
-
local function settle(field)
|
|
202
|
-
redis.call('ZREM', KEYS[3], field)
|
|
203
|
-
redis.call('ZREM', KEYS[4], field)
|
|
204
|
-
local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
|
|
205
|
-
if lane == '' then return end
|
|
206
|
-
local head_key = lane_field(lane, 'head')
|
|
207
|
-
if redis.call('HGET', KEYS[2], head_key) ~= field then return end
|
|
208
|
-
local next_field = redis.call('HGET', KEYS[2], field .. ':next')
|
|
209
|
-
while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
|
|
210
|
-
next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
|
|
211
|
-
end
|
|
212
|
-
if next_field then
|
|
213
|
-
redis.call('HSET', KEYS[2], head_key, next_field)
|
|
214
|
-
if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
|
|
215
|
-
redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
|
|
216
|
-
end
|
|
217
|
-
else
|
|
218
|
-
redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
|
|
219
|
-
end
|
|
220
|
-
end
|
|
456
|
+
local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[7]
|
|
457
|
+
${META_LUA}
|
|
458
|
+
${RELEASE_PINS_LUA}
|
|
221
459
|
local idx = tonumber(ARGV[1])
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
460
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
461
|
+
local total = tonumber(counts[1] or '0')
|
|
462
|
+
use_split(counts[2], counts[1], counts[3])
|
|
463
|
+
if idx <= boundary then return { 'cleared', '0' } end
|
|
464
|
+
if idx < 1 or idx > total then return { 'missing', '0' } end
|
|
225
465
|
local field = ARGV[1]
|
|
226
|
-
local
|
|
227
|
-
local
|
|
228
|
-
|
|
466
|
+
local attempt = tonumber(ARGV[2])
|
|
467
|
+
local row = read_fields({ field }, { 'a', 'd', 'p', 'f', 'lfa', 'ch' })[field]
|
|
468
|
+
local failures = tonumber(row[1] or '0')
|
|
469
|
+
if row[3] or tonumber(row[2] or '0') ~= attempt then
|
|
229
470
|
return { 'superseded', tostring(failures) }
|
|
230
471
|
end
|
|
231
|
-
if
|
|
232
|
-
|
|
233
|
-
end
|
|
234
|
-
if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
|
|
235
|
-
tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
|
|
236
|
-
redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
|
|
472
|
+
if row[4] then return { 'dead_lettered', tostring(failures) } end
|
|
473
|
+
if row[5] and tonumber(row[5]) == attempt and not row[6] then
|
|
237
474
|
return { 'failed', tostring(failures) }
|
|
238
475
|
end
|
|
239
|
-
if
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
476
|
+
if not row[6] then return { 'superseded', tostring(failures) } end
|
|
477
|
+
failures = failures + 1
|
|
478
|
+
local dead = failures >= tonumber(ARGV[4])
|
|
479
|
+
local meta_key = mkey(field)
|
|
480
|
+
put(meta_key, field .. ':a', tostring(failures))
|
|
481
|
+
put(meta_key, field .. ':e', ARGV[3])
|
|
482
|
+
put(meta_key, field .. ':lf', ARGV[5])
|
|
483
|
+
put(meta_key, field .. ':lfa', ARGV[2])
|
|
484
|
+
if dead then put(meta_key, field .. ':f', ARGV[5]) end
|
|
485
|
+
flush()
|
|
486
|
+
redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
|
|
487
|
+
if dead then
|
|
250
488
|
redis.call('ZREM', KEYS[4], field)
|
|
251
|
-
release_snapshot_pins()
|
|
489
|
+
release_snapshot_pins(KEYS[5], KEYS[6])
|
|
252
490
|
return { 'dead_lettered', tostring(failures) }
|
|
253
491
|
end
|
|
254
492
|
return { 'failed', tostring(failures) }
|
|
255
493
|
`
|
|
256
494
|
|
|
257
495
|
/**
|
|
258
|
-
* KEYS: store, counter, meta, pending, ready.
|
|
496
|
+
* KEYS: store, counter, meta, pending, ready, metadata split.
|
|
259
497
|
* ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
|
|
498
|
+
* The ready set is a subset of the pending set, so an empty ready set is
|
|
499
|
+
* enough to report 'settled'. A claimed result ends with the clear boundary.
|
|
260
500
|
*/
|
|
261
501
|
const CLAIM_AVAILABLE_LUA = `
|
|
262
|
-
|
|
502
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[6]
|
|
503
|
+
${META_LUA}
|
|
263
504
|
local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
|
|
264
505
|
local excluded = {}
|
|
265
506
|
for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
|
|
507
|
+
local candidates = {}
|
|
508
|
+
for _, field in ipairs(ready) do
|
|
509
|
+
if not excluded[field] then candidates[#candidates + 1] = field end
|
|
510
|
+
end
|
|
511
|
+
local rows = read_fields(candidates, { 'ce', 'd', 'fc', 'a', 'e', 'lf', 'lfa' })
|
|
266
512
|
local claimed = {}
|
|
267
513
|
local retry_at = nil
|
|
268
|
-
for _, field in ipairs(
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
514
|
+
for _, field in ipairs(candidates) do
|
|
515
|
+
local row = rows[field]
|
|
516
|
+
local expires = tonumber(row[1] or '0')
|
|
517
|
+
if expires > tonumber(ARGV[2]) then
|
|
518
|
+
if not retry_at or expires < retry_at then retry_at = expires end
|
|
519
|
+
else
|
|
520
|
+
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
521
|
+
if #entries == 0 then return { 'missing', field } end
|
|
522
|
+
local attempt = tostring(tonumber(row[2] or '0') + 1)
|
|
523
|
+
local first_claimed = row[3] or ARGV[2]
|
|
524
|
+
local meta_key = mkey(field)
|
|
525
|
+
put(meta_key, field .. ':d', attempt)
|
|
526
|
+
put(meta_key, field .. ':fc', first_claimed)
|
|
527
|
+
put(meta_key, field .. ':lc', ARGV[2])
|
|
528
|
+
put(meta_key, field .. ':ch', ARGV[1])
|
|
529
|
+
put(meta_key, field .. ':ce', ARGV[3])
|
|
530
|
+
claimed[#claimed + 1] = {
|
|
531
|
+
attempt,
|
|
532
|
+
row[4] or '0',
|
|
533
|
+
row[5] or false,
|
|
534
|
+
first_claimed,
|
|
535
|
+
ARGV[2],
|
|
536
|
+
row[6] or false,
|
|
537
|
+
row[7] or false,
|
|
538
|
+
ARGV[1],
|
|
539
|
+
ARGV[3],
|
|
540
|
+
entries[1]
|
|
541
|
+
}
|
|
295
542
|
end
|
|
296
543
|
end
|
|
544
|
+
flush()
|
|
297
545
|
if #claimed > 0 then
|
|
298
546
|
local result = { 'claimed', tostring(#claimed) }
|
|
299
547
|
for _, claim in ipairs(claimed) do
|
|
300
548
|
for _, value in ipairs(claim) do result[#result + 1] = value end
|
|
301
549
|
end
|
|
550
|
+
result[#result + 1] = tostring(boundary)
|
|
302
551
|
return result
|
|
303
552
|
end
|
|
304
553
|
if retry_at then return { 'busy', tostring(retry_at) } end
|
|
@@ -306,35 +555,43 @@ return { 'settled' }
|
|
|
306
555
|
`
|
|
307
556
|
|
|
308
557
|
/**
|
|
309
|
-
* KEYS: counter, meta.
|
|
558
|
+
* KEYS: counter, meta, metadata split.
|
|
310
559
|
* ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
|
|
311
560
|
* Returns [renewedCount, ...renewed, ...superseded].
|
|
312
561
|
*/
|
|
313
562
|
const RENEW_CLAIMS_LUA = `
|
|
314
|
-
local
|
|
315
|
-
|
|
316
|
-
local
|
|
563
|
+
local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[3]
|
|
564
|
+
${META_LUA}
|
|
565
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
566
|
+
local total = tonumber(counts[1] or '0')
|
|
567
|
+
use_split(counts[2], counts[1], counts[3])
|
|
568
|
+
local claims, fields, cleared = {}, {}, {}
|
|
317
569
|
for i = 1, tonumber(ARGV[4]) do
|
|
318
570
|
local field = ARGV[4 + (i - 1) * 2 + 1]
|
|
319
|
-
local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
|
|
320
571
|
local idx = tonumber(field)
|
|
321
|
-
if idx
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
572
|
+
if idx <= boundary then
|
|
573
|
+
cleared[#cleared + 1] = field
|
|
574
|
+
elseif idx >= 1 and idx <= total then
|
|
575
|
+
claims[#claims + 1] = { field = field, attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2]) }
|
|
576
|
+
fields[#fields + 1] = field
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
local rows = read_fields(fields, { 'd', 'ce', 'p', 'f', 'ch' })
|
|
580
|
+
local renewed = {}
|
|
581
|
+
local superseded = {}
|
|
582
|
+
for _, claim in ipairs(claims) do
|
|
583
|
+
local row = rows[claim.field]
|
|
584
|
+
if tonumber(row[1] or '0') > claim.attempt then
|
|
585
|
+
superseded[#superseded + 1] = claim.field
|
|
586
|
+
elseif tonumber(ARGV[3]) > tonumber(ARGV[2]) and
|
|
587
|
+
not row[3] and not row[4] and row[5] == ARGV[1] and
|
|
588
|
+
tonumber(row[2] or '0') > tonumber(ARGV[2]) then
|
|
589
|
+
put(mkey(claim.field), claim.field .. ':ce', ARGV[3])
|
|
590
|
+
renewed[#renewed + 1] = claim.field
|
|
336
591
|
end
|
|
337
592
|
end
|
|
593
|
+
flush()
|
|
594
|
+
for _, field in ipairs(cleared) do superseded[#superseded + 1] = field end
|
|
338
595
|
local result = { tostring(#renewed) }
|
|
339
596
|
for i = 1, #renewed do result[#result + 1] = renewed[i] end
|
|
340
597
|
for i = 1, #superseded do result[#result + 1] = superseded[i] end
|
|
@@ -342,85 +599,57 @@ return result
|
|
|
342
599
|
`
|
|
343
600
|
|
|
344
601
|
/**
|
|
345
|
-
* KEYS: store, counter,
|
|
346
|
-
*
|
|
602
|
+
* KEYS: store, counter, meta, pending, ready, event snapshot pins, snapshot
|
|
603
|
+
* pin counts, metadata split, then one id ownership key per child.
|
|
347
604
|
* ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
|
|
348
605
|
* id, type, payloadJson, lane, settled; finally notify channel.
|
|
349
606
|
*/
|
|
350
607
|
const COMPLETE_ATTEMPT_LUA = `
|
|
351
|
-
local
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
end
|
|
361
|
-
redis.call('DEL', KEYS[8])
|
|
362
|
-
if redis.call('HLEN', KEYS[9]) == 0 then redis.call('DEL', KEYS[9]) end
|
|
363
|
-
end
|
|
364
|
-
local function lane_field(lane, suffix)
|
|
365
|
-
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
366
|
-
end
|
|
367
|
-
local function settle(field)
|
|
368
|
-
redis.call('ZREM', KEYS[5], field)
|
|
369
|
-
redis.call('ZREM', KEYS[6], field)
|
|
370
|
-
local lane = redis.call('HGET', KEYS[4], field .. ':l') or ''
|
|
608
|
+
local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[8], KEYS[4], KEYS[5]
|
|
609
|
+
${META_LUA}
|
|
610
|
+
${ENQUEUE_LUA}
|
|
611
|
+
${ID_OWNERS_LUA}
|
|
612
|
+
${RELEASE_PINS_LUA}
|
|
613
|
+
${WAKE_LUA}
|
|
614
|
+
local function settle(field, lane, next_field)
|
|
615
|
+
redis.call('ZREM', PENDING, field)
|
|
616
|
+
redis.call('ZREM', READY, field)
|
|
371
617
|
if lane == '' then return end
|
|
372
618
|
local head_key = lane_field(lane, 'head')
|
|
373
|
-
if redis.call('HGET',
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
619
|
+
if redis.call('HGET', META, head_key) ~= field then return end
|
|
620
|
+
while next_field do
|
|
621
|
+
local row = read_fields({ next_field }, { 'p', 'f', 'next' })[next_field]
|
|
622
|
+
if not row[1] then
|
|
623
|
+
redis.call('HSET', META, head_key, next_field)
|
|
624
|
+
if not row[2] then
|
|
625
|
+
redis.call('ZADD', READY, tonumber(next_field), next_field)
|
|
626
|
+
end
|
|
627
|
+
return
|
|
382
628
|
end
|
|
383
|
-
|
|
384
|
-
redis.call('HDEL', KEYS[4], head_key, lane_field(lane, 'tail'))
|
|
385
|
-
end
|
|
386
|
-
end
|
|
387
|
-
local function enqueue(field, idx, lane)
|
|
388
|
-
redis.call('ZADD', KEYS[5], idx, field)
|
|
389
|
-
if lane == '' then
|
|
390
|
-
redis.call('ZADD', KEYS[6], idx, field)
|
|
391
|
-
return
|
|
392
|
-
end
|
|
393
|
-
local tail_key = lane_field(lane, 'tail')
|
|
394
|
-
local tail = redis.call('HGET', KEYS[4], tail_key)
|
|
395
|
-
if tail then
|
|
396
|
-
redis.call('HSET', KEYS[4], tail .. ':next', field)
|
|
397
|
-
else
|
|
398
|
-
redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
|
|
399
|
-
redis.call('ZADD', KEYS[6], idx, field)
|
|
629
|
+
next_field = row[3]
|
|
400
630
|
end
|
|
401
|
-
|
|
631
|
+
clear_lane(lane)
|
|
402
632
|
end
|
|
403
633
|
local parent = ARGV[3]
|
|
404
634
|
local attempt = tonumber(ARGV[4])
|
|
405
635
|
local n = tonumber(ARGV[5])
|
|
406
|
-
local
|
|
636
|
+
local owners, count = read_owners(8, n, ARGV[1])
|
|
637
|
+
local total = tonumber(count or '0')
|
|
638
|
+
if tonumber(parent) <= boundary then return { 'superseded' } end
|
|
407
639
|
if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
|
|
408
|
-
local
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
if
|
|
412
|
-
|
|
413
|
-
local raw_recorded_ids = redis.call('HGET', KEYS[4], parent .. ':ri')
|
|
414
|
-
if not raw_recorded_ids then return { 'partial' } end
|
|
415
|
-
local recorded_ids = cjson.decode(raw_recorded_ids)
|
|
640
|
+
local row = read_fields({ parent }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'lfa', 'l', 'next' })[parent]
|
|
641
|
+
if row[2] then
|
|
642
|
+
if tonumber(row[3] or '0') ~= attempt then return { 'superseded' } end
|
|
643
|
+
if not row[4] then return { 'partial' } end
|
|
644
|
+
local recorded_ids = cjson.decode(row[4])
|
|
416
645
|
if #recorded_ids ~= n then return { 'partial' } end
|
|
646
|
+
for i = 1, n do
|
|
647
|
+
if recorded_ids[i] ~= ARGV[5 + (i - 1) * 5 + 1] then return { 'partial' } end
|
|
648
|
+
end
|
|
417
649
|
local indexes = {}
|
|
418
650
|
for i = 1, n do
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
local existing = redis.call('HGET', KEYS[3], id)
|
|
422
|
-
if not existing then return { 'partial' } end
|
|
423
|
-
local rec = cjson.decode(existing)
|
|
651
|
+
if not owners[i] then return { 'partial' } end
|
|
652
|
+
local rec = cjson.decode(owners[i])
|
|
424
653
|
if rec[1] ~= ARGV[1] then return { 'partial' } end
|
|
425
654
|
local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
|
|
426
655
|
if #entries == 0 then return { 'partial' } end
|
|
@@ -439,62 +668,81 @@ if processed then
|
|
|
439
668
|
for _, idx in ipairs(indexes) do result[#result + 1] = idx end
|
|
440
669
|
return result
|
|
441
670
|
end
|
|
442
|
-
if
|
|
443
|
-
if
|
|
444
|
-
redis.call('HEXISTS', KEYS[4], parent .. ':f') == 1 or
|
|
445
|
-
tonumber(redis.call('HGET', KEYS[4], parent .. ':lfa') or '0') == attempt then
|
|
671
|
+
if tonumber(row[1] or '0') ~= attempt then return { 'superseded' } end
|
|
672
|
+
if not row[5] or row[6] or tonumber(row[7] or '0') == attempt then
|
|
446
673
|
return { 'superseded' }
|
|
447
674
|
end
|
|
448
675
|
for i = 1, n do
|
|
449
|
-
|
|
450
|
-
if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
|
|
676
|
+
if owners[i] then return { 'partial' } end
|
|
451
677
|
end
|
|
452
678
|
local returned_ids = {}
|
|
453
679
|
for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
|
|
454
680
|
local returned_json = '[]'
|
|
455
681
|
if n > 0 then returned_json = cjson.encode(returned_ids) end
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
redis.call('HDEL',
|
|
461
|
-
settle(parent)
|
|
462
|
-
release_snapshot_pins()
|
|
682
|
+
local parent_key = mkey(parent)
|
|
683
|
+
put(parent_key, parent .. ':p', ARGV[2])
|
|
684
|
+
put(parent_key, parent .. ':pa', ARGV[4])
|
|
685
|
+
put(parent_key, parent .. ':ri', returned_json)
|
|
686
|
+
redis.call('HDEL', parent_key, parent .. ':ch', parent .. ':ce')
|
|
687
|
+
settle(parent, row[8] or '', row[9])
|
|
688
|
+
release_snapshot_pins(KEYS[6], KEYS[7])
|
|
463
689
|
local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
|
|
690
|
+
local owner_writes = {}
|
|
464
691
|
for i = 1, n do
|
|
465
|
-
local
|
|
466
|
-
local
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
|
|
476
|
-
if settled == '1' then
|
|
477
|
-
redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
|
|
692
|
+
local o = 5 + (i - 1) * 5
|
|
693
|
+
local field = tostring(total + i)
|
|
694
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
695
|
+
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
696
|
+
'created_at', ARGV[2], 'cause', cause, 'lane', ARGV[o + 4])
|
|
697
|
+
owner_writes[#owner_writes + 1] = KEYS[8 + i]
|
|
698
|
+
owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], total + i })
|
|
699
|
+
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
700
|
+
if ARGV[o + 5] == '1' then
|
|
701
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
478
702
|
else
|
|
479
|
-
enqueue(
|
|
703
|
+
enqueue(field, ARGV[o + 4])
|
|
480
704
|
end
|
|
481
705
|
end
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
706
|
+
if n > 0 then
|
|
707
|
+
owner_writes[#owner_writes + 1] = COUNTER
|
|
708
|
+
owner_writes[#owner_writes + 1] = tostring(total + n)
|
|
709
|
+
split_write(owner_writes)
|
|
710
|
+
sliced('MSET', nil, owner_writes)
|
|
485
711
|
end
|
|
712
|
+
flush_queue()
|
|
713
|
+
flush()
|
|
714
|
+
if n > 0 then wake(ARGV[6 + n * 5], tostring(total + n), ARGV[2]) end
|
|
486
715
|
return { 'ok', tostring(total + 1) }
|
|
487
716
|
`
|
|
488
717
|
|
|
489
718
|
/**
|
|
490
|
-
* KEYS: head, history, event counter, event meta, pin counts.
|
|
719
|
+
* KEYS: head, history, event counter, event meta, pin counts, metadata split.
|
|
491
720
|
* ARGV: nowMs, event pin key prefix, write count, then per write:
|
|
492
|
-
* index, snapshot JSON, pin count, pin event indexes
|
|
721
|
+
* index, snapshot JSON, pin count, pin event indexes; finally the reducer
|
|
722
|
+
* name. Writes from before the latest clear are dropped, and a head from
|
|
723
|
+
* before it is replaced as if absent.
|
|
493
724
|
*/
|
|
494
725
|
const PUT_SNAPSHOTS_LUA = `
|
|
495
|
-
local
|
|
496
|
-
|
|
726
|
+
local COUNTER, META, SPLIT = KEYS[3], KEYS[4], KEYS[6]
|
|
727
|
+
${META_LUA}
|
|
728
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
729
|
+
local total = tonumber(counts[1] or '0')
|
|
730
|
+
use_split(counts[2], counts[1], counts[3])
|
|
731
|
+
local pinned = {}
|
|
497
732
|
local offset = 4
|
|
733
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
734
|
+
local pin_count = tonumber(ARGV[offset + 2])
|
|
735
|
+
for p = 1, pin_count do
|
|
736
|
+
local event_index = tonumber(ARGV[offset + 2 + p])
|
|
737
|
+
if event_index > boundary and event_index <= total then
|
|
738
|
+
pinned[#pinned + 1] = tostring(event_index)
|
|
739
|
+
end
|
|
740
|
+
end
|
|
741
|
+
offset = offset + 3 + pin_count
|
|
742
|
+
end
|
|
743
|
+
local status = read_fields(pinned, { 'p', 'f' })
|
|
744
|
+
local snapshots = {}
|
|
745
|
+
offset = 4
|
|
498
746
|
for i = 1, tonumber(ARGV[3]) do
|
|
499
747
|
local index = tonumber(ARGV[offset])
|
|
500
748
|
local snapshot = ARGV[offset + 1]
|
|
@@ -502,23 +750,40 @@ for i = 1, tonumber(ARGV[3]) do
|
|
|
502
750
|
local ref = cjson.encode({ KEYS[2], tostring(index) })
|
|
503
751
|
for p = 1, pin_count do
|
|
504
752
|
local event_index = tonumber(ARGV[offset + 2 + p])
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
753
|
+
local row = status[tostring(event_index)]
|
|
754
|
+
if index >= boundary and event_index > boundary and event_index <= total and
|
|
755
|
+
not row[1] and not row[2] then
|
|
508
756
|
local pin_key = ARGV[2] .. tostring(event_index)
|
|
509
757
|
if redis.call('SADD', pin_key, ref) == 1 then
|
|
510
758
|
redis.call('HINCRBY', KEYS[5], ref, 1)
|
|
511
759
|
end
|
|
512
760
|
end
|
|
513
761
|
end
|
|
514
|
-
|
|
762
|
+
if index >= boundary then
|
|
763
|
+
snapshots[#snapshots + 1] = { index = index, snapshot = snapshot, ref = ref }
|
|
764
|
+
end
|
|
515
765
|
offset = offset + 3 + pin_count
|
|
516
766
|
end
|
|
767
|
+
if #snapshots == 0 then return 1 end
|
|
517
768
|
|
|
518
769
|
local head = redis.call('GET', KEYS[1])
|
|
519
770
|
local head_index = nil
|
|
520
|
-
|
|
521
|
-
|
|
771
|
+
local registered = false
|
|
772
|
+
if head then
|
|
773
|
+
local record = cjson.decode(head)
|
|
774
|
+
head_index = tonumber(record['index'])
|
|
775
|
+
registered = record['r'] == 1
|
|
776
|
+
end
|
|
777
|
+
if head_index and head_index < boundary then
|
|
778
|
+
head, head_index, registered = nil, nil, false
|
|
779
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', '(' .. boundary)
|
|
780
|
+
end
|
|
781
|
+
-- Deletion finds each reducer's keys through this set instead of a scan.
|
|
782
|
+
-- Heads written since carry the mark, so a reducer registers once.
|
|
783
|
+
if not registered then
|
|
784
|
+
redis.call('SADD', string.sub(COUNTER, 1, -6) .. 'snap-reducers', ARGV[#ARGV])
|
|
785
|
+
end
|
|
786
|
+
local latest = snapshots[#snapshots]
|
|
522
787
|
local writes_head = not head_index or latest.index >= head_index
|
|
523
788
|
local advances = not head_index or latest.index > head_index
|
|
524
789
|
local final_head_index = advances and latest.index or head_index
|
|
@@ -528,7 +793,7 @@ if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEY
|
|
|
528
793
|
redis.call('ZADD', KEYS[2], head_index, head)
|
|
529
794
|
end
|
|
530
795
|
|
|
531
|
-
for _, write in ipairs(
|
|
796
|
+
for _, write in ipairs(snapshots) do
|
|
532
797
|
if write.index < final_head_index then
|
|
533
798
|
redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
|
|
534
799
|
if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
|
|
@@ -607,30 +872,242 @@ return live
|
|
|
607
872
|
|
|
608
873
|
/**
|
|
609
874
|
* KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
|
|
610
|
-
* Returns one atomic multiversion-cache-plus-log-frontier read
|
|
875
|
+
* Returns one atomic multiversion-cache-plus-log-frontier read, with the
|
|
876
|
+
* clear boundary; checkpoints from before it are ignored.
|
|
611
877
|
*/
|
|
612
878
|
const READ_STATE_LUA = `
|
|
879
|
+
local counts = redis.call('MGET', KEYS[3], string.sub(KEYS[3], 1, -6) .. 'clear')
|
|
880
|
+
local boundary = tonumber(counts[2] or '0')
|
|
613
881
|
local head = redis.call('GET', KEYS[1])
|
|
614
882
|
local head_index = head and cjson.decode(head)['index'] or nil
|
|
615
883
|
if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
|
|
616
884
|
return redis.error_reply('invalid snapshot head index')
|
|
617
885
|
end
|
|
886
|
+
if head_index and head_index < boundary then
|
|
887
|
+
head = nil
|
|
888
|
+
head_index = nil
|
|
889
|
+
end
|
|
618
890
|
local cap = ARGV[1]
|
|
619
891
|
local snapshot = nil
|
|
620
892
|
if head_index and (cap == '' or head_index <= tonumber(cap)) then
|
|
621
893
|
snapshot = head
|
|
622
894
|
else
|
|
623
895
|
local upper = cap == '' and '+inf' or cap
|
|
624
|
-
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper,
|
|
896
|
+
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, boundary, 'LIMIT', 0, 1)
|
|
625
897
|
if #historical > 0 then snapshot = historical[1] end
|
|
626
898
|
end
|
|
627
|
-
|
|
628
|
-
|
|
899
|
+
return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
|
|
900
|
+
`
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Removes every registered reducer's checkpoints below the latest clear, and
|
|
904
|
+
* the pin counts that name them. Snapshot writes register each reducer in
|
|
905
|
+
* {prefix}:{session}:snap-reducers, so no key scan is needed.
|
|
906
|
+
*/
|
|
907
|
+
const SWEEP_CHECKPOINTS_LUA = `
|
|
908
|
+
local function sweep_checkpoints(session, boundary)
|
|
909
|
+
for _, reducer in ipairs(redis.call('SMEMBERS', session .. 'snap-reducers')) do
|
|
910
|
+
local head_key = session .. 'snap:' .. reducer
|
|
911
|
+
local head = redis.call('GET', head_key)
|
|
912
|
+
if head and tonumber(cjson.decode(head)['index']) < boundary then
|
|
913
|
+
redis.call('DEL', head_key)
|
|
914
|
+
end
|
|
915
|
+
redis.call('ZREMRANGEBYSCORE', session .. 'snap-history:' .. reducer, '-inf', '(' .. boundary)
|
|
916
|
+
end
|
|
917
|
+
local counts_key = session .. 'snapshot-pin-counts'
|
|
918
|
+
local counts = redis.call('HGETALL', counts_key)
|
|
919
|
+
local stale = {}
|
|
920
|
+
for i = 1, #counts, 2 do
|
|
921
|
+
if tonumber(cjson.decode(counts[i])[2]) < boundary then stale[#stale + 1] = counts[i] end
|
|
922
|
+
end
|
|
923
|
+
if #stale > 0 then redis.call('HDEL', counts_key, unpack(stale)) end
|
|
924
|
+
end
|
|
925
|
+
`
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* KEYS: store stream, counter, metadata, metadata split, marker id ownership
|
|
929
|
+
* key, pending, ready.
|
|
930
|
+
* ARGV: sessionId, nowMs, ifLastIndex or '', cause index or '', cause
|
|
931
|
+
* attempt, marker id, notify channel.
|
|
932
|
+
* Returns ['ok', cleared, head, boundary] | ['no_parent', idx] |
|
|
933
|
+
* ['superseded', idx] | ['foreign']. The old lifetime's log moves aside in one
|
|
934
|
+
* RENAME, so every read of the log starts at the marker, and its queues are
|
|
935
|
+
* unlinked, so claims and pending checks see only the new lifetime; deletion
|
|
936
|
+
* drains the moved log.
|
|
937
|
+
*/
|
|
938
|
+
const CLEAR_LUA = `
|
|
939
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
|
|
940
|
+
${META_LUA}
|
|
941
|
+
${WAKE_LUA}
|
|
942
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR, KEYS[5])
|
|
943
|
+
local head = tonumber(counts[1] or '0')
|
|
944
|
+
use_split(counts[2], counts[1], counts[3])
|
|
945
|
+
if ARGV[4] ~= '' then
|
|
946
|
+
local field = ARGV[4]
|
|
947
|
+
local pidx = tonumber(field)
|
|
948
|
+
if pidx <= boundary then return { 'superseded', field } end
|
|
949
|
+
if pidx < 1 or pidx > head then return { 'no_parent', field } end
|
|
950
|
+
local row = read_fields({ field }, { 'd', 'f' })[field]
|
|
951
|
+
if tonumber(row[1] or '0') ~= tonumber(ARGV[5]) or row[2] then
|
|
952
|
+
return { 'superseded', field }
|
|
953
|
+
end
|
|
954
|
+
end
|
|
955
|
+
local expected = ARGV[3]
|
|
956
|
+
if expected ~= '' and boundary == tonumber(expected) + 1 then
|
|
957
|
+
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
958
|
+
end
|
|
959
|
+
if expected ~= '' and tonumber(expected) ~= head then
|
|
960
|
+
return { 'ok', '0', tostring(head), tostring(boundary) }
|
|
961
|
+
end
|
|
962
|
+
if head == 0 or boundary == head then
|
|
963
|
+
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
964
|
+
end
|
|
965
|
+
if counts[4] then return { 'foreign' } end
|
|
966
|
+
local field = tostring(head + 1)
|
|
967
|
+
local session = string.sub(COUNTER, 1, -6)
|
|
968
|
+
local moved = session .. 'cleared:' .. field
|
|
969
|
+
redis.call('RENAME', KEYS[1], moved)
|
|
970
|
+
redis.call('UNLINK', KEYS[6], KEYS[7])
|
|
971
|
+
redis.call('SADD', session .. 'cleared', moved)
|
|
972
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
973
|
+
'id', ARGV[6], 'type', '${CLEAR_EVENT_TYPE}', 'payload', 'null',
|
|
974
|
+
'created_at', ARGV[2], 'cause', '', 'lane', '')
|
|
975
|
+
local writes = { KEYS[5], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
|
|
976
|
+
split_write(writes)
|
|
977
|
+
redis.call('MSET', unpack(writes))
|
|
978
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
979
|
+
flush()
|
|
980
|
+
wake_add(field, ARGV[6], '${CLEAR_EVENT_TYPE}', 'null')
|
|
981
|
+
wake(ARGV[7], field, ARGV[2])
|
|
982
|
+
return { 'ok', '1', field, field }
|
|
983
|
+
`
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* KEYS: counter. ARGV: page size.
|
|
987
|
+
* The next deletion batch: a moved log and up to a page of its [index, id]
|
|
988
|
+
* pairs, without payloads. Empty moved logs are dropped; with none left it
|
|
989
|
+
* sweeps the old checkpoints and returns nothing, so a retry finishes an
|
|
990
|
+
* interrupted deletion.
|
|
991
|
+
*/
|
|
992
|
+
const RECLAIM_BATCH_LUA = `
|
|
993
|
+
${SWEEP_CHECKPOINTS_LUA}
|
|
994
|
+
local session = string.sub(KEYS[1], 1, -6)
|
|
995
|
+
local boundary = tonumber(redis.call('GET', session .. 'clear') or '0')
|
|
996
|
+
if boundary == 0 then return {} end
|
|
997
|
+
for _, log in ipairs(redis.call('SMEMBERS', session .. 'cleared')) do
|
|
998
|
+
local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
|
|
999
|
+
if #entries > 0 then
|
|
1000
|
+
local batch = { log }
|
|
1001
|
+
for _, entry in ipairs(entries) do
|
|
1002
|
+
local fields = entry[2]
|
|
1003
|
+
for i = 1, #fields, 2 do
|
|
1004
|
+
if fields[i] == 'id' then
|
|
1005
|
+
batch[#batch + 1] = string.match(entry[1], '^(%d+)')
|
|
1006
|
+
batch[#batch + 1] = fields[i + 1]
|
|
1007
|
+
break
|
|
1008
|
+
end
|
|
1009
|
+
end
|
|
1010
|
+
end
|
|
1011
|
+
return batch
|
|
1012
|
+
end
|
|
1013
|
+
redis.call('DEL', log)
|
|
1014
|
+
redis.call('SREM', session .. 'cleared', log)
|
|
1015
|
+
end
|
|
1016
|
+
sweep_checkpoints(session, boundary)
|
|
1017
|
+
return {}
|
|
1018
|
+
`
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* KEYS: moved log, counter, metadata, metadata split, then one id ownership
|
|
1022
|
+
* key per entry.
|
|
1023
|
+
* ARGV: sessionId, event pin key prefix, n, then per entry: index, id.
|
|
1024
|
+
* Deletes the listed entries below the latest clear and everything that
|
|
1025
|
+
* names them. An id record survives when it names another event, so an id
|
|
1026
|
+
* reused in the new lifetime keeps its owner. Returns [deleted, done].
|
|
1027
|
+
*/
|
|
1028
|
+
const RECLAIM_LUA = `
|
|
1029
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
|
|
1030
|
+
${META_LUA}
|
|
1031
|
+
${SWEEP_CHECKPOINTS_LUA}
|
|
1032
|
+
local function lane_field(lane, suffix)
|
|
1033
|
+
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
1034
|
+
end
|
|
1035
|
+
local session = string.sub(COUNTER, 1, -6)
|
|
1036
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
1037
|
+
use_split(counts[2], counts[1], counts[3])
|
|
1038
|
+
local fields, id_keys, indexes, stream_ids, pin_keys, deleted = {}, {}, {}, {}, {}, {}
|
|
1039
|
+
-- A concurrent reclaim may list the same batch; only XDEL says who removed it.
|
|
1040
|
+
local removed = 0
|
|
1041
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
1042
|
+
local field = ARGV[3 + (i - 1) * 2 + 1]
|
|
1043
|
+
if tonumber(field) < boundary then
|
|
1044
|
+
fields[#fields + 1] = field
|
|
1045
|
+
id_keys[#fields] = KEYS[4 + i]
|
|
1046
|
+
indexes[#fields] = tonumber(field)
|
|
1047
|
+
stream_ids[#fields] = field .. '-0'
|
|
1048
|
+
pin_keys[#fields] = ARGV[2] .. field
|
|
1049
|
+
deleted[field] = true
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
1052
|
+
if #fields > 0 then
|
|
1053
|
+
local lanes, seen, pointers = read_fields(fields, { 'l' }), {}, {}
|
|
1054
|
+
for _, field in ipairs(fields) do
|
|
1055
|
+
local lane = lanes[field][1]
|
|
1056
|
+
if lane and lane ~= '' and not seen[lane] then
|
|
1057
|
+
seen[lane] = true
|
|
1058
|
+
pointers[#pointers + 1] = lane_field(lane, 'head')
|
|
1059
|
+
pointers[#pointers + 1] = lane_field(lane, 'tail')
|
|
1060
|
+
end
|
|
1061
|
+
end
|
|
1062
|
+
if #pointers > 0 then
|
|
1063
|
+
local values = redis.call('HMGET', META, unpack(pointers))
|
|
1064
|
+
local stale = {}
|
|
1065
|
+
for i = 1, #pointers do
|
|
1066
|
+
if values[i] and deleted[values[i]] then stale[#stale + 1] = pointers[i] end
|
|
1067
|
+
end
|
|
1068
|
+
if #stale > 0 then redis.call('HDEL', META, unpack(stale)) end
|
|
1069
|
+
end
|
|
1070
|
+
local suffixes = { 'l', 'next', 'p', 'pa', 'ri', 'ch', 'ce', 'f', 'fc', 'lc', 'lf', 'lfa', 'd', 'a', 'e' }
|
|
1071
|
+
local columns, order = {}, {}
|
|
1072
|
+
for _, field in ipairs(fields) do
|
|
1073
|
+
local key = mkey(field)
|
|
1074
|
+
if not columns[key] then
|
|
1075
|
+
columns[key] = {}
|
|
1076
|
+
order[#order + 1] = key
|
|
1077
|
+
end
|
|
1078
|
+
for _, suffix in ipairs(suffixes) do
|
|
1079
|
+
columns[key][#columns[key] + 1] = field .. ':' .. suffix
|
|
1080
|
+
end
|
|
1081
|
+
end
|
|
1082
|
+
for _, key in ipairs(order) do sliced('HDEL', key, columns[key]) end
|
|
1083
|
+
sliced('DEL', nil, pin_keys)
|
|
1084
|
+
local owners, stale_ids = sliced('MGET', nil, id_keys), {}
|
|
1085
|
+
for i = 1, #fields do
|
|
1086
|
+
if owners[i] then
|
|
1087
|
+
local rec = cjson.decode(owners[i])
|
|
1088
|
+
if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
|
|
1089
|
+
stale_ids[#stale_ids + 1] = id_keys[i]
|
|
1090
|
+
end
|
|
1091
|
+
end
|
|
1092
|
+
end
|
|
1093
|
+
if #stale_ids > 0 then sliced('DEL', nil, stale_ids) end
|
|
1094
|
+
removed = redis.call('XDEL', KEYS[1], unpack(stream_ids))
|
|
1095
|
+
end
|
|
1096
|
+
local done = 0
|
|
1097
|
+
if redis.call('XLEN', KEYS[1]) == 0 then
|
|
1098
|
+
redis.call('DEL', KEYS[1])
|
|
1099
|
+
redis.call('SREM', session .. 'cleared', KEYS[1])
|
|
1100
|
+
if redis.call('SCARD', session .. 'cleared') == 0 then
|
|
1101
|
+
sweep_checkpoints(session, boundary)
|
|
1102
|
+
done = 1
|
|
1103
|
+
end
|
|
1104
|
+
end
|
|
1105
|
+
return { removed, done }
|
|
629
1106
|
`
|
|
630
1107
|
|
|
631
1108
|
// ── wire helpers ─────────────────────────────────────────────────────
|
|
632
1109
|
|
|
633
|
-
type StreamEntry = [id: string, fields: string[]]
|
|
1110
|
+
export type StreamEntry = [id: string, fields: string[]]
|
|
634
1111
|
|
|
635
1112
|
const fieldMap = (fields: string[]): Record<string, string> => {
|
|
636
1113
|
const map: Record<string, string> = {}
|
|
@@ -671,6 +1148,66 @@ const toEvent = (sessionId: string, entry: StreamEntry): Event => {
|
|
|
671
1148
|
}
|
|
672
1149
|
}
|
|
673
1150
|
|
|
1151
|
+
type ByteStreamEntry = [Uint8Array, Uint8Array[]]
|
|
1152
|
+
|
|
1153
|
+
const fieldBytes = (name: string): Uint8Array => new TextEncoder().encode(name)
|
|
1154
|
+
const ID_FIELD = fieldBytes('id')
|
|
1155
|
+
const TYPE_FIELD = fieldBytes('type')
|
|
1156
|
+
const PAYLOAD_FIELD = fieldBytes('payload')
|
|
1157
|
+
const CREATED_AT_FIELD = fieldBytes('created_at')
|
|
1158
|
+
// A leading U+FEFF is part of a stored id or type, never a byte-order mark.
|
|
1159
|
+
const utf8 = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
1160
|
+
|
|
1161
|
+
const isField = (name: Uint8Array, field: Uint8Array): boolean => {
|
|
1162
|
+
if (name.length !== field.length) return false
|
|
1163
|
+
for (let at = 0; at < field.length; at++)
|
|
1164
|
+
if (name[at] !== field[at]) return false
|
|
1165
|
+
return true
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
const bytesToSerializedEvent = (
|
|
1169
|
+
sessionId: string,
|
|
1170
|
+
entry: ByteStreamEntry,
|
|
1171
|
+
): SerializedEvent => {
|
|
1172
|
+
const values = entry[1]
|
|
1173
|
+
let id: string | undefined
|
|
1174
|
+
let type: string | undefined
|
|
1175
|
+
let payloadJson: string | undefined
|
|
1176
|
+
let createdAt: string | undefined
|
|
1177
|
+
for (let at = 0; at + 1 < values.length; at += 2) {
|
|
1178
|
+
const name = values[at]!
|
|
1179
|
+
if (isField(name, ID_FIELD)) id = utf8.decode(values[at + 1])
|
|
1180
|
+
else if (isField(name, TYPE_FIELD)) type = utf8.decode(values[at + 1])
|
|
1181
|
+
else if (isField(name, PAYLOAD_FIELD))
|
|
1182
|
+
payloadJson = utf8.decode(values[at + 1])
|
|
1183
|
+
else if (isField(name, CREATED_AT_FIELD))
|
|
1184
|
+
createdAt = utf8.decode(values[at + 1])
|
|
1185
|
+
}
|
|
1186
|
+
return {
|
|
1187
|
+
id: id!,
|
|
1188
|
+
type: type!,
|
|
1189
|
+
payloadJson: payloadJson!,
|
|
1190
|
+
index: entryIndex(utf8.decode(entry[0])),
|
|
1191
|
+
sessionId,
|
|
1192
|
+
createdAtMs: Number(createdAt),
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
const toSerializedEvent = (
|
|
1197
|
+
sessionId: string,
|
|
1198
|
+
entry: StreamEntry,
|
|
1199
|
+
): SerializedEvent => {
|
|
1200
|
+
const fields = fieldMap(entry[1])
|
|
1201
|
+
return {
|
|
1202
|
+
id: fields['id']!,
|
|
1203
|
+
type: fields['type']!,
|
|
1204
|
+
payloadJson: fields['payload']!,
|
|
1205
|
+
index: entryIndex(entry[0]),
|
|
1206
|
+
sessionId,
|
|
1207
|
+
createdAtMs: Number(fields['created_at']),
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
|
|
674
1211
|
const json = (value: unknown): string => JSON.stringify(value) ?? 'null'
|
|
675
1212
|
|
|
676
1213
|
const toCause = (raw: string | undefined): EventCause | null => {
|
|
@@ -744,6 +1281,9 @@ const toClaimAvailableResult = (
|
|
|
744
1281
|
}),
|
|
745
1282
|
)
|
|
746
1283
|
}
|
|
1284
|
+
const clearIndex = Number(reply[2 + count * 10] ?? 0)
|
|
1285
|
+
if (clearIndex > 0)
|
|
1286
|
+
for (const event of events) event.clearIndex = clearIndex
|
|
747
1287
|
return {
|
|
748
1288
|
outcome: 'claimed',
|
|
749
1289
|
events,
|
|
@@ -758,6 +1298,57 @@ const toClaimAvailableResult = (
|
|
|
758
1298
|
)
|
|
759
1299
|
}
|
|
760
1300
|
|
|
1301
|
+
const toIndex = ({
|
|
1302
|
+
raw,
|
|
1303
|
+
sessionId,
|
|
1304
|
+
name,
|
|
1305
|
+
}: {
|
|
1306
|
+
raw: unknown
|
|
1307
|
+
sessionId: string
|
|
1308
|
+
name: string
|
|
1309
|
+
}): number => {
|
|
1310
|
+
const index = Number(raw ?? 0)
|
|
1311
|
+
if (!Number.isSafeInteger(index) || index < 0) {
|
|
1312
|
+
throw new A2Error(
|
|
1313
|
+
'STORE_UNAVAILABLE',
|
|
1314
|
+
`redis ${name} for session '${sessionId}' is invalid`,
|
|
1315
|
+
)
|
|
1316
|
+
}
|
|
1317
|
+
return index
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* A range read that met a gap. A clear moves the old lifetime's log aside, so
|
|
1322
|
+
* a read whose bounds predate it finds nothing where it expected events;
|
|
1323
|
+
* anywhere else a gap is corruption.
|
|
1324
|
+
*/
|
|
1325
|
+
const rangeGap = (sessionId: string, message: string): A2Error =>
|
|
1326
|
+
Object.assign(
|
|
1327
|
+
new A2Error(
|
|
1328
|
+
'STORE_UNAVAILABLE',
|
|
1329
|
+
`redis event range for session '${sessionId}' ${message}`,
|
|
1330
|
+
),
|
|
1331
|
+
{ rangeGap: true },
|
|
1332
|
+
)
|
|
1333
|
+
|
|
1334
|
+
const isRangeGap = (error: unknown): boolean =>
|
|
1335
|
+
error instanceof A2Error &&
|
|
1336
|
+
(error as { rangeGap?: boolean }).rangeGap === true
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Run a whole read, bounds included. A gap means a clear raced it, so the
|
|
1340
|
+
* read runs once more from fresh bounds. A second gap, from another clear or
|
|
1341
|
+
* from corruption, surfaces as the retryable STORE_UNAVAILABLE.
|
|
1342
|
+
*/
|
|
1343
|
+
const retryAfterGap = async <T>(read: () => Promise<T>): Promise<T> => {
|
|
1344
|
+
try {
|
|
1345
|
+
return await read()
|
|
1346
|
+
} catch (error) {
|
|
1347
|
+
if (!isRangeGap(error)) throw error
|
|
1348
|
+
return read()
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
|
|
761
1352
|
const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
762
1353
|
try {
|
|
763
1354
|
return await fn()
|
|
@@ -772,13 +1363,16 @@ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
|
772
1363
|
export function createRedisStoreCore(
|
|
773
1364
|
options: RedisStoreCoreOptions,
|
|
774
1365
|
): RedisStoreCore {
|
|
775
|
-
const { call, clock } = options
|
|
1366
|
+
const { call, callBytes, clock } = options
|
|
776
1367
|
const generateId = options.ids
|
|
777
1368
|
const prefix = options.keyPrefix
|
|
778
1369
|
|
|
779
1370
|
const storeKey = (s: string): string => `${prefix}:${s}:log`
|
|
780
1371
|
const countKey = (s: string): string => `${prefix}:${s}:count`
|
|
781
1372
|
const metaKey = (s: string): string => `${prefix}:${s}:meta`
|
|
1373
|
+
const metaChunkKey = (s: string, index: number): string =>
|
|
1374
|
+
`${metaKey(s)}:${Math.floor((index - 1) / META_CHUNK_SIZE)}`
|
|
1375
|
+
const metaSplitKey = (s: string): string => `${prefix}:${s}:meta-split`
|
|
782
1376
|
const pendingKey = (s: string): string => `${prefix}:${s}:pending`
|
|
783
1377
|
const readyKey = (s: string): string => `${prefix}:${s}:ready`
|
|
784
1378
|
const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
|
|
@@ -790,9 +1384,15 @@ export function createRedisStoreCore(
|
|
|
790
1384
|
`${prefix}:${s}:snapshot-pins:`
|
|
791
1385
|
const snapshotPinCountsKey = (s: string): string =>
|
|
792
1386
|
`${prefix}:${s}:snapshot-pin-counts`
|
|
793
|
-
const watchedKey = (s: string): string => `${prefix}:${s}:watched`
|
|
794
1387
|
const presenceKey = (s: string): string => `${prefix}:${s}:presence`
|
|
795
|
-
|
|
1388
|
+
// Scripts derive this from the counter key; keep the two in step.
|
|
1389
|
+
const clearKey = (s: string): string => `${prefix}:${s}:clear`
|
|
1390
|
+
// One ownership key per event id; reclaim deletes those naming cleared events.
|
|
1391
|
+
// Session ids are raw and may contain colons, so a raw id could name a
|
|
1392
|
+
// session key (`ids:count`). A JSON string with escaped colons starts with
|
|
1393
|
+
// a quote and has no colon, so it never matches a session key's suffix.
|
|
1394
|
+
const idKey = (id: string): string =>
|
|
1395
|
+
`${prefix}:ids:${JSON.stringify(id).replaceAll(':', '\\u003a')}`
|
|
796
1396
|
|
|
797
1397
|
const evalScript = (
|
|
798
1398
|
script: string,
|
|
@@ -800,23 +1400,37 @@ export function createRedisStoreCore(
|
|
|
800
1400
|
args: Array<string | number>,
|
|
801
1401
|
): Promise<unknown> => call('EVAL', script, keys.length, ...keys, ...args)
|
|
802
1402
|
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
)
|
|
1403
|
+
const readRangeBounds = async (
|
|
1404
|
+
sessionId: string,
|
|
1405
|
+
): Promise<{ frontier: number; metaSplit: number; clearIndex: number }> => {
|
|
1406
|
+
const [count, split, cleared] = (await call(
|
|
1407
|
+
'MGET',
|
|
1408
|
+
countKey(sessionId),
|
|
1409
|
+
metaSplitKey(sessionId),
|
|
1410
|
+
clearKey(sessionId),
|
|
1411
|
+
)) as [unknown, unknown, unknown]
|
|
1412
|
+
const frontier = toIndex({ raw: count, sessionId, name: 'event count' })
|
|
1413
|
+
return {
|
|
1414
|
+
frontier,
|
|
1415
|
+
metaSplit:
|
|
1416
|
+
split === null
|
|
1417
|
+
? frontier
|
|
1418
|
+
: toIndex({ raw: split, sessionId, name: 'metadata split' }),
|
|
1419
|
+
clearIndex: toIndex({ raw: cleared, sessionId, name: 'clear boundary' }),
|
|
810
1420
|
}
|
|
811
|
-
return frontier
|
|
812
1421
|
}
|
|
813
1422
|
|
|
814
|
-
const readRangePage = async (
|
|
815
|
-
sessionId
|
|
816
|
-
afterIndex
|
|
817
|
-
throughIndex
|
|
818
|
-
|
|
819
|
-
|
|
1423
|
+
const readRangePage = async ({
|
|
1424
|
+
sessionId,
|
|
1425
|
+
afterIndex,
|
|
1426
|
+
throughIndex,
|
|
1427
|
+
metaSplit,
|
|
1428
|
+
}: {
|
|
1429
|
+
sessionId: string
|
|
1430
|
+
afterIndex: number
|
|
1431
|
+
throughIndex: number
|
|
1432
|
+
metaSplit: number
|
|
1433
|
+
}): Promise<StoredEvent[]> => {
|
|
820
1434
|
if (throughIndex <= afterIndex) return []
|
|
821
1435
|
const entries = (await call(
|
|
822
1436
|
'XRANGE',
|
|
@@ -824,23 +1438,36 @@ export function createRedisStoreCore(
|
|
|
824
1438
|
`${afterIndex + 1}-0`,
|
|
825
1439
|
`${throughIndex}-0`,
|
|
826
1440
|
'COUNT',
|
|
827
|
-
|
|
1441
|
+
READ_PAGE_SIZE,
|
|
828
1442
|
)) as StreamEntry[]
|
|
829
1443
|
if (entries.length === 0) return []
|
|
830
1444
|
|
|
831
|
-
const
|
|
1445
|
+
const fieldsByKey = new Map<string, string[]>()
|
|
1446
|
+
for (const entry of entries) {
|
|
832
1447
|
const index = entryIndex(entry[0])
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
(
|
|
837
|
-
|
|
838
|
-
|
|
1448
|
+
const key =
|
|
1449
|
+
index <= metaSplit ? metaKey(sessionId) : metaChunkKey(sessionId, index)
|
|
1450
|
+
const fields = fieldsByKey.get(key) ?? []
|
|
1451
|
+
fields.push(
|
|
1452
|
+
...STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`),
|
|
1453
|
+
)
|
|
1454
|
+
fieldsByKey.set(key, fields)
|
|
1455
|
+
}
|
|
1456
|
+
const lookups = [...fieldsByKey]
|
|
1457
|
+
const results = await Promise.allSettled(
|
|
1458
|
+
lookups.map(([key, fields]) => call('HMGET', key, ...fields)),
|
|
1459
|
+
)
|
|
839
1460
|
const meta: Record<string, string> = {}
|
|
840
|
-
for (let
|
|
841
|
-
const
|
|
842
|
-
if (
|
|
843
|
-
|
|
1461
|
+
for (let position = 0; position < lookups.length; position += 1) {
|
|
1462
|
+
const result = results[position]!
|
|
1463
|
+
if (result.status === 'rejected') throw result.reason
|
|
1464
|
+
const fields = lookups[position]![1]
|
|
1465
|
+
const values = (result.value as Array<string | null> | null) ?? []
|
|
1466
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
1467
|
+
const value = values[i]
|
|
1468
|
+
if (value !== null && value !== undefined) {
|
|
1469
|
+
meta[fields[i]!] = value
|
|
1470
|
+
}
|
|
844
1471
|
}
|
|
845
1472
|
}
|
|
846
1473
|
return entries.map((entry) => {
|
|
@@ -910,18 +1537,12 @@ export function createRedisStoreCore(
|
|
|
910
1537
|
READ_PAGE_SIZE,
|
|
911
1538
|
)) as StreamEntry[]
|
|
912
1539
|
if (entries.length === 0) {
|
|
913
|
-
throw
|
|
914
|
-
'STORE_UNAVAILABLE',
|
|
915
|
-
`redis event range for session '${sessionId}' ended before index ${throughIndex}`,
|
|
916
|
-
)
|
|
1540
|
+
throw rangeGap(sessionId, `ended before index ${throughIndex}`)
|
|
917
1541
|
}
|
|
918
1542
|
for (let position = 0; position < entries.length; position += 1) {
|
|
919
1543
|
const index = entryIndex(entries[position]![0])
|
|
920
1544
|
if (index !== nextIndex + position || index > throughIndex) {
|
|
921
|
-
throw
|
|
922
|
-
'STORE_UNAVAILABLE',
|
|
923
|
-
`redis event range for session '${sessionId}' is not contiguous`,
|
|
924
|
-
)
|
|
1545
|
+
throw rangeGap(sessionId, 'is not contiguous')
|
|
925
1546
|
}
|
|
926
1547
|
}
|
|
927
1548
|
events.push(...entries.map((entry) => toEvent(sessionId, entry)))
|
|
@@ -972,34 +1593,43 @@ export function createRedisStoreCore(
|
|
|
972
1593
|
sessionId: string,
|
|
973
1594
|
afterIndex: number,
|
|
974
1595
|
throughIndex?: number,
|
|
975
|
-
): Promise<StoredEvent[]> =>
|
|
976
|
-
|
|
977
|
-
|
|
1596
|
+
): Promise<StoredEvent[]> =>
|
|
1597
|
+
(await readBoundedRange(sessionId, afterIndex, throughIndex)).rows
|
|
1598
|
+
|
|
1599
|
+
/** `readRange`, with the clear boundary its rows start at. */
|
|
1600
|
+
const readBoundedRange = async (
|
|
1601
|
+
sessionId: string,
|
|
1602
|
+
afterIndex: number,
|
|
1603
|
+
throughIndex?: number,
|
|
1604
|
+
): Promise<{ rows: StoredEvent[]; clearIndex: number }> => {
|
|
1605
|
+
if (throughIndex !== undefined && throughIndex <= afterIndex)
|
|
1606
|
+
return { rows: [], clearIndex: 0 }
|
|
1607
|
+
const {
|
|
1608
|
+
frontier: currentFrontier,
|
|
1609
|
+
metaSplit,
|
|
1610
|
+
clearIndex,
|
|
1611
|
+
} = await readRangeBounds(sessionId)
|
|
978
1612
|
const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier)
|
|
979
|
-
|
|
1613
|
+
// Reads start at the latest clear marker.
|
|
1614
|
+
const after = Math.max(afterIndex, clearIndex - 1)
|
|
1615
|
+
if (frontier <= after) return { rows: [], clearIndex }
|
|
980
1616
|
|
|
981
1617
|
const rows: StoredEvent[] = []
|
|
982
|
-
let nextIndex =
|
|
1618
|
+
let nextIndex = after + 1
|
|
983
1619
|
while (nextIndex <= frontier) {
|
|
984
1620
|
// oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range
|
|
985
|
-
const page = await readRangePage(
|
|
1621
|
+
const page = await readRangePage({
|
|
986
1622
|
sessionId,
|
|
987
|
-
nextIndex - 1,
|
|
988
|
-
frontier,
|
|
989
|
-
|
|
990
|
-
)
|
|
1623
|
+
afterIndex: nextIndex - 1,
|
|
1624
|
+
throughIndex: frontier,
|
|
1625
|
+
metaSplit,
|
|
1626
|
+
})
|
|
991
1627
|
if (page.length === 0) {
|
|
992
|
-
throw
|
|
993
|
-
'STORE_UNAVAILABLE',
|
|
994
|
-
`redis event range for session '${sessionId}' ended before index ${frontier}`,
|
|
995
|
-
)
|
|
1628
|
+
throw rangeGap(sessionId, `ended before index ${frontier}`)
|
|
996
1629
|
}
|
|
997
1630
|
for (let position = 0; position < page.length; position += 1) {
|
|
998
1631
|
if (page[position]!.index !== nextIndex + position) {
|
|
999
|
-
throw
|
|
1000
|
-
'STORE_UNAVAILABLE',
|
|
1001
|
-
`redis event range for session '${sessionId}' is not contiguous`,
|
|
1002
|
-
)
|
|
1632
|
+
throw rangeGap(sessionId, 'is not contiguous')
|
|
1003
1633
|
}
|
|
1004
1634
|
}
|
|
1005
1635
|
const lastIndex = page.at(-1)!.index
|
|
@@ -1012,11 +1642,45 @@ export function createRedisStoreCore(
|
|
|
1012
1642
|
rows.push(...page)
|
|
1013
1643
|
nextIndex = lastIndex + 1
|
|
1014
1644
|
}
|
|
1015
|
-
return rows
|
|
1645
|
+
return { rows, clearIndex }
|
|
1016
1646
|
}
|
|
1017
1647
|
|
|
1648
|
+
const readStream = options.readStream
|
|
1018
1649
|
return {
|
|
1019
|
-
|
|
1650
|
+
...(readStream
|
|
1651
|
+
? {
|
|
1652
|
+
streamEvents: async function* ({
|
|
1653
|
+
sessionId,
|
|
1654
|
+
afterIndex,
|
|
1655
|
+
signal,
|
|
1656
|
+
}: {
|
|
1657
|
+
sessionId: string
|
|
1658
|
+
afterIndex: number
|
|
1659
|
+
signal: AbortSignal
|
|
1660
|
+
}): AsyncGenerator<SerializedEvent[], void> {
|
|
1661
|
+
try {
|
|
1662
|
+
for await (const entries of readStream({
|
|
1663
|
+
key: storeKey(sessionId),
|
|
1664
|
+
from: `${afterIndex + 1}-0`,
|
|
1665
|
+
signal,
|
|
1666
|
+
})) {
|
|
1667
|
+
signal.throwIfAborted()
|
|
1668
|
+
yield entries.map((entry) =>
|
|
1669
|
+
toSerializedEvent(sessionId, entry),
|
|
1670
|
+
)
|
|
1671
|
+
}
|
|
1672
|
+
} catch (cause) {
|
|
1673
|
+
if (cause instanceof A2Error) throw cause
|
|
1674
|
+
throw new A2Error(
|
|
1675
|
+
'STORE_UNAVAILABLE',
|
|
1676
|
+
'redis stream read failed',
|
|
1677
|
+
{ cause },
|
|
1678
|
+
)
|
|
1679
|
+
}
|
|
1680
|
+
},
|
|
1681
|
+
}
|
|
1682
|
+
: {}),
|
|
1683
|
+
async append(sessionId, events, appendOptions) {
|
|
1020
1684
|
return wrap(async () => {
|
|
1021
1685
|
const supplied = events.filter((e) => e.id !== undefined)
|
|
1022
1686
|
const suppliedIds = supplied.map((e) => e.id as string)
|
|
@@ -1048,17 +1712,18 @@ export function createRedisStoreCore(
|
|
|
1048
1712
|
e.settled ? '1' : '0',
|
|
1049
1713
|
]),
|
|
1050
1714
|
`${prefix}:${sessionId}:notify`,
|
|
1715
|
+
appendOptions?.originIndex ?? '',
|
|
1051
1716
|
]
|
|
1052
1717
|
const reply = (await evalScript(
|
|
1053
1718
|
APPEND_LUA,
|
|
1054
1719
|
[
|
|
1055
1720
|
storeKey(sessionId),
|
|
1056
1721
|
countKey(sessionId),
|
|
1057
|
-
idsKey,
|
|
1058
1722
|
metaKey(sessionId),
|
|
1059
1723
|
pendingKey(sessionId),
|
|
1060
1724
|
readyKey(sessionId),
|
|
1061
|
-
|
|
1725
|
+
metaSplitKey(sessionId),
|
|
1726
|
+
...withIds.map((e) => idKey(e.id)),
|
|
1062
1727
|
],
|
|
1063
1728
|
args,
|
|
1064
1729
|
)) as string[]
|
|
@@ -1078,6 +1743,11 @@ export function createRedisStoreCore(
|
|
|
1078
1743
|
`no event at index ${reply[1]} in session '${sessionId}'`,
|
|
1079
1744
|
)
|
|
1080
1745
|
}
|
|
1746
|
+
if (verdict === 'cleared') {
|
|
1747
|
+
throw sessionClearedError(
|
|
1748
|
+
`scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`,
|
|
1749
|
+
)
|
|
1750
|
+
}
|
|
1081
1751
|
if (verdict === 'superseded') {
|
|
1082
1752
|
throw new A2Error(
|
|
1083
1753
|
'SUPERSEDED_ATTEMPT',
|
|
@@ -1091,13 +1761,23 @@ export function createRedisStoreCore(
|
|
|
1091
1761
|
)
|
|
1092
1762
|
}
|
|
1093
1763
|
if (verdict === 'dup') {
|
|
1094
|
-
// A retry of a committed batch whose ack was lost — return
|
|
1095
|
-
// the original rows, ordered by index like every adapter.
|
|
1096
1764
|
const hasPending = reply[1] === '1'
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
const rows = await
|
|
1100
|
-
|
|
1765
|
+
const firstIndex = Math.min(...reply.slice(2).map(Number))
|
|
1766
|
+
// A valid retry occupies exactly events.length consecutive indexes.
|
|
1767
|
+
const { rows: existing, clearIndex } = await retryAfterGap(() =>
|
|
1768
|
+
readBoundedRange(
|
|
1769
|
+
sessionId,
|
|
1770
|
+
firstIndex - 1,
|
|
1771
|
+
firstIndex + events.length - 1,
|
|
1772
|
+
),
|
|
1773
|
+
)
|
|
1774
|
+
if (firstIndex < clearIndex) {
|
|
1775
|
+
// Cleared after the script matched the retry: the ids are free.
|
|
1776
|
+
throw new A2Error(
|
|
1777
|
+
'STORE_UNAVAILABLE',
|
|
1778
|
+
`session '${sessionId}' was cleared while an append retry replayed; retry it`,
|
|
1779
|
+
)
|
|
1780
|
+
}
|
|
1101
1781
|
return {
|
|
1102
1782
|
events: idempotentReplay(events, existing),
|
|
1103
1783
|
hasPending,
|
|
@@ -1134,28 +1814,42 @@ export function createRedisStoreCore(
|
|
|
1134
1814
|
},
|
|
1135
1815
|
|
|
1136
1816
|
async read(sessionId, opts) {
|
|
1137
|
-
return wrap(
|
|
1138
|
-
|
|
1139
|
-
sessionId,
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
)
|
|
1143
|
-
return rows
|
|
1144
|
-
})
|
|
1817
|
+
return wrap(() =>
|
|
1818
|
+
retryAfterGap(() =>
|
|
1819
|
+
readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex),
|
|
1820
|
+
),
|
|
1821
|
+
)
|
|
1145
1822
|
},
|
|
1146
1823
|
|
|
1147
1824
|
async readHistory(sessionId, opts) {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1825
|
+
const after = opts?.afterIndex ?? 0
|
|
1826
|
+
if (opts?.throughIndex !== undefined && opts.throughIndex <= after)
|
|
1827
|
+
return []
|
|
1828
|
+
return wrap(() =>
|
|
1829
|
+
retryAfterGap(async () => {
|
|
1830
|
+
const [count, cleared] = (await call(
|
|
1831
|
+
'MGET',
|
|
1832
|
+
countKey(sessionId),
|
|
1833
|
+
clearKey(sessionId),
|
|
1834
|
+
)) as [unknown, unknown]
|
|
1835
|
+
const frontier = toIndex({
|
|
1836
|
+
raw: count,
|
|
1837
|
+
sessionId,
|
|
1838
|
+
name: 'event count',
|
|
1839
|
+
})
|
|
1840
|
+
const clearIndex = toIndex({
|
|
1841
|
+
raw: cleared,
|
|
1842
|
+
sessionId,
|
|
1843
|
+
name: 'clear boundary',
|
|
1844
|
+
})
|
|
1845
|
+
// Reads start at the latest clear marker.
|
|
1846
|
+
return readEventRange(
|
|
1847
|
+
sessionId,
|
|
1848
|
+
Math.max(after, clearIndex - 1),
|
|
1849
|
+
Math.min(opts?.throughIndex ?? frontier, frontier),
|
|
1850
|
+
)
|
|
1851
|
+
}),
|
|
1852
|
+
)
|
|
1159
1853
|
},
|
|
1160
1854
|
|
|
1161
1855
|
async claimAvailable({
|
|
@@ -1177,6 +1871,7 @@ export function createRedisStoreCore(
|
|
|
1177
1871
|
metaKey(sessionId),
|
|
1178
1872
|
pendingKey(sessionId),
|
|
1179
1873
|
readyKey(sessionId),
|
|
1874
|
+
metaSplitKey(sessionId),
|
|
1180
1875
|
],
|
|
1181
1876
|
[holder, now, expiresAt, excluded.length, ...excluded],
|
|
1182
1877
|
)) as unknown[]
|
|
@@ -1190,7 +1885,7 @@ export function createRedisStoreCore(
|
|
|
1190
1885
|
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
1191
1886
|
const reply = (await evalScript(
|
|
1192
1887
|
RENEW_CLAIMS_LUA,
|
|
1193
|
-
[countKey(sessionId), metaKey(sessionId)],
|
|
1888
|
+
[countKey(sessionId), metaKey(sessionId), metaSplitKey(sessionId)],
|
|
1194
1889
|
[
|
|
1195
1890
|
holder,
|
|
1196
1891
|
now,
|
|
@@ -1223,13 +1918,13 @@ export function createRedisStoreCore(
|
|
|
1223
1918
|
[
|
|
1224
1919
|
storeKey(sessionId),
|
|
1225
1920
|
countKey(sessionId),
|
|
1226
|
-
idsKey,
|
|
1227
1921
|
metaKey(sessionId),
|
|
1228
1922
|
pendingKey(sessionId),
|
|
1229
1923
|
readyKey(sessionId),
|
|
1230
|
-
watchedKey(sessionId),
|
|
1231
1924
|
snapshotEventPinsKey(sessionId, index),
|
|
1232
1925
|
snapshotPinCountsKey(sessionId),
|
|
1926
|
+
metaSplitKey(sessionId),
|
|
1927
|
+
...ids.map((id) => idKey(id)),
|
|
1233
1928
|
],
|
|
1234
1929
|
[
|
|
1235
1930
|
sessionId,
|
|
@@ -1295,13 +1990,16 @@ export function createRedisStoreCore(
|
|
|
1295
1990
|
if (events.length === 0) {
|
|
1296
1991
|
return { outcome: 'completed', events: [] }
|
|
1297
1992
|
}
|
|
1298
|
-
const
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1993
|
+
const firstIndex = Math.min(...reply.slice(1).map(Number))
|
|
1994
|
+
const { rows: existing, clearIndex } = await retryAfterGap(() =>
|
|
1995
|
+
readBoundedRange(
|
|
1996
|
+
sessionId,
|
|
1997
|
+
firstIndex - 1,
|
|
1998
|
+
firstIndex + events.length - 1,
|
|
1999
|
+
),
|
|
2000
|
+
)
|
|
2001
|
+
// Cleared after the script matched the retry.
|
|
2002
|
+
if (index <= clearIndex) return { outcome: 'superseded' }
|
|
1305
2003
|
return {
|
|
1306
2004
|
outcome: 'completed',
|
|
1307
2005
|
events: idempotentReplay(events, existing),
|
|
@@ -1322,10 +2020,13 @@ export function createRedisStoreCore(
|
|
|
1322
2020
|
readyKey(sessionId),
|
|
1323
2021
|
snapshotEventPinsKey(sessionId, index),
|
|
1324
2022
|
snapshotPinCountsKey(sessionId),
|
|
2023
|
+
metaSplitKey(sessionId),
|
|
1325
2024
|
],
|
|
1326
2025
|
[index, attempt, error, maxFailures, clock.now().getTime()],
|
|
1327
2026
|
)) as string[]
|
|
1328
2027
|
const outcome = reply[0]
|
|
2028
|
+
if (outcome === 'cleared')
|
|
2029
|
+
return { outcome: 'superseded', failureCount: 0 }
|
|
1329
2030
|
if (outcome === 'missing') {
|
|
1330
2031
|
throw new TypeError(
|
|
1331
2032
|
`no event at index ${index} in session '${sessionId}'`,
|
|
@@ -1339,59 +2040,64 @@ export function createRedisStoreCore(
|
|
|
1339
2040
|
},
|
|
1340
2041
|
|
|
1341
2042
|
async readState(sessionId, reducerName, stateOptions) {
|
|
1342
|
-
return wrap(
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
stateOptions?.
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
(
|
|
1369
|
-
|
|
1370
|
-
(
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
2043
|
+
return wrap(() =>
|
|
2044
|
+
retryAfterGap(async () => {
|
|
2045
|
+
const reply = (await evalScript(
|
|
2046
|
+
READ_STATE_LUA,
|
|
2047
|
+
[
|
|
2048
|
+
snapKey(sessionId, reducerName),
|
|
2049
|
+
snapHistoryKey(sessionId, reducerName),
|
|
2050
|
+
countKey(sessionId),
|
|
2051
|
+
],
|
|
2052
|
+
[
|
|
2053
|
+
stateOptions?.snapshotThroughIndex ??
|
|
2054
|
+
stateOptions?.throughIndex ??
|
|
2055
|
+
'',
|
|
2056
|
+
],
|
|
2057
|
+
)) as [number | string, string, string, string]
|
|
2058
|
+
const rawHead = reply[0]
|
|
2059
|
+
const raw = reply[1]
|
|
2060
|
+
const headIndex = rawHead === '' ? null : Number(rawHead)
|
|
2061
|
+
const parsed =
|
|
2062
|
+
raw === ''
|
|
2063
|
+
? null
|
|
2064
|
+
: (JSON.parse(raw) as { index: number; state: unknown })
|
|
2065
|
+
const currentFrontier = Number(reply[2])
|
|
2066
|
+
if (
|
|
2067
|
+
!Number.isSafeInteger(currentFrontier) ||
|
|
2068
|
+
currentFrontier < 0 ||
|
|
2069
|
+
(headIndex !== null &&
|
|
2070
|
+
(!Number.isSafeInteger(headIndex) || headIndex < 0)) ||
|
|
2071
|
+
(parsed !== null &&
|
|
2072
|
+
(!Number.isSafeInteger(parsed.index) || parsed.index < 0))
|
|
2073
|
+
) {
|
|
2074
|
+
throw new A2Error(
|
|
2075
|
+
'STORE_UNAVAILABLE',
|
|
2076
|
+
`redis state frontier for session '${sessionId}' is invalid`,
|
|
2077
|
+
)
|
|
2078
|
+
}
|
|
2079
|
+
const frontier = Math.min(
|
|
2080
|
+
currentFrontier,
|
|
2081
|
+
stateOptions?.throughIndex ?? currentFrontier,
|
|
1375
2082
|
)
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
})
|
|
2083
|
+
const snapshot = parsed
|
|
2084
|
+
const clearIndex = Number(reply[3] ?? 0)
|
|
2085
|
+
// The fold starts at the latest clear marker when no checkpoint is newer.
|
|
2086
|
+
const events = await readEventRange(
|
|
2087
|
+
sessionId,
|
|
2088
|
+
Math.max(snapshot?.index ?? 0, clearIndex - 1),
|
|
2089
|
+
frontier,
|
|
2090
|
+
)
|
|
2091
|
+
return {
|
|
2092
|
+
...(clearIndex > 0 ? { clearIndex } : {}),
|
|
2093
|
+
headIndex,
|
|
2094
|
+
snapshot: snapshot
|
|
2095
|
+
? { index: snapshot.index, state: snapshot.state }
|
|
2096
|
+
: null,
|
|
2097
|
+
events,
|
|
2098
|
+
}
|
|
2099
|
+
}),
|
|
2100
|
+
)
|
|
1395
2101
|
},
|
|
1396
2102
|
|
|
1397
2103
|
async putSnapshots(sessionId, reducerName, writes) {
|
|
@@ -1431,6 +2137,8 @@ export function createRedisStoreCore(
|
|
|
1431
2137
|
index: write.index,
|
|
1432
2138
|
state: write.state,
|
|
1433
2139
|
updatedAt: clock.now().getTime(),
|
|
2140
|
+
// This reducer is in the session's registry (PUT_SNAPSHOTS_LUA).
|
|
2141
|
+
r: 1,
|
|
1434
2142
|
}),
|
|
1435
2143
|
write.pinEventIndexes.size,
|
|
1436
2144
|
...write.pinEventIndexes,
|
|
@@ -1444,8 +2152,9 @@ export function createRedisStoreCore(
|
|
|
1444
2152
|
countKey(sessionId),
|
|
1445
2153
|
metaKey(sessionId),
|
|
1446
2154
|
snapshotPinCountsKey(sessionId),
|
|
2155
|
+
metaSplitKey(sessionId),
|
|
1447
2156
|
],
|
|
1448
|
-
args,
|
|
2157
|
+
[...args, reducerName],
|
|
1449
2158
|
)
|
|
1450
2159
|
})
|
|
1451
2160
|
},
|
|
@@ -1540,6 +2249,105 @@ export function createRedisStoreCore(
|
|
|
1540
2249
|
return wrap(() => readRange(sessionId, afterIndex, throughIndex))
|
|
1541
2250
|
},
|
|
1542
2251
|
|
|
2252
|
+
async readTail(sessionId, fromIndex, throughIndex) {
|
|
2253
|
+
return wrap(async () => {
|
|
2254
|
+
const newest = (await call(
|
|
2255
|
+
'XREVRANGE',
|
|
2256
|
+
storeKey(sessionId),
|
|
2257
|
+
throughIndex === undefined ? '+' : `${throughIndex}-0`,
|
|
2258
|
+
`${fromIndex}-0`,
|
|
2259
|
+
'COUNT',
|
|
2260
|
+
READ_PAGE_SIZE,
|
|
2261
|
+
)) as StreamEntry[]
|
|
2262
|
+
const page = newest
|
|
2263
|
+
.toReversed()
|
|
2264
|
+
.map((entry) => toEvent(sessionId, entry))
|
|
2265
|
+
for (let position = 1; position < page.length; position += 1) {
|
|
2266
|
+
if (page[position]!.index !== page[position - 1]!.index + 1) {
|
|
2267
|
+
throw new A2Error(
|
|
2268
|
+
'STORE_UNAVAILABLE',
|
|
2269
|
+
`redis event range for session '${sessionId}' is not contiguous`,
|
|
2270
|
+
)
|
|
2271
|
+
}
|
|
2272
|
+
}
|
|
2273
|
+
const oldest = page[0]?.index
|
|
2274
|
+
return oldest === undefined || oldest <= fromIndex ? page : null
|
|
2275
|
+
})
|
|
2276
|
+
},
|
|
2277
|
+
|
|
2278
|
+
async clear({ sessionId, ifLastIndex, cause }) {
|
|
2279
|
+
return wrap(async () => {
|
|
2280
|
+
const markerId = generateId()
|
|
2281
|
+
const reply = (await evalScript(
|
|
2282
|
+
CLEAR_LUA,
|
|
2283
|
+
[
|
|
2284
|
+
storeKey(sessionId),
|
|
2285
|
+
countKey(sessionId),
|
|
2286
|
+
metaKey(sessionId),
|
|
2287
|
+
metaSplitKey(sessionId),
|
|
2288
|
+
idKey(markerId),
|
|
2289
|
+
pendingKey(sessionId),
|
|
2290
|
+
readyKey(sessionId),
|
|
2291
|
+
],
|
|
2292
|
+
[
|
|
2293
|
+
sessionId,
|
|
2294
|
+
clock.now().getTime(),
|
|
2295
|
+
ifLastIndex ?? '',
|
|
2296
|
+
cause?.index ?? '',
|
|
2297
|
+
cause?.attempt ?? '',
|
|
2298
|
+
markerId,
|
|
2299
|
+
`${prefix}:${sessionId}:notify`,
|
|
2300
|
+
],
|
|
2301
|
+
)) as string[]
|
|
2302
|
+
if (reply[0] === 'superseded') {
|
|
2303
|
+
throw supersededAttemptError(sessionId, cause!)
|
|
2304
|
+
}
|
|
2305
|
+
if (reply[0] === 'no_parent') {
|
|
2306
|
+
throw new TypeError(
|
|
2307
|
+
`no event at index ${reply[1]} in session '${sessionId}'`,
|
|
2308
|
+
)
|
|
2309
|
+
}
|
|
2310
|
+
if (reply[0] === 'foreign') {
|
|
2311
|
+
throw new A2Error(
|
|
2312
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
2313
|
+
`the clear marker id '${markerId}' already exists`,
|
|
2314
|
+
)
|
|
2315
|
+
}
|
|
2316
|
+
return {
|
|
2317
|
+
cleared: reply[1] === '1',
|
|
2318
|
+
index: Number(reply[2]),
|
|
2319
|
+
clearIndex: Number(reply[3]),
|
|
2320
|
+
}
|
|
2321
|
+
})
|
|
2322
|
+
},
|
|
2323
|
+
|
|
2324
|
+
async reclaim(sessionId) {
|
|
2325
|
+
return wrap(async () => {
|
|
2326
|
+
const [log, ...pairs] = (await evalScript(
|
|
2327
|
+
RECLAIM_BATCH_LUA,
|
|
2328
|
+
[countKey(sessionId)],
|
|
2329
|
+
[READ_PAGE_SIZE],
|
|
2330
|
+
)) as string[]
|
|
2331
|
+
if (log === undefined) return { deleted: 0, done: true }
|
|
2332
|
+
// Id keys are built here, like append's: they use JSON.stringify,
|
|
2333
|
+
// whose escaping cjson does not reproduce.
|
|
2334
|
+
const ids: string[] = []
|
|
2335
|
+
for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]!)
|
|
2336
|
+
const [deleted, done] = (await evalScript(
|
|
2337
|
+
RECLAIM_LUA,
|
|
2338
|
+
[
|
|
2339
|
+
log,
|
|
2340
|
+
countKey(sessionId),
|
|
2341
|
+
metaKey(sessionId),
|
|
2342
|
+
metaSplitKey(sessionId),
|
|
2343
|
+
...ids.map((id) => idKey(id)),
|
|
2344
|
+
],
|
|
2345
|
+
[sessionId, snapshotEventPinsPrefix(sessionId), ids.length, ...pairs],
|
|
2346
|
+
)) as [number, number]
|
|
2347
|
+
return { deleted: Number(deleted), done: Number(done) === 1 }
|
|
2348
|
+
})
|
|
2349
|
+
},
|
|
2350
|
+
|
|
1543
2351
|
async readEvents(sessionId, afterIndex) {
|
|
1544
2352
|
return wrap(async () => {
|
|
1545
2353
|
const entries = (await call(
|
|
@@ -1553,9 +2361,27 @@ export function createRedisStoreCore(
|
|
|
1553
2361
|
return entries.map((entry) => toEvent(sessionId, entry))
|
|
1554
2362
|
})
|
|
1555
2363
|
},
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
2364
|
+
async readFeedPage(sessionId, afterIndex) {
|
|
2365
|
+
return wrap(async () => {
|
|
2366
|
+
const range = [
|
|
2367
|
+
storeKey(sessionId),
|
|
2368
|
+
`${afterIndex + 1}-0`,
|
|
2369
|
+
'+',
|
|
2370
|
+
'COUNT',
|
|
2371
|
+
FEED_PAGE_SIZE,
|
|
2372
|
+
] as const
|
|
2373
|
+
if (callBytes) {
|
|
2374
|
+
const entries = (await callBytes(
|
|
2375
|
+
'XRANGE',
|
|
2376
|
+
...range,
|
|
2377
|
+
)) as ByteStreamEntry[]
|
|
2378
|
+
return entries.map((entry) =>
|
|
2379
|
+
bytesToSerializedEvent(sessionId, entry),
|
|
2380
|
+
)
|
|
2381
|
+
}
|
|
2382
|
+
const entries = (await call('XRANGE', ...range)) as StreamEntry[]
|
|
2383
|
+
return entries.map((entry) => toSerializedEvent(sessionId, entry))
|
|
2384
|
+
})
|
|
1559
2385
|
},
|
|
1560
2386
|
}
|
|
1561
2387
|
}
|