experimental-a2 0.16.1 → 0.18.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 +373 -0
- package/README.md +1 -1
- package/dist/{actor-ohPC-81x.d.ts → actor-Bjce9_5U.d.ts} +21 -11
- package/dist/actor-Bjce9_5U.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-BpOGm-zB.js} +621 -216
- package/dist/ai-BpOGm-zB.js.map +1 -0
- package/dist/{ai-CrEf6p_W.d.ts → ai-DrUaMyRt.d.ts} +114 -100
- package/dist/ai-DrUaMyRt.d.ts.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-fx.browser.d.ts +1 -0
- package/dist/ai-fx.browser.js +6 -0
- package/dist/ai-fx.browser.js.map +1 -0
- package/dist/ai-fx.d.ts +10 -0
- package/dist/ai-fx.d.ts.map +1 -0
- package/dist/ai-fx.js +634 -0
- package/dist/ai-fx.js.map +1 -0
- package/dist/ai-harness-BuLh_EYi.js +273 -0
- package/dist/ai-harness-BuLh_EYi.js.map +1 -0
- package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
- package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
- package/dist/ai-pi.browser.d.ts +1 -0
- package/dist/ai-pi.browser.js +6 -0
- package/dist/ai-pi.browser.js.map +1 -0
- package/dist/ai-pi.d.ts +17 -0
- package/dist/ai-pi.d.ts.map +1 -0
- package/dist/ai-pi.js +904 -0
- package/dist/ai-pi.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 +109 -43
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +526 -283
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/bounded-cache-CPHWF-8h.js +41 -0
- package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
- package/dist/{client-BYzHjkwU.js → client-BReKqwZC.js} +447 -80
- package/dist/client-BReKqwZC.js.map +1 -0
- package/dist/{client-CzyacQpJ.d.ts → client-h_DKFAjj.d.ts} +14 -13
- package/dist/client-h_DKFAjj.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-Bx-c0pDP.d.ts} +7 -3
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +1 -0
- package/dist/{internal-Dq2qYxou.js → internal-B2T3U4Rq.js} +29 -2
- package/dist/internal-B2T3U4Rq.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/react-BBXgHWFc.d.ts +86 -0
- package/dist/react-BBXgHWFc.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-CqA59Q6U.d.ts} +46 -30
- package/dist/reducer-CqA59Q6U.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-BD5ckxZb.js → server-Dw_k_7kO.js} +904 -219
- package/dist/server-Dw_k_7kO.js.map +1 -0
- package/dist/{server-DbD7IJVK.d.ts → server-En7cbi3v.d.ts} +116 -26
- package/dist/server-En7cbi3v.d.ts.map +1 -0
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/session-clear-Bo-XTFEy.d.ts +12 -0
- package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
- package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
- package/dist/session-clear-DGI4jTQ5.js.map +1 -0
- package/dist/{store-DyZM6fS5.d.ts → store-Djdv0bnZ.d.ts} +73 -3
- package/dist/store-Djdv0bnZ.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 +205 -66
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-CmxUbV93.js → store-polling-B5FcwJfo.js} +21 -4
- package/dist/store-polling-B5FcwJfo.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 +450 -108
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-BAwS8RWn.js} +3 -3
- package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-BAwS8RWn.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-BRITti_o.js +2493 -0
- package/dist/store-redis-notify-BRITti_o.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 +197 -33
- 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-Cyfqo6Qt.d.ts} +2 -2
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
- package/dist/{wire-BO5wWCb1.js → wire-rSUglYo8.js} +132 -10
- package/dist/wire-rSUglYo8.js.map +1 -0
- package/docs/01-quickstart.mdx +50 -84
- package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
- package/docs/actors/01-introduction.mdx +48 -82
- package/docs/actors/02-concurrency.mdx +43 -70
- package/docs/actors/03-timers.mdx +28 -42
- package/docs/actors/04-routes.mdx +110 -137
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +257 -0
- package/docs/agents/02-tools.mdx +247 -0
- package/docs/agents/03-conversations.mdx +538 -0
- package/docs/agents/04-harnesses.mdx +136 -0
- package/docs/agents/meta.ts +1 -0
- package/docs/client/01-react.mdx +294 -0
- package/docs/client/02-transports.mdx +86 -0
- package/docs/client/03-presence.mdx +224 -0
- package/docs/client/meta.ts +1 -0
- package/docs/contract/01-events.mdx +57 -0
- package/docs/contract/02-state.mdx +142 -0
- package/docs/contract/03-sessions.mdx +175 -0
- package/docs/index.mdx +112 -156
- package/docs/reference/01-core.mdx +139 -0
- package/docs/reference/02-server.mdx +855 -0
- package/docs/reference/03-client.mdx +301 -0
- package/docs/reference/04-ai.mdx +722 -0
- package/docs/reference/05-adapters.mdx +351 -0
- package/docs/reference/06-errors.mdx +138 -0
- package/docs/reference/meta.ts +1 -1
- package/docs/server/01-handlers.mdx +200 -0
- package/docs/server/02-timers.mdx +130 -0
- package/docs/server/03-cancellation.mdx +141 -0
- package/docs/server/04-routes.mdx +243 -0
- package/docs/server/05-production.mdx +322 -0
- package/docs/server/06-guarantees.mdx +123 -0
- package/examples/playground/DEPLOY.md +53 -7
- package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
- package/examples/playground/app/a0/[appId]/page.tsx +53 -0
- package/examples/playground/app/a0/a0.css +818 -0
- package/examples/playground/app/a0/app-list.tsx +218 -0
- package/examples/playground/app/a0/app-view.tsx +63 -0
- package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
- package/examples/playground/app/a0/apps/[appId]/route.ts +28 -0
- package/examples/playground/app/a0/apps.test.ts +128 -0
- package/examples/playground/app/a0/apps.ts +102 -0
- package/examples/playground/app/a0/chat.tsx +410 -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/fx/events/route.ts +9 -0
- package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/fx/short/events/route.ts +11 -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 +73 -0
- package/examples/playground/app/a0/model.ts +95 -0
- package/examples/playground/app/a0/no-photon.cjs +4 -0
- package/examples/playground/app/a0/no-quickjs.cjs +3 -0
- package/examples/playground/app/a0/page.tsx +14 -0
- package/examples/playground/app/a0/pi/events/route.ts +9 -0
- package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
- package/examples/playground/app/a0/pi/short/events/route.ts +11 -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 +75 -0
- package/examples/playground/app/a0/sandbox-session.ts +152 -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 +213 -0
- package/examples/playground/app/a0/session.ts +59 -0
- package/examples/playground/app/a0/shell.tsx +158 -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 +101 -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 +19 -15
- package/examples/playground/app/agent/session.ts +5 -6
- package/examples/playground/app/agent/telemetry.test.ts +189 -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 +12 -12
- 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 +112 -0
- package/examples/playground/app/chat/server.ts +113 -117
- 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/next.config.ts +17 -5
- package/examples/playground/package.json +13 -2
- package/examples/playground/scripts/a0-template.ts +77 -0
- package/examples/playground/vercel.json +36 -0
- package/package.json +29 -3
- 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 +2517 -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-fx.browser.ts +5 -0
- package/src/ai-fx.ts +833 -0
- package/src/ai-harness.ts +518 -0
- package/src/ai-message-base.ts +209 -0
- package/src/ai-message-projection.ts +262 -64
- package/src/ai-model-metadata.ts +11 -6
- package/src/ai-pi.browser.ts +5 -0
- package/src/ai-pi.ts +1263 -0
- 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-sdk-step.ts +2 -2
- package/src/ai-server.browser.ts +2 -2
- package/src/ai-server.ts +23 -1965
- 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 +52 -0
- package/src/libfx.d.ts +34 -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 +93 -11
- 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 +1133 -255
- 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 +252 -75
- package/src/store-polling.ts +18 -4
- package/src/store-postgres.ts +541 -114
- package/src/store-redis-core.ts +1450 -493
- package/src/store-redis-http.ts +18 -2
- package/src/store-redis-notify.ts +262 -85
- package/src/store-redis.ts +19 -9
- package/src/store-sqlite.ts +319 -45
- package/src/store.ts +79 -1
- package/src/stream-buffer.ts +43 -0
- package/src/telemetry.ts +14 -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/docs/concepts/01-contracts.mdx +0 -142
- package/docs/concepts/02-handlers.mdx +0 -284
- package/docs/concepts/03-durability.mdx +0 -314
- package/docs/concepts/04-state.mdx +0 -209
- package/docs/guides/01-timers.mdx +0 -185
- package/docs/guides/02-cancellation.mdx +0 -161
- package/docs/guides/03-react.mdx +0 -384
- package/docs/guides/04-local-first.mdx +0 -88
- package/docs/guides/05-production.mdx +0 -521
- package/docs/guides/06-ai-agents.mdx +0 -1318
- package/docs/guides/08-application-data.mdx +0 -113
- package/docs/guides/09-presence.mdx +0 -266
- package/docs/guides/10-transports.mdx +0 -221
- package/docs/reference/01-api.mdx +0 -1896
- package/docs/reference/02-errors.mdx +0 -149
- 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/docs/{concepts → contract}/meta.ts +0 -0
- /package/docs/{guides → server}/meta.ts +0 -0
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,508 @@ 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
|
+
|
|
126
|
+
/** The metadata columns of a full stored row. */
|
|
127
|
+
const STORED_EVENT_META_SUFFIXES = [
|
|
128
|
+
'p',
|
|
129
|
+
'pa',
|
|
130
|
+
'ri',
|
|
131
|
+
'ch',
|
|
132
|
+
'ce',
|
|
133
|
+
'f',
|
|
134
|
+
'fc',
|
|
135
|
+
'lc',
|
|
136
|
+
'lf',
|
|
137
|
+
'lfa',
|
|
138
|
+
'd',
|
|
139
|
+
'a',
|
|
140
|
+
'e',
|
|
141
|
+
] as const
|
|
142
|
+
|
|
88
143
|
/**
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
144
|
+
* Upstash bills every call a script makes, so scripts read the counter and
|
|
145
|
+
* split with one MGET, read metadata with one HMGET per hash (`read_fields`),
|
|
146
|
+
* and buffer hash writes (`put`) into one HSET per hash (`flush`). Buffered
|
|
147
|
+
* writes are invisible to reads until flushed, so a script must not read a
|
|
148
|
+
* field it has buffered. Per-event metadata lives in hashes of
|
|
149
|
+
* META_CHUNK_SIZE consecutive indexes, so no key grows with a session's
|
|
150
|
+
* history. Indexes at or below the split predate chunking and stay in the
|
|
151
|
+
* base hash, which also holds every session's lane pointers. An unset split
|
|
152
|
+
* means the current event count, so any script that adds events must write
|
|
153
|
+
* `split_write` pairs in its write phase. Including scripts bind COUNTER,
|
|
154
|
+
* META (the base hash), and SPLIT first.
|
|
96
155
|
*/
|
|
97
|
-
const
|
|
156
|
+
const META_LUA = `
|
|
157
|
+
local split, split_stored = nil, false
|
|
158
|
+
-- The latest clear marker lives beside the counter, {prefix}:{session}:clear,
|
|
159
|
+
-- and is read in the same MGET, so a session never cleared pays nothing.
|
|
160
|
+
local CLEAR = string.sub(COUNTER, 1, -6) .. 'clear'
|
|
161
|
+
local boundary = 0
|
|
162
|
+
local function use_split(stored, count, cleared)
|
|
163
|
+
split_stored = stored ~= false
|
|
164
|
+
split = tonumber(stored or count or '0')
|
|
165
|
+
boundary = tonumber(cleared or '0')
|
|
166
|
+
end
|
|
167
|
+
local function mkey(field)
|
|
168
|
+
if split == nil then
|
|
169
|
+
local values = redis.call('MGET', SPLIT, COUNTER, CLEAR)
|
|
170
|
+
use_split(values[1], values[2], values[3])
|
|
171
|
+
end
|
|
172
|
+
local idx = tonumber(field)
|
|
173
|
+
if idx <= split then return META end
|
|
174
|
+
return META .. ':' .. tostring(math.floor((idx - 1) / ${META_CHUNK_SIZE}))
|
|
175
|
+
end
|
|
176
|
+
local function split_write(args)
|
|
177
|
+
if not split_stored then
|
|
178
|
+
args[#args + 1] = SPLIT
|
|
179
|
+
args[#args + 1] = tostring(split)
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
local function sliced(command, key, args)
|
|
183
|
+
local results = {}
|
|
184
|
+
for first = 1, #args, 1000 do
|
|
185
|
+
local last = math.min(first + 999, #args)
|
|
186
|
+
local reply
|
|
187
|
+
if key then
|
|
188
|
+
reply = redis.call(command, key, unpack(args, first, last))
|
|
189
|
+
else
|
|
190
|
+
reply = redis.call(command, unpack(args, first, last))
|
|
191
|
+
end
|
|
192
|
+
if type(reply) == 'table' then
|
|
193
|
+
for _, value in ipairs(reply) do results[#results + 1] = value end
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
return results
|
|
197
|
+
end
|
|
198
|
+
local function read_fields(fields, suffixes)
|
|
199
|
+
local groups, order = {}, {}
|
|
200
|
+
for _, field in ipairs(fields) do
|
|
201
|
+
local key = mkey(field)
|
|
202
|
+
local group = groups[key]
|
|
203
|
+
if not group then
|
|
204
|
+
group = { fields = {}, names = {} }
|
|
205
|
+
groups[key] = group
|
|
206
|
+
order[#order + 1] = key
|
|
207
|
+
end
|
|
208
|
+
group.fields[#group.fields + 1] = field
|
|
209
|
+
for _, suffix in ipairs(suffixes) do
|
|
210
|
+
group.names[#group.names + 1] = field .. ':' .. suffix
|
|
211
|
+
end
|
|
212
|
+
end
|
|
213
|
+
local rows = {}
|
|
214
|
+
for _, key in ipairs(order) do
|
|
215
|
+
local group = groups[key]
|
|
216
|
+
local values = sliced('HMGET', key, group.names)
|
|
217
|
+
for position, field in ipairs(group.fields) do
|
|
218
|
+
local row = {}
|
|
219
|
+
for column = 1, #suffixes do
|
|
220
|
+
row[column] = values[(position - 1) * #suffixes + column]
|
|
221
|
+
end
|
|
222
|
+
rows[field] = row
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
return rows
|
|
226
|
+
end
|
|
227
|
+
local writes, write_order = {}, {}
|
|
228
|
+
local function put(key, field, value)
|
|
229
|
+
local fields = writes[key]
|
|
230
|
+
if not fields then
|
|
231
|
+
fields = {}
|
|
232
|
+
writes[key] = fields
|
|
233
|
+
write_order[#write_order + 1] = key
|
|
234
|
+
end
|
|
235
|
+
fields[#fields + 1] = field
|
|
236
|
+
fields[#fields + 1] = value
|
|
237
|
+
end
|
|
238
|
+
local function flush()
|
|
239
|
+
for _, key in ipairs(write_order) do sliced('HSET', key, writes[key]) end
|
|
240
|
+
writes, write_order = {}, {}
|
|
241
|
+
end
|
|
242
|
+
`
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Lane-aware enqueueing for scripts that add events. Including scripts bind
|
|
246
|
+
* PENDING and READY. Lane tails are read once per lane and tracked in
|
|
247
|
+
* memory, so a batch in one lane links its events without re-reading. A
|
|
248
|
+
* lane emptied earlier in the script (`clear_lane`) is known to have no tail,
|
|
249
|
+
* and its pointers are deleted at `flush_queue` only if nothing re-filled it.
|
|
250
|
+
*/
|
|
251
|
+
const ENQUEUE_LUA = `
|
|
98
252
|
local function lane_field(lane, suffix)
|
|
99
253
|
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
100
254
|
end
|
|
101
|
-
local
|
|
102
|
-
|
|
255
|
+
local queued, ready_now, lane_tails, lane_order = {}, {}, {}, {}
|
|
256
|
+
local function enqueue(field, lane)
|
|
257
|
+
local score = tonumber(field)
|
|
258
|
+
queued[#queued + 1] = score
|
|
259
|
+
queued[#queued + 1] = field
|
|
103
260
|
if lane == '' then
|
|
104
|
-
|
|
261
|
+
ready_now[#ready_now + 1] = score
|
|
262
|
+
ready_now[#ready_now + 1] = field
|
|
105
263
|
return
|
|
106
264
|
end
|
|
107
|
-
|
|
108
|
-
local tail =
|
|
265
|
+
put(mkey(field), field .. ':l', lane)
|
|
266
|
+
local tail = lane_tails[lane]
|
|
267
|
+
if tail == nil then
|
|
268
|
+
tail = redis.call('HGET', META, lane_field(lane, 'tail'))
|
|
269
|
+
if tail and tonumber(tail) <= boundary then tail = false end
|
|
270
|
+
lane_order[#lane_order + 1] = lane
|
|
271
|
+
end
|
|
109
272
|
if tail then
|
|
110
|
-
|
|
273
|
+
put(mkey(tail), tail .. ':next', field)
|
|
111
274
|
else
|
|
112
|
-
|
|
113
|
-
|
|
275
|
+
put(META, lane_field(lane, 'head'), field)
|
|
276
|
+
ready_now[#ready_now + 1] = score
|
|
277
|
+
ready_now[#ready_now + 1] = field
|
|
278
|
+
end
|
|
279
|
+
lane_tails[lane] = field
|
|
280
|
+
end
|
|
281
|
+
local function clear_lane(lane)
|
|
282
|
+
if lane_tails[lane] == nil then lane_order[#lane_order + 1] = lane end
|
|
283
|
+
lane_tails[lane] = false
|
|
284
|
+
end
|
|
285
|
+
local function flush_queue()
|
|
286
|
+
for _, lane in ipairs(lane_order) do
|
|
287
|
+
if lane_tails[lane] then
|
|
288
|
+
put(META, lane_field(lane, 'tail'), lane_tails[lane])
|
|
289
|
+
else
|
|
290
|
+
redis.call('HDEL', META, lane_field(lane, 'head'), lane_field(lane, 'tail'))
|
|
291
|
+
end
|
|
114
292
|
end
|
|
115
|
-
|
|
293
|
+
sliced('ZADD', PENDING, queued)
|
|
294
|
+
sliced('ZADD', READY, ready_now)
|
|
295
|
+
end
|
|
296
|
+
`
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Settles one unfinished event for scripts that include ENQUEUE_LUA: it
|
|
300
|
+
* leaves the pending and ready sets, and a lane head hands its lane to the
|
|
301
|
+
* next unfinished event. A dead-lettered successor stays out of the ready set.
|
|
302
|
+
*/
|
|
303
|
+
const SETTLE_LUA = `
|
|
304
|
+
local function settle(field, lane, next_field)
|
|
305
|
+
redis.call('ZREM', PENDING, field)
|
|
306
|
+
redis.call('ZREM', READY, field)
|
|
307
|
+
if lane == '' then return end
|
|
308
|
+
local head_key = lane_field(lane, 'head')
|
|
309
|
+
if redis.call('HGET', META, head_key) ~= field then return end
|
|
310
|
+
while next_field do
|
|
311
|
+
local row = read_fields({ next_field }, { 'p', 'f', 'next' })[next_field]
|
|
312
|
+
if not row[1] then
|
|
313
|
+
redis.call('HSET', META, head_key, next_field)
|
|
314
|
+
if not row[2] then
|
|
315
|
+
redis.call('ZADD', READY, tonumber(next_field), next_field)
|
|
316
|
+
end
|
|
317
|
+
return
|
|
318
|
+
end
|
|
319
|
+
next_field = row[3]
|
|
320
|
+
end
|
|
321
|
+
clear_lane(lane)
|
|
322
|
+
end
|
|
323
|
+
`
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Event-id ownership for scripts that add events. Each script reads the
|
|
327
|
+
* counter, split, clear boundary, and per-id keys with one MGET and applies
|
|
328
|
+
* the first three. Returns the owner record, or false, per event, and the
|
|
329
|
+
* counter. A record this session made below its latest clear is free.
|
|
330
|
+
*/
|
|
331
|
+
const ID_OWNERS_LUA = `
|
|
332
|
+
local function read_owners(key_offset, n, session)
|
|
333
|
+
local keys = { COUNTER, SPLIT, CLEAR }
|
|
334
|
+
for i = 1, n do keys[#keys + 1] = KEYS[key_offset + i] end
|
|
335
|
+
local values = sliced('MGET', nil, keys)
|
|
336
|
+
use_split(values[2], values[1], values[3])
|
|
337
|
+
local owners = {}
|
|
338
|
+
for i = 1, n do
|
|
339
|
+
local owner = values[3 + i]
|
|
340
|
+
if owner then
|
|
341
|
+
local rec = cjson.decode(owner)
|
|
342
|
+
if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
|
|
343
|
+
end
|
|
344
|
+
owners[i] = owner
|
|
345
|
+
end
|
|
346
|
+
return owners, values[1]
|
|
347
|
+
end
|
|
348
|
+
`
|
|
349
|
+
|
|
350
|
+
const WAKE_PAYLOAD_LIMIT = 32_768
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Wake-up messages carry the events they announce, so a subscribed reader
|
|
354
|
+
* that is caught up applies them without reading the stream:
|
|
355
|
+
* `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
|
|
356
|
+
* are the stored JSON text, inlined verbatim. A batch over
|
|
357
|
+
* WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
|
|
358
|
+
* with XRANGE.
|
|
359
|
+
*/
|
|
360
|
+
const WAKE_LUA = `
|
|
361
|
+
local wake_entries, wake_bytes = {}, 0
|
|
362
|
+
local function wake_add(field, id, typ, payload)
|
|
363
|
+
if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
|
|
364
|
+
wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
|
|
365
|
+
return
|
|
366
|
+
end
|
|
367
|
+
local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
|
|
368
|
+
cjson.encode(typ) .. ',' .. payload .. ']'
|
|
369
|
+
wake_bytes = wake_bytes + #entry + 1
|
|
370
|
+
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
371
|
+
wake_entries[#wake_entries + 1] = entry
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
local function wake(channel, frontier, created_at)
|
|
375
|
+
local message = '{"f":' .. frontier .. '}'
|
|
376
|
+
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
377
|
+
message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
|
|
378
|
+
table.concat(wake_entries, ',') .. ']}'
|
|
379
|
+
end
|
|
380
|
+
redis.pcall('PUBLISH', channel, message)
|
|
381
|
+
end
|
|
382
|
+
`
|
|
383
|
+
|
|
384
|
+
const RELEASE_PINS_LUA = `
|
|
385
|
+
local function release_snapshot_pins(pins, counts)
|
|
386
|
+
local refs = redis.call('SMEMBERS', pins)
|
|
387
|
+
if #refs == 0 then return end
|
|
388
|
+
for _, ref in ipairs(refs) do
|
|
389
|
+
local count = redis.call('HINCRBY', counts, ref, -1)
|
|
390
|
+
if count <= 0 then
|
|
391
|
+
redis.call('HDEL', counts, ref)
|
|
392
|
+
local checkpoint = cjson.decode(ref)
|
|
393
|
+
redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
|
|
394
|
+
end
|
|
395
|
+
end
|
|
396
|
+
redis.call('DEL', pins)
|
|
397
|
+
end
|
|
398
|
+
`
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* KEYS: store stream, counter, metadata, pending, ready, metadata split,
|
|
402
|
+
* then one id ownership key per event.
|
|
403
|
+
* ARGV: sessionId, nowMs, n, then per event:
|
|
404
|
+
* id, type, payloadJson, causeJson, lane, settled; then notify channel and
|
|
405
|
+
* origin index or ''.
|
|
406
|
+
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
407
|
+
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
408
|
+
* ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx] |
|
|
409
|
+
* ['cleared', originIndex].
|
|
410
|
+
*/
|
|
411
|
+
const APPEND_LUA = `
|
|
412
|
+
local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[6], KEYS[4], KEYS[5]
|
|
413
|
+
${META_LUA}
|
|
414
|
+
${ENQUEUE_LUA}
|
|
415
|
+
${ID_OWNERS_LUA}
|
|
416
|
+
${WAKE_LUA}
|
|
417
|
+
local function has_pending()
|
|
418
|
+
return redis.call('ZCARD', PENDING) > 0 and '1' or '0'
|
|
116
419
|
end
|
|
117
420
|
local n = tonumber(ARGV[3])
|
|
118
|
-
|
|
119
|
-
local
|
|
120
|
-
|
|
421
|
+
if n == 0 then return { 'empty', has_pending() } end
|
|
422
|
+
local owners, count = read_owners(6, n, ARGV[1])
|
|
423
|
+
local base = tonumber(count or '0')
|
|
121
424
|
local dups = {}
|
|
122
425
|
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
|
|
426
|
+
if owners[i] then
|
|
427
|
+
local rec = cjson.decode(owners[i])
|
|
428
|
+
if rec[1] ~= ARGV[1] then return { 'foreign', ARGV[3 + (i - 1) * 6 + 1] } end
|
|
128
429
|
dups[#dups + 1] = tostring(rec[2])
|
|
129
430
|
end
|
|
130
431
|
end
|
|
131
432
|
if #dups == n then
|
|
132
|
-
local res = { 'dup', has_pending }
|
|
433
|
+
local res = { 'dup', has_pending() }
|
|
133
434
|
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
134
435
|
return res
|
|
135
436
|
end
|
|
136
437
|
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
438
|
+
local origin = ARGV[5 + n * 6]
|
|
439
|
+
if origin and origin ~= '' and tonumber(origin) <= boundary then
|
|
440
|
+
return { 'cleared', origin }
|
|
441
|
+
end
|
|
442
|
+
local causes, parents = {}, {}
|
|
137
443
|
for i = 1, n do
|
|
138
444
|
local cause = ARGV[3 + (i - 1) * 6 + 4]
|
|
139
445
|
if cause ~= '' then
|
|
140
446
|
local parsed = cjson.decode(cause)
|
|
141
447
|
local pidx = tonumber(parsed.index)
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
448
|
+
causes[#causes + 1] = parsed
|
|
449
|
+
if pidx >= 1 and pidx <= base then parents[#parents + 1] = tostring(pidx) end
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
local parent_rows = read_fields(parents, { 'd', 'f', 'p' })
|
|
453
|
+
local parent_pending = false
|
|
454
|
+
for _, parsed in ipairs(causes) do
|
|
455
|
+
local pidx = tonumber(parsed.index)
|
|
456
|
+
if pidx <= boundary then return { 'superseded', tostring(pidx) } end
|
|
457
|
+
if pidx < 1 or pidx > base then
|
|
458
|
+
return { 'no_parent', tostring(parsed.index) }
|
|
459
|
+
end
|
|
460
|
+
local row = parent_rows[tostring(pidx)]
|
|
461
|
+
if tonumber(row[1] or '0') ~= tonumber(parsed.attempt) or row[2] then
|
|
462
|
+
return { 'superseded', tostring(pidx) }
|
|
151
463
|
end
|
|
464
|
+
-- A claimed, unprocessed parent is still in the pending set.
|
|
465
|
+
if not row[3] then parent_pending = true end
|
|
152
466
|
end
|
|
467
|
+
local owner_writes = {}
|
|
153
468
|
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])
|
|
469
|
+
local o = 3 + (i - 1) * 6
|
|
470
|
+
local field = tostring(base + i)
|
|
471
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
472
|
+
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
473
|
+
'created_at', ARGV[2], 'cause', ARGV[o + 4], 'lane', ARGV[o + 5])
|
|
474
|
+
owner_writes[#owner_writes + 1] = KEYS[6 + i]
|
|
475
|
+
owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], base + i })
|
|
476
|
+
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
477
|
+
if ARGV[o + 6] == '1' then
|
|
478
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
168
479
|
else
|
|
169
|
-
enqueue(
|
|
480
|
+
enqueue(field, ARGV[o + 5])
|
|
170
481
|
end
|
|
171
482
|
end
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
483
|
+
owner_writes[#owner_writes + 1] = COUNTER
|
|
484
|
+
owner_writes[#owner_writes + 1] = tostring(base + n)
|
|
485
|
+
split_write(owner_writes)
|
|
486
|
+
sliced('MSET', nil, owner_writes)
|
|
487
|
+
flush_queue()
|
|
488
|
+
flush()
|
|
489
|
+
local pending_after = (#queued > 0 or parent_pending) and '1' or has_pending()
|
|
490
|
+
wake(ARGV[4 + n * 6], tostring(base + n), ARGV[2])
|
|
491
|
+
return { 'ok', tostring(base + 1), pending_after }
|
|
178
492
|
`
|
|
179
493
|
|
|
180
494
|
/**
|
|
181
|
-
* KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts
|
|
495
|
+
* KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts,
|
|
496
|
+
* metadata split.
|
|
182
497
|
* ARGV: idx, attempt, error, maxFailures, nowMs.
|
|
183
498
|
*/
|
|
184
499
|
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
|
|
500
|
+
local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[7]
|
|
501
|
+
${META_LUA}
|
|
502
|
+
${RELEASE_PINS_LUA}
|
|
221
503
|
local idx = tonumber(ARGV[1])
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
504
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
505
|
+
local total = tonumber(counts[1] or '0')
|
|
506
|
+
use_split(counts[2], counts[1], counts[3])
|
|
507
|
+
if idx <= boundary then return { 'cleared', '0' } end
|
|
508
|
+
if idx < 1 or idx > total then return { 'missing', '0' } end
|
|
225
509
|
local field = ARGV[1]
|
|
226
|
-
local
|
|
227
|
-
local
|
|
228
|
-
|
|
510
|
+
local attempt = tonumber(ARGV[2])
|
|
511
|
+
local row = read_fields({ field }, { 'a', 'd', 'p', 'f', 'lfa', 'ch' })[field]
|
|
512
|
+
local failures = tonumber(row[1] or '0')
|
|
513
|
+
if row[3] or tonumber(row[2] or '0') ~= attempt then
|
|
229
514
|
return { 'superseded', tostring(failures) }
|
|
230
515
|
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
|
|
516
|
+
if row[4] then return { 'dead_lettered', tostring(failures) } end
|
|
517
|
+
if row[5] and tonumber(row[5]) == attempt and not row[6] then
|
|
237
518
|
return { 'failed', tostring(failures) }
|
|
238
519
|
end
|
|
239
|
-
if
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
520
|
+
if not row[6] then return { 'superseded', tostring(failures) } end
|
|
521
|
+
failures = failures + 1
|
|
522
|
+
local dead = failures >= tonumber(ARGV[4])
|
|
523
|
+
local meta_key = mkey(field)
|
|
524
|
+
put(meta_key, field .. ':a', tostring(failures))
|
|
525
|
+
put(meta_key, field .. ':e', ARGV[3])
|
|
526
|
+
put(meta_key, field .. ':lf', ARGV[5])
|
|
527
|
+
put(meta_key, field .. ':lfa', ARGV[2])
|
|
528
|
+
if dead then put(meta_key, field .. ':f', ARGV[5]) end
|
|
529
|
+
flush()
|
|
530
|
+
redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
|
|
531
|
+
if dead then
|
|
250
532
|
redis.call('ZREM', KEYS[4], field)
|
|
251
|
-
release_snapshot_pins()
|
|
533
|
+
release_snapshot_pins(KEYS[5], KEYS[6])
|
|
252
534
|
return { 'dead_lettered', tostring(failures) }
|
|
253
535
|
end
|
|
254
536
|
return { 'failed', tostring(failures) }
|
|
255
537
|
`
|
|
256
538
|
|
|
257
539
|
/**
|
|
258
|
-
* KEYS: store, counter, meta, pending, ready.
|
|
540
|
+
* KEYS: store, counter, meta, pending, ready, metadata split.
|
|
259
541
|
* ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
|
|
542
|
+
* The ready set is a subset of the pending set, so an empty ready set is
|
|
543
|
+
* enough to report 'settled'. A claimed result ends with the clear boundary.
|
|
260
544
|
*/
|
|
261
545
|
const CLAIM_AVAILABLE_LUA = `
|
|
262
|
-
|
|
546
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[6]
|
|
547
|
+
${META_LUA}
|
|
263
548
|
local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
|
|
264
549
|
local excluded = {}
|
|
265
550
|
for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
|
|
551
|
+
local candidates = {}
|
|
552
|
+
for _, field in ipairs(ready) do
|
|
553
|
+
if not excluded[field] then candidates[#candidates + 1] = field end
|
|
554
|
+
end
|
|
555
|
+
local rows = read_fields(candidates, { 'ce', 'd', 'fc', 'a', 'e', 'lf', 'lfa' })
|
|
266
556
|
local claimed = {}
|
|
267
557
|
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
|
-
|
|
558
|
+
for _, field in ipairs(candidates) do
|
|
559
|
+
local row = rows[field]
|
|
560
|
+
local expires = tonumber(row[1] or '0')
|
|
561
|
+
if expires > tonumber(ARGV[2]) then
|
|
562
|
+
if not retry_at or expires < retry_at then retry_at = expires end
|
|
563
|
+
else
|
|
564
|
+
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
565
|
+
if #entries == 0 then return { 'missing', field } end
|
|
566
|
+
local attempt = tostring(tonumber(row[2] or '0') + 1)
|
|
567
|
+
local first_claimed = row[3] or ARGV[2]
|
|
568
|
+
local meta_key = mkey(field)
|
|
569
|
+
put(meta_key, field .. ':d', attempt)
|
|
570
|
+
put(meta_key, field .. ':fc', first_claimed)
|
|
571
|
+
put(meta_key, field .. ':lc', ARGV[2])
|
|
572
|
+
put(meta_key, field .. ':ch', ARGV[1])
|
|
573
|
+
put(meta_key, field .. ':ce', ARGV[3])
|
|
574
|
+
claimed[#claimed + 1] = {
|
|
575
|
+
attempt,
|
|
576
|
+
row[4] or '0',
|
|
577
|
+
row[5] or false,
|
|
578
|
+
first_claimed,
|
|
579
|
+
ARGV[2],
|
|
580
|
+
row[6] or false,
|
|
581
|
+
row[7] or false,
|
|
582
|
+
ARGV[1],
|
|
583
|
+
ARGV[3],
|
|
584
|
+
entries[1]
|
|
585
|
+
}
|
|
295
586
|
end
|
|
296
587
|
end
|
|
588
|
+
flush()
|
|
297
589
|
if #claimed > 0 then
|
|
298
590
|
local result = { 'claimed', tostring(#claimed) }
|
|
299
591
|
for _, claim in ipairs(claimed) do
|
|
300
592
|
for _, value in ipairs(claim) do result[#result + 1] = value end
|
|
301
593
|
end
|
|
594
|
+
result[#result + 1] = tostring(boundary)
|
|
302
595
|
return result
|
|
303
596
|
end
|
|
304
597
|
if retry_at then return { 'busy', tostring(retry_at) } end
|
|
@@ -306,35 +599,43 @@ return { 'settled' }
|
|
|
306
599
|
`
|
|
307
600
|
|
|
308
601
|
/**
|
|
309
|
-
* KEYS: counter, meta.
|
|
602
|
+
* KEYS: counter, meta, metadata split.
|
|
310
603
|
* ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
|
|
311
604
|
* Returns [renewedCount, ...renewed, ...superseded].
|
|
312
605
|
*/
|
|
313
606
|
const RENEW_CLAIMS_LUA = `
|
|
314
|
-
local
|
|
315
|
-
|
|
316
|
-
local
|
|
607
|
+
local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[3]
|
|
608
|
+
${META_LUA}
|
|
609
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
610
|
+
local total = tonumber(counts[1] or '0')
|
|
611
|
+
use_split(counts[2], counts[1], counts[3])
|
|
612
|
+
local claims, fields, cleared = {}, {}, {}
|
|
317
613
|
for i = 1, tonumber(ARGV[4]) do
|
|
318
614
|
local field = ARGV[4 + (i - 1) * 2 + 1]
|
|
319
|
-
local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
|
|
320
615
|
local idx = tonumber(field)
|
|
321
|
-
if idx
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
616
|
+
if idx <= boundary then
|
|
617
|
+
cleared[#cleared + 1] = field
|
|
618
|
+
elseif idx >= 1 and idx <= total then
|
|
619
|
+
claims[#claims + 1] = { field = field, attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2]) }
|
|
620
|
+
fields[#fields + 1] = field
|
|
621
|
+
end
|
|
622
|
+
end
|
|
623
|
+
local rows = read_fields(fields, { 'd', 'ce', 'p', 'f', 'ch' })
|
|
624
|
+
local renewed = {}
|
|
625
|
+
local superseded = {}
|
|
626
|
+
for _, claim in ipairs(claims) do
|
|
627
|
+
local row = rows[claim.field]
|
|
628
|
+
if tonumber(row[1] or '0') > claim.attempt then
|
|
629
|
+
superseded[#superseded + 1] = claim.field
|
|
630
|
+
elseif tonumber(ARGV[3]) > tonumber(ARGV[2]) and
|
|
631
|
+
not row[3] and not row[4] and row[5] == ARGV[1] and
|
|
632
|
+
tonumber(row[2] or '0') > tonumber(ARGV[2]) then
|
|
633
|
+
put(mkey(claim.field), claim.field .. ':ce', ARGV[3])
|
|
634
|
+
renewed[#renewed + 1] = claim.field
|
|
336
635
|
end
|
|
337
636
|
end
|
|
637
|
+
flush()
|
|
638
|
+
for _, field in ipairs(cleared) do superseded[#superseded + 1] = field end
|
|
338
639
|
local result = { tostring(#renewed) }
|
|
339
640
|
for i = 1, #renewed do result[#result + 1] = renewed[i] end
|
|
340
641
|
for i = 1, #superseded do result[#result + 1] = superseded[i] end
|
|
@@ -342,85 +643,39 @@ return result
|
|
|
342
643
|
`
|
|
343
644
|
|
|
344
645
|
/**
|
|
345
|
-
* KEYS: store, counter,
|
|
346
|
-
*
|
|
646
|
+
* KEYS: store, counter, meta, pending, ready, event snapshot pins, snapshot
|
|
647
|
+
* pin counts, metadata split, then one id ownership key per child.
|
|
347
648
|
* ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
|
|
348
649
|
* id, type, payloadJson, lane, settled; finally notify channel.
|
|
349
650
|
*/
|
|
350
651
|
const COMPLETE_ATTEMPT_LUA = `
|
|
351
|
-
local
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
|
|
359
|
-
end
|
|
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 ''
|
|
371
|
-
if lane == '' then return end
|
|
372
|
-
local head_key = lane_field(lane, 'head')
|
|
373
|
-
if redis.call('HGET', KEYS[4], head_key) ~= field then return end
|
|
374
|
-
local next_field = redis.call('HGET', KEYS[4], field .. ':next')
|
|
375
|
-
while next_field and redis.call('HEXISTS', KEYS[4], next_field .. ':p') == 1 do
|
|
376
|
-
next_field = redis.call('HGET', KEYS[4], next_field .. ':next')
|
|
377
|
-
end
|
|
378
|
-
if next_field then
|
|
379
|
-
redis.call('HSET', KEYS[4], head_key, next_field)
|
|
380
|
-
if redis.call('HEXISTS', KEYS[4], next_field .. ':f') == 0 then
|
|
381
|
-
redis.call('ZADD', KEYS[6], tonumber(next_field), next_field)
|
|
382
|
-
end
|
|
383
|
-
else
|
|
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)
|
|
400
|
-
end
|
|
401
|
-
redis.call('HSET', KEYS[4], tail_key, field)
|
|
402
|
-
end
|
|
652
|
+
local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[8], KEYS[4], KEYS[5]
|
|
653
|
+
${META_LUA}
|
|
654
|
+
${ENQUEUE_LUA}
|
|
655
|
+
${ID_OWNERS_LUA}
|
|
656
|
+
${RELEASE_PINS_LUA}
|
|
657
|
+
${WAKE_LUA}
|
|
658
|
+
${SETTLE_LUA}
|
|
403
659
|
local parent = ARGV[3]
|
|
404
660
|
local attempt = tonumber(ARGV[4])
|
|
405
661
|
local n = tonumber(ARGV[5])
|
|
406
|
-
local
|
|
662
|
+
local owners, count = read_owners(8, n, ARGV[1])
|
|
663
|
+
local total = tonumber(count or '0')
|
|
664
|
+
if tonumber(parent) <= boundary then return { 'superseded' } end
|
|
407
665
|
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)
|
|
666
|
+
local row = read_fields({ parent }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'lfa', 'l', 'next' })[parent]
|
|
667
|
+
if row[2] then
|
|
668
|
+
if tonumber(row[3] or '0') ~= attempt then return { 'superseded' } end
|
|
669
|
+
if not row[4] then return { 'partial' } end
|
|
670
|
+
local recorded_ids = cjson.decode(row[4])
|
|
416
671
|
if #recorded_ids ~= n then return { 'partial' } end
|
|
672
|
+
for i = 1, n do
|
|
673
|
+
if recorded_ids[i] ~= ARGV[5 + (i - 1) * 5 + 1] then return { 'partial' } end
|
|
674
|
+
end
|
|
417
675
|
local indexes = {}
|
|
418
676
|
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)
|
|
677
|
+
if not owners[i] then return { 'partial' } end
|
|
678
|
+
local rec = cjson.decode(owners[i])
|
|
424
679
|
if rec[1] ~= ARGV[1] then return { 'partial' } end
|
|
425
680
|
local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
|
|
426
681
|
if #entries == 0 then return { 'partial' } end
|
|
@@ -439,62 +694,153 @@ if processed then
|
|
|
439
694
|
for _, idx in ipairs(indexes) do result[#result + 1] = idx end
|
|
440
695
|
return result
|
|
441
696
|
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
|
|
697
|
+
if tonumber(row[1] or '0') ~= attempt then return { 'superseded' } end
|
|
698
|
+
if not row[5] or row[6] or tonumber(row[7] or '0') == attempt then
|
|
446
699
|
return { 'superseded' }
|
|
447
700
|
end
|
|
448
701
|
for i = 1, n do
|
|
449
|
-
|
|
450
|
-
if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
|
|
702
|
+
if owners[i] then return { 'partial' } end
|
|
451
703
|
end
|
|
452
704
|
local returned_ids = {}
|
|
453
705
|
for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
|
|
454
706
|
local returned_json = '[]'
|
|
455
707
|
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()
|
|
708
|
+
local parent_key = mkey(parent)
|
|
709
|
+
put(parent_key, parent .. ':p', ARGV[2])
|
|
710
|
+
put(parent_key, parent .. ':pa', ARGV[4])
|
|
711
|
+
put(parent_key, parent .. ':ri', returned_json)
|
|
712
|
+
redis.call('HDEL', parent_key, parent .. ':ch', parent .. ':ce')
|
|
713
|
+
settle(parent, row[8] or '', row[9])
|
|
714
|
+
release_snapshot_pins(KEYS[6], KEYS[7])
|
|
463
715
|
local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
|
|
716
|
+
local owner_writes = {}
|
|
464
717
|
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])
|
|
718
|
+
local o = 5 + (i - 1) * 5
|
|
719
|
+
local field = tostring(total + i)
|
|
720
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
721
|
+
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
722
|
+
'created_at', ARGV[2], 'cause', cause, 'lane', ARGV[o + 4])
|
|
723
|
+
owner_writes[#owner_writes + 1] = KEYS[8 + i]
|
|
724
|
+
owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], total + i })
|
|
725
|
+
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
726
|
+
if ARGV[o + 5] == '1' then
|
|
727
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
478
728
|
else
|
|
479
|
-
enqueue(
|
|
729
|
+
enqueue(field, ARGV[o + 4])
|
|
480
730
|
end
|
|
481
731
|
end
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
732
|
+
if n > 0 then
|
|
733
|
+
owner_writes[#owner_writes + 1] = COUNTER
|
|
734
|
+
owner_writes[#owner_writes + 1] = tostring(total + n)
|
|
735
|
+
split_write(owner_writes)
|
|
736
|
+
sliced('MSET', nil, owner_writes)
|
|
485
737
|
end
|
|
738
|
+
flush_queue()
|
|
739
|
+
flush()
|
|
740
|
+
if n > 0 then wake(ARGV[6 + n * 5], tostring(total + n), ARGV[2]) end
|
|
486
741
|
return { 'ok', tostring(total + 1) }
|
|
487
742
|
`
|
|
488
743
|
|
|
489
744
|
/**
|
|
490
|
-
* KEYS:
|
|
745
|
+
* KEYS: counter, meta, pending, ready, metadata split.
|
|
746
|
+
* ARGV: idx, action ('retry' | 'skip'), nowMs.
|
|
747
|
+
* Returns [redriven, hasPending]. Acts only on an unfinished, dead-lettered
|
|
748
|
+
* event above the clear boundary. A retry keeps the event pending and makes
|
|
749
|
+
* it ready again when it is unlaned or its lane's head.
|
|
750
|
+
*/
|
|
751
|
+
const REDRIVE_LUA = `
|
|
752
|
+
local COUNTER, META, SPLIT, PENDING, READY = KEYS[1], KEYS[2], KEYS[5], KEYS[3], KEYS[4]
|
|
753
|
+
${META_LUA}
|
|
754
|
+
${ENQUEUE_LUA}
|
|
755
|
+
${SETTLE_LUA}
|
|
756
|
+
local function result(redriven)
|
|
757
|
+
return { redriven, redis.call('ZCARD', PENDING) > 0 and '1' or '0' }
|
|
758
|
+
end
|
|
759
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
760
|
+
local total = tonumber(counts[1] or '0')
|
|
761
|
+
use_split(counts[2], counts[1], counts[3])
|
|
762
|
+
local idx = tonumber(ARGV[1])
|
|
763
|
+
if idx <= boundary or idx > total then return result('0') end
|
|
764
|
+
local field = tostring(idx)
|
|
765
|
+
local row = read_fields({ field }, { 'p', 'f', 'l', 'next' })[field]
|
|
766
|
+
if row[1] or not row[2] then return result('0') end
|
|
767
|
+
local meta_key = mkey(field)
|
|
768
|
+
local lane = row[3] or ''
|
|
769
|
+
if ARGV[2] == 'skip' then
|
|
770
|
+
put(meta_key, field .. ':p', ARGV[3])
|
|
771
|
+
settle(field, lane, row[4])
|
|
772
|
+
flush_queue()
|
|
773
|
+
flush()
|
|
774
|
+
else
|
|
775
|
+
redis.call('HDEL', meta_key, field .. ':f', field .. ':a')
|
|
776
|
+
if lane == '' or redis.call('HGET', META, lane_field(lane, 'head')) == field then
|
|
777
|
+
redis.call('ZADD', READY, idx, field)
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
return result('1')
|
|
781
|
+
`
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* KEYS: store, counter, meta, pending, metadata split.
|
|
785
|
+
* Returns the dead-lettered events above the clear boundary, oldest first,
|
|
786
|
+
* as pairs of a stream entry and its STORED_EVENT_META_SUFFIXES columns.
|
|
787
|
+
* Every unfinished event is in the pending set, so the scan is bounded by
|
|
788
|
+
* unfinished work, not history; only dead letters read their full columns.
|
|
789
|
+
*/
|
|
790
|
+
const DEAD_LETTERS_LUA = `
|
|
791
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[5]
|
|
792
|
+
${META_LUA}
|
|
793
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
794
|
+
use_split(counts[2], counts[1], counts[3])
|
|
795
|
+
local candidates = {}
|
|
796
|
+
for _, field in ipairs(redis.call('ZRANGE', KEYS[4], 0, -1)) do
|
|
797
|
+
if tonumber(field) > boundary then candidates[#candidates + 1] = field end
|
|
798
|
+
end
|
|
799
|
+
local status = read_fields(candidates, { 'p', 'f' })
|
|
800
|
+
local dead = {}
|
|
801
|
+
for _, field in ipairs(candidates) do
|
|
802
|
+
if status[field][2] and not status[field][1] then dead[#dead + 1] = field end
|
|
803
|
+
end
|
|
804
|
+
local rows = read_fields(dead, { ${STORED_EVENT_META_SUFFIXES.map((suffix) => `'${suffix}'`).join(', ')} })
|
|
805
|
+
local result = {}
|
|
806
|
+
for _, field in ipairs(dead) do
|
|
807
|
+
local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
|
|
808
|
+
if #entries > 0 then
|
|
809
|
+
result[#result + 1] = entries[1]
|
|
810
|
+
result[#result + 1] = rows[field]
|
|
811
|
+
end
|
|
812
|
+
end
|
|
813
|
+
return result
|
|
814
|
+
`
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* KEYS: head, history, event counter, event meta, pin counts, metadata split.
|
|
491
818
|
* ARGV: nowMs, event pin key prefix, write count, then per write:
|
|
492
|
-
* index, snapshot JSON, pin count, pin event indexes
|
|
819
|
+
* index, snapshot JSON, pin count, pin event indexes; finally the reducer
|
|
820
|
+
* name. Writes from before the latest clear are dropped, and a head from
|
|
821
|
+
* before it is replaced as if absent.
|
|
493
822
|
*/
|
|
494
823
|
const PUT_SNAPSHOTS_LUA = `
|
|
495
|
-
local
|
|
496
|
-
|
|
824
|
+
local COUNTER, META, SPLIT = KEYS[3], KEYS[4], KEYS[6]
|
|
825
|
+
${META_LUA}
|
|
826
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
827
|
+
local total = tonumber(counts[1] or '0')
|
|
828
|
+
use_split(counts[2], counts[1], counts[3])
|
|
829
|
+
local pinned = {}
|
|
497
830
|
local offset = 4
|
|
831
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
832
|
+
local pin_count = tonumber(ARGV[offset + 2])
|
|
833
|
+
for p = 1, pin_count do
|
|
834
|
+
local event_index = tonumber(ARGV[offset + 2 + p])
|
|
835
|
+
if event_index > boundary and event_index <= total then
|
|
836
|
+
pinned[#pinned + 1] = tostring(event_index)
|
|
837
|
+
end
|
|
838
|
+
end
|
|
839
|
+
offset = offset + 3 + pin_count
|
|
840
|
+
end
|
|
841
|
+
local status = read_fields(pinned, { 'p', 'f' })
|
|
842
|
+
local snapshots = {}
|
|
843
|
+
offset = 4
|
|
498
844
|
for i = 1, tonumber(ARGV[3]) do
|
|
499
845
|
local index = tonumber(ARGV[offset])
|
|
500
846
|
local snapshot = ARGV[offset + 1]
|
|
@@ -502,23 +848,40 @@ for i = 1, tonumber(ARGV[3]) do
|
|
|
502
848
|
local ref = cjson.encode({ KEYS[2], tostring(index) })
|
|
503
849
|
for p = 1, pin_count do
|
|
504
850
|
local event_index = tonumber(ARGV[offset + 2 + p])
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
851
|
+
local row = status[tostring(event_index)]
|
|
852
|
+
if index >= boundary and event_index > boundary and event_index <= total and
|
|
853
|
+
not row[1] and not row[2] then
|
|
508
854
|
local pin_key = ARGV[2] .. tostring(event_index)
|
|
509
855
|
if redis.call('SADD', pin_key, ref) == 1 then
|
|
510
856
|
redis.call('HINCRBY', KEYS[5], ref, 1)
|
|
511
857
|
end
|
|
512
858
|
end
|
|
513
859
|
end
|
|
514
|
-
|
|
860
|
+
if index >= boundary then
|
|
861
|
+
snapshots[#snapshots + 1] = { index = index, snapshot = snapshot, ref = ref }
|
|
862
|
+
end
|
|
515
863
|
offset = offset + 3 + pin_count
|
|
516
864
|
end
|
|
865
|
+
if #snapshots == 0 then return 1 end
|
|
517
866
|
|
|
518
867
|
local head = redis.call('GET', KEYS[1])
|
|
519
868
|
local head_index = nil
|
|
520
|
-
|
|
521
|
-
|
|
869
|
+
local registered = false
|
|
870
|
+
if head then
|
|
871
|
+
local record = cjson.decode(head)
|
|
872
|
+
head_index = tonumber(record['index'])
|
|
873
|
+
registered = record['r'] == 1
|
|
874
|
+
end
|
|
875
|
+
if head_index and head_index < boundary then
|
|
876
|
+
head, head_index, registered = nil, nil, false
|
|
877
|
+
redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', '(' .. boundary)
|
|
878
|
+
end
|
|
879
|
+
-- Deletion finds each reducer's keys through this set instead of a scan.
|
|
880
|
+
-- Heads written since carry the mark, so a reducer registers once.
|
|
881
|
+
if not registered then
|
|
882
|
+
redis.call('SADD', string.sub(COUNTER, 1, -6) .. 'snap-reducers', ARGV[#ARGV])
|
|
883
|
+
end
|
|
884
|
+
local latest = snapshots[#snapshots]
|
|
522
885
|
local writes_head = not head_index or latest.index >= head_index
|
|
523
886
|
local advances = not head_index or latest.index > head_index
|
|
524
887
|
local final_head_index = advances and latest.index or head_index
|
|
@@ -528,7 +891,7 @@ if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEY
|
|
|
528
891
|
redis.call('ZADD', KEYS[2], head_index, head)
|
|
529
892
|
end
|
|
530
893
|
|
|
531
|
-
for _, write in ipairs(
|
|
894
|
+
for _, write in ipairs(snapshots) do
|
|
532
895
|
if write.index < final_head_index then
|
|
533
896
|
redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
|
|
534
897
|
if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
|
|
@@ -607,30 +970,242 @@ return live
|
|
|
607
970
|
|
|
608
971
|
/**
|
|
609
972
|
* KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
|
|
610
|
-
* Returns one atomic multiversion-cache-plus-log-frontier read
|
|
973
|
+
* Returns one atomic multiversion-cache-plus-log-frontier read, with the
|
|
974
|
+
* clear boundary; checkpoints from before it are ignored.
|
|
611
975
|
*/
|
|
612
976
|
const READ_STATE_LUA = `
|
|
977
|
+
local counts = redis.call('MGET', KEYS[3], string.sub(KEYS[3], 1, -6) .. 'clear')
|
|
978
|
+
local boundary = tonumber(counts[2] or '0')
|
|
613
979
|
local head = redis.call('GET', KEYS[1])
|
|
614
980
|
local head_index = head and cjson.decode(head)['index'] or nil
|
|
615
981
|
if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
|
|
616
982
|
return redis.error_reply('invalid snapshot head index')
|
|
617
983
|
end
|
|
984
|
+
if head_index and head_index < boundary then
|
|
985
|
+
head = nil
|
|
986
|
+
head_index = nil
|
|
987
|
+
end
|
|
618
988
|
local cap = ARGV[1]
|
|
619
989
|
local snapshot = nil
|
|
620
990
|
if head_index and (cap == '' or head_index <= tonumber(cap)) then
|
|
621
991
|
snapshot = head
|
|
622
992
|
else
|
|
623
993
|
local upper = cap == '' and '+inf' or cap
|
|
624
|
-
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper,
|
|
994
|
+
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, boundary, 'LIMIT', 0, 1)
|
|
625
995
|
if #historical > 0 then snapshot = historical[1] end
|
|
626
996
|
end
|
|
627
|
-
|
|
628
|
-
|
|
997
|
+
return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
|
|
998
|
+
`
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Removes every registered reducer's checkpoints below the latest clear, and
|
|
1002
|
+
* the pin counts that name them. Snapshot writes register each reducer in
|
|
1003
|
+
* {prefix}:{session}:snap-reducers, so no key scan is needed.
|
|
1004
|
+
*/
|
|
1005
|
+
const SWEEP_CHECKPOINTS_LUA = `
|
|
1006
|
+
local function sweep_checkpoints(session, boundary)
|
|
1007
|
+
for _, reducer in ipairs(redis.call('SMEMBERS', session .. 'snap-reducers')) do
|
|
1008
|
+
local head_key = session .. 'snap:' .. reducer
|
|
1009
|
+
local head = redis.call('GET', head_key)
|
|
1010
|
+
if head and tonumber(cjson.decode(head)['index']) < boundary then
|
|
1011
|
+
redis.call('DEL', head_key)
|
|
1012
|
+
end
|
|
1013
|
+
redis.call('ZREMRANGEBYSCORE', session .. 'snap-history:' .. reducer, '-inf', '(' .. boundary)
|
|
1014
|
+
end
|
|
1015
|
+
local counts_key = session .. 'snapshot-pin-counts'
|
|
1016
|
+
local counts = redis.call('HGETALL', counts_key)
|
|
1017
|
+
local stale = {}
|
|
1018
|
+
for i = 1, #counts, 2 do
|
|
1019
|
+
if tonumber(cjson.decode(counts[i])[2]) < boundary then stale[#stale + 1] = counts[i] end
|
|
1020
|
+
end
|
|
1021
|
+
if #stale > 0 then redis.call('HDEL', counts_key, unpack(stale)) end
|
|
1022
|
+
end
|
|
1023
|
+
`
|
|
1024
|
+
|
|
1025
|
+
/**
|
|
1026
|
+
* KEYS: store stream, counter, metadata, metadata split, marker id ownership
|
|
1027
|
+
* key, pending, ready.
|
|
1028
|
+
* ARGV: sessionId, nowMs, ifLastIndex or '', cause index or '', cause
|
|
1029
|
+
* attempt, marker id, notify channel.
|
|
1030
|
+
* Returns ['ok', cleared, head, boundary] | ['no_parent', idx] |
|
|
1031
|
+
* ['superseded', idx] | ['foreign']. The old lifetime's log moves aside in one
|
|
1032
|
+
* RENAME, so every read of the log starts at the marker, and its queues are
|
|
1033
|
+
* unlinked, so claims and pending checks see only the new lifetime; deletion
|
|
1034
|
+
* drains the moved log.
|
|
1035
|
+
*/
|
|
1036
|
+
const CLEAR_LUA = `
|
|
1037
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
|
|
1038
|
+
${META_LUA}
|
|
1039
|
+
${WAKE_LUA}
|
|
1040
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR, KEYS[5])
|
|
1041
|
+
local head = tonumber(counts[1] or '0')
|
|
1042
|
+
use_split(counts[2], counts[1], counts[3])
|
|
1043
|
+
if ARGV[4] ~= '' then
|
|
1044
|
+
local field = ARGV[4]
|
|
1045
|
+
local pidx = tonumber(field)
|
|
1046
|
+
if pidx <= boundary then return { 'superseded', field } end
|
|
1047
|
+
if pidx < 1 or pidx > head then return { 'no_parent', field } end
|
|
1048
|
+
local row = read_fields({ field }, { 'd', 'f' })[field]
|
|
1049
|
+
if tonumber(row[1] or '0') ~= tonumber(ARGV[5]) or row[2] then
|
|
1050
|
+
return { 'superseded', field }
|
|
1051
|
+
end
|
|
1052
|
+
end
|
|
1053
|
+
local expected = ARGV[3]
|
|
1054
|
+
if expected ~= '' and boundary == tonumber(expected) + 1 then
|
|
1055
|
+
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
1056
|
+
end
|
|
1057
|
+
if expected ~= '' and tonumber(expected) ~= head then
|
|
1058
|
+
return { 'ok', '0', tostring(head), tostring(boundary) }
|
|
1059
|
+
end
|
|
1060
|
+
if head == 0 or boundary == head then
|
|
1061
|
+
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
1062
|
+
end
|
|
1063
|
+
if counts[4] then return { 'foreign' } end
|
|
1064
|
+
local field = tostring(head + 1)
|
|
1065
|
+
local session = string.sub(COUNTER, 1, -6)
|
|
1066
|
+
local moved = session .. 'cleared:' .. field
|
|
1067
|
+
redis.call('RENAME', KEYS[1], moved)
|
|
1068
|
+
redis.call('UNLINK', KEYS[6], KEYS[7])
|
|
1069
|
+
redis.call('SADD', session .. 'cleared', moved)
|
|
1070
|
+
redis.call('XADD', KEYS[1], field .. '-0',
|
|
1071
|
+
'id', ARGV[6], 'type', '${CLEAR_EVENT_TYPE}', 'payload', 'null',
|
|
1072
|
+
'created_at', ARGV[2], 'cause', '', 'lane', '')
|
|
1073
|
+
local writes = { KEYS[5], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
|
|
1074
|
+
split_write(writes)
|
|
1075
|
+
redis.call('MSET', unpack(writes))
|
|
1076
|
+
put(mkey(field), field .. ':p', ARGV[2])
|
|
1077
|
+
flush()
|
|
1078
|
+
wake_add(field, ARGV[6], '${CLEAR_EVENT_TYPE}', 'null')
|
|
1079
|
+
wake(ARGV[7], field, ARGV[2])
|
|
1080
|
+
return { 'ok', '1', field, field }
|
|
1081
|
+
`
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* KEYS: counter. ARGV: page size.
|
|
1085
|
+
* The next deletion batch: a moved log and up to a page of its [index, id]
|
|
1086
|
+
* pairs, without payloads. Empty moved logs are dropped; with none left it
|
|
1087
|
+
* sweeps the old checkpoints and returns nothing, so a retry finishes an
|
|
1088
|
+
* interrupted deletion.
|
|
1089
|
+
*/
|
|
1090
|
+
const RECLAIM_BATCH_LUA = `
|
|
1091
|
+
${SWEEP_CHECKPOINTS_LUA}
|
|
1092
|
+
local session = string.sub(KEYS[1], 1, -6)
|
|
1093
|
+
local boundary = tonumber(redis.call('GET', session .. 'clear') or '0')
|
|
1094
|
+
if boundary == 0 then return {} end
|
|
1095
|
+
for _, log in ipairs(redis.call('SMEMBERS', session .. 'cleared')) do
|
|
1096
|
+
local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
|
|
1097
|
+
if #entries > 0 then
|
|
1098
|
+
local batch = { log }
|
|
1099
|
+
for _, entry in ipairs(entries) do
|
|
1100
|
+
local fields = entry[2]
|
|
1101
|
+
for i = 1, #fields, 2 do
|
|
1102
|
+
if fields[i] == 'id' then
|
|
1103
|
+
batch[#batch + 1] = string.match(entry[1], '^(%d+)')
|
|
1104
|
+
batch[#batch + 1] = fields[i + 1]
|
|
1105
|
+
break
|
|
1106
|
+
end
|
|
1107
|
+
end
|
|
1108
|
+
end
|
|
1109
|
+
return batch
|
|
1110
|
+
end
|
|
1111
|
+
redis.call('DEL', log)
|
|
1112
|
+
redis.call('SREM', session .. 'cleared', log)
|
|
1113
|
+
end
|
|
1114
|
+
sweep_checkpoints(session, boundary)
|
|
1115
|
+
return {}
|
|
1116
|
+
`
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* KEYS: moved log, counter, metadata, metadata split, then one id ownership
|
|
1120
|
+
* key per entry.
|
|
1121
|
+
* ARGV: sessionId, event pin key prefix, n, then per entry: index, id.
|
|
1122
|
+
* Deletes the listed entries below the latest clear and everything that
|
|
1123
|
+
* names them. An id record survives when it names another event, so an id
|
|
1124
|
+
* reused in the new lifetime keeps its owner. Returns [deleted, done].
|
|
1125
|
+
*/
|
|
1126
|
+
const RECLAIM_LUA = `
|
|
1127
|
+
local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
|
|
1128
|
+
${META_LUA}
|
|
1129
|
+
${SWEEP_CHECKPOINTS_LUA}
|
|
1130
|
+
local function lane_field(lane, suffix)
|
|
1131
|
+
return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
|
|
1132
|
+
end
|
|
1133
|
+
local session = string.sub(COUNTER, 1, -6)
|
|
1134
|
+
local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
|
|
1135
|
+
use_split(counts[2], counts[1], counts[3])
|
|
1136
|
+
local fields, id_keys, indexes, stream_ids, pin_keys, deleted = {}, {}, {}, {}, {}, {}
|
|
1137
|
+
-- A concurrent reclaim may list the same batch; only XDEL says who removed it.
|
|
1138
|
+
local removed = 0
|
|
1139
|
+
for i = 1, tonumber(ARGV[3]) do
|
|
1140
|
+
local field = ARGV[3 + (i - 1) * 2 + 1]
|
|
1141
|
+
if tonumber(field) < boundary then
|
|
1142
|
+
fields[#fields + 1] = field
|
|
1143
|
+
id_keys[#fields] = KEYS[4 + i]
|
|
1144
|
+
indexes[#fields] = tonumber(field)
|
|
1145
|
+
stream_ids[#fields] = field .. '-0'
|
|
1146
|
+
pin_keys[#fields] = ARGV[2] .. field
|
|
1147
|
+
deleted[field] = true
|
|
1148
|
+
end
|
|
1149
|
+
end
|
|
1150
|
+
if #fields > 0 then
|
|
1151
|
+
local lanes, seen, pointers = read_fields(fields, { 'l' }), {}, {}
|
|
1152
|
+
for _, field in ipairs(fields) do
|
|
1153
|
+
local lane = lanes[field][1]
|
|
1154
|
+
if lane and lane ~= '' and not seen[lane] then
|
|
1155
|
+
seen[lane] = true
|
|
1156
|
+
pointers[#pointers + 1] = lane_field(lane, 'head')
|
|
1157
|
+
pointers[#pointers + 1] = lane_field(lane, 'tail')
|
|
1158
|
+
end
|
|
1159
|
+
end
|
|
1160
|
+
if #pointers > 0 then
|
|
1161
|
+
local values = redis.call('HMGET', META, unpack(pointers))
|
|
1162
|
+
local stale = {}
|
|
1163
|
+
for i = 1, #pointers do
|
|
1164
|
+
if values[i] and deleted[values[i]] then stale[#stale + 1] = pointers[i] end
|
|
1165
|
+
end
|
|
1166
|
+
if #stale > 0 then redis.call('HDEL', META, unpack(stale)) end
|
|
1167
|
+
end
|
|
1168
|
+
local suffixes = { 'l', 'next', 'p', 'pa', 'ri', 'ch', 'ce', 'f', 'fc', 'lc', 'lf', 'lfa', 'd', 'a', 'e' }
|
|
1169
|
+
local columns, order = {}, {}
|
|
1170
|
+
for _, field in ipairs(fields) do
|
|
1171
|
+
local key = mkey(field)
|
|
1172
|
+
if not columns[key] then
|
|
1173
|
+
columns[key] = {}
|
|
1174
|
+
order[#order + 1] = key
|
|
1175
|
+
end
|
|
1176
|
+
for _, suffix in ipairs(suffixes) do
|
|
1177
|
+
columns[key][#columns[key] + 1] = field .. ':' .. suffix
|
|
1178
|
+
end
|
|
1179
|
+
end
|
|
1180
|
+
for _, key in ipairs(order) do sliced('HDEL', key, columns[key]) end
|
|
1181
|
+
sliced('DEL', nil, pin_keys)
|
|
1182
|
+
local owners, stale_ids = sliced('MGET', nil, id_keys), {}
|
|
1183
|
+
for i = 1, #fields do
|
|
1184
|
+
if owners[i] then
|
|
1185
|
+
local rec = cjson.decode(owners[i])
|
|
1186
|
+
if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
|
|
1187
|
+
stale_ids[#stale_ids + 1] = id_keys[i]
|
|
1188
|
+
end
|
|
1189
|
+
end
|
|
1190
|
+
end
|
|
1191
|
+
if #stale_ids > 0 then sliced('DEL', nil, stale_ids) end
|
|
1192
|
+
removed = redis.call('XDEL', KEYS[1], unpack(stream_ids))
|
|
1193
|
+
end
|
|
1194
|
+
local done = 0
|
|
1195
|
+
if redis.call('XLEN', KEYS[1]) == 0 then
|
|
1196
|
+
redis.call('DEL', KEYS[1])
|
|
1197
|
+
redis.call('SREM', session .. 'cleared', KEYS[1])
|
|
1198
|
+
if redis.call('SCARD', session .. 'cleared') == 0 then
|
|
1199
|
+
sweep_checkpoints(session, boundary)
|
|
1200
|
+
done = 1
|
|
1201
|
+
end
|
|
1202
|
+
end
|
|
1203
|
+
return { removed, done }
|
|
629
1204
|
`
|
|
630
1205
|
|
|
631
1206
|
// ── wire helpers ─────────────────────────────────────────────────────
|
|
632
1207
|
|
|
633
|
-
type StreamEntry = [id: string, fields: string[]]
|
|
1208
|
+
export type StreamEntry = [id: string, fields: string[]]
|
|
634
1209
|
|
|
635
1210
|
const fieldMap = (fields: string[]): Record<string, string> => {
|
|
636
1211
|
const map: Record<string, string> = {}
|
|
@@ -640,22 +1215,6 @@ const fieldMap = (fields: string[]): Record<string, string> => {
|
|
|
640
1215
|
return map
|
|
641
1216
|
}
|
|
642
1217
|
|
|
643
|
-
const STORED_EVENT_META_SUFFIXES = [
|
|
644
|
-
'p',
|
|
645
|
-
'pa',
|
|
646
|
-
'ri',
|
|
647
|
-
'ch',
|
|
648
|
-
'ce',
|
|
649
|
-
'f',
|
|
650
|
-
'fc',
|
|
651
|
-
'lc',
|
|
652
|
-
'lf',
|
|
653
|
-
'lfa',
|
|
654
|
-
'd',
|
|
655
|
-
'a',
|
|
656
|
-
'e',
|
|
657
|
-
] as const
|
|
658
|
-
|
|
659
1218
|
const entryIndex = (entryId: string): number =>
|
|
660
1219
|
Number(entryId.slice(0, entryId.indexOf('-')))
|
|
661
1220
|
|
|
@@ -671,6 +1230,66 @@ const toEvent = (sessionId: string, entry: StreamEntry): Event => {
|
|
|
671
1230
|
}
|
|
672
1231
|
}
|
|
673
1232
|
|
|
1233
|
+
type ByteStreamEntry = [Uint8Array, Uint8Array[]]
|
|
1234
|
+
|
|
1235
|
+
const fieldBytes = (name: string): Uint8Array => new TextEncoder().encode(name)
|
|
1236
|
+
const ID_FIELD = fieldBytes('id')
|
|
1237
|
+
const TYPE_FIELD = fieldBytes('type')
|
|
1238
|
+
const PAYLOAD_FIELD = fieldBytes('payload')
|
|
1239
|
+
const CREATED_AT_FIELD = fieldBytes('created_at')
|
|
1240
|
+
// A leading U+FEFF is part of a stored id or type, never a byte-order mark.
|
|
1241
|
+
const utf8 = new TextDecoder('utf-8', { ignoreBOM: true })
|
|
1242
|
+
|
|
1243
|
+
const isField = (name: Uint8Array, field: Uint8Array): boolean => {
|
|
1244
|
+
if (name.length !== field.length) return false
|
|
1245
|
+
for (let at = 0; at < field.length; at++)
|
|
1246
|
+
if (name[at] !== field[at]) return false
|
|
1247
|
+
return true
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
const bytesToSerializedEvent = (
|
|
1251
|
+
sessionId: string,
|
|
1252
|
+
entry: ByteStreamEntry,
|
|
1253
|
+
): SerializedEvent => {
|
|
1254
|
+
const values = entry[1]
|
|
1255
|
+
let id: string | undefined
|
|
1256
|
+
let type: string | undefined
|
|
1257
|
+
let payloadJson: string | undefined
|
|
1258
|
+
let createdAt: string | undefined
|
|
1259
|
+
for (let at = 0; at + 1 < values.length; at += 2) {
|
|
1260
|
+
const name = values[at]!
|
|
1261
|
+
if (isField(name, ID_FIELD)) id = utf8.decode(values[at + 1])
|
|
1262
|
+
else if (isField(name, TYPE_FIELD)) type = utf8.decode(values[at + 1])
|
|
1263
|
+
else if (isField(name, PAYLOAD_FIELD))
|
|
1264
|
+
payloadJson = utf8.decode(values[at + 1])
|
|
1265
|
+
else if (isField(name, CREATED_AT_FIELD))
|
|
1266
|
+
createdAt = utf8.decode(values[at + 1])
|
|
1267
|
+
}
|
|
1268
|
+
return {
|
|
1269
|
+
id: id!,
|
|
1270
|
+
type: type!,
|
|
1271
|
+
payloadJson: payloadJson!,
|
|
1272
|
+
index: entryIndex(utf8.decode(entry[0])),
|
|
1273
|
+
sessionId,
|
|
1274
|
+
createdAtMs: Number(createdAt),
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
const toSerializedEvent = (
|
|
1279
|
+
sessionId: string,
|
|
1280
|
+
entry: StreamEntry,
|
|
1281
|
+
): SerializedEvent => {
|
|
1282
|
+
const fields = fieldMap(entry[1])
|
|
1283
|
+
return {
|
|
1284
|
+
id: fields['id']!,
|
|
1285
|
+
type: fields['type']!,
|
|
1286
|
+
payloadJson: fields['payload']!,
|
|
1287
|
+
index: entryIndex(entry[0]),
|
|
1288
|
+
sessionId,
|
|
1289
|
+
createdAtMs: Number(fields['created_at']),
|
|
1290
|
+
}
|
|
1291
|
+
}
|
|
1292
|
+
|
|
674
1293
|
const json = (value: unknown): string => JSON.stringify(value) ?? 'null'
|
|
675
1294
|
|
|
676
1295
|
const toCause = (raw: string | undefined): EventCause | null => {
|
|
@@ -703,6 +1322,46 @@ const toCause = (raw: string | undefined): EventCause | null => {
|
|
|
703
1322
|
}
|
|
704
1323
|
}
|
|
705
1324
|
|
|
1325
|
+
type MetaSuffix = (typeof STORED_EVENT_META_SUFFIXES)[number]
|
|
1326
|
+
|
|
1327
|
+
/** A stream entry joined with its metadata columns; absent columns are unset. */
|
|
1328
|
+
const toStoredEvent = (
|
|
1329
|
+
sessionId: string,
|
|
1330
|
+
entry: StreamEntry,
|
|
1331
|
+
column: (suffix: MetaSuffix) => string | undefined,
|
|
1332
|
+
): StoredEvent => {
|
|
1333
|
+
const fields = fieldMap(entry[1])
|
|
1334
|
+
const date = (suffix: MetaSuffix): Date | null => {
|
|
1335
|
+
const value = column(suffix)
|
|
1336
|
+
return value === undefined ? null : new Date(Number(value))
|
|
1337
|
+
}
|
|
1338
|
+
const count = (suffix: MetaSuffix): number | null => {
|
|
1339
|
+
const value = column(suffix)
|
|
1340
|
+
return value === undefined ? null : Number(value)
|
|
1341
|
+
}
|
|
1342
|
+
const returnedEventIds = column('ri')
|
|
1343
|
+
return Object.assign(toEvent(sessionId, entry), {
|
|
1344
|
+
cause: toCause(fields['cause']),
|
|
1345
|
+
lane: fields['lane'] === '' ? null : fields['lane']!,
|
|
1346
|
+
processedAt: date('p'),
|
|
1347
|
+
processedByAttempt: count('pa'),
|
|
1348
|
+
returnedEventIds:
|
|
1349
|
+
returnedEventIds === undefined
|
|
1350
|
+
? null
|
|
1351
|
+
: decodeReturnedEventIds(returnedEventIds),
|
|
1352
|
+
firstClaimedAt: date('fc'),
|
|
1353
|
+
lastClaimedAt: date('lc'),
|
|
1354
|
+
attemptCount: count('d') ?? 0,
|
|
1355
|
+
claimHolder: column('ch') ?? null,
|
|
1356
|
+
claimExpiresAt: date('ce'),
|
|
1357
|
+
failureCount: count('a') ?? 0,
|
|
1358
|
+
lastFailedAt: date('lf'),
|
|
1359
|
+
lastFailedAttempt: count('lfa'),
|
|
1360
|
+
lastError: column('e') ?? null,
|
|
1361
|
+
failedAt: date('f'),
|
|
1362
|
+
})
|
|
1363
|
+
}
|
|
1364
|
+
|
|
706
1365
|
const toClaimAvailableResult = (
|
|
707
1366
|
sessionId: string,
|
|
708
1367
|
reply: unknown[],
|
|
@@ -744,6 +1403,9 @@ const toClaimAvailableResult = (
|
|
|
744
1403
|
}),
|
|
745
1404
|
)
|
|
746
1405
|
}
|
|
1406
|
+
const clearIndex = Number(reply[2 + count * 10] ?? 0)
|
|
1407
|
+
if (clearIndex > 0)
|
|
1408
|
+
for (const event of events) event.clearIndex = clearIndex
|
|
747
1409
|
return {
|
|
748
1410
|
outcome: 'claimed',
|
|
749
1411
|
events,
|
|
@@ -758,6 +1420,57 @@ const toClaimAvailableResult = (
|
|
|
758
1420
|
)
|
|
759
1421
|
}
|
|
760
1422
|
|
|
1423
|
+
const toIndex = ({
|
|
1424
|
+
raw,
|
|
1425
|
+
sessionId,
|
|
1426
|
+
name,
|
|
1427
|
+
}: {
|
|
1428
|
+
raw: unknown
|
|
1429
|
+
sessionId: string
|
|
1430
|
+
name: string
|
|
1431
|
+
}): number => {
|
|
1432
|
+
const index = Number(raw ?? 0)
|
|
1433
|
+
if (!Number.isSafeInteger(index) || index < 0) {
|
|
1434
|
+
throw new A2Error(
|
|
1435
|
+
'STORE_UNAVAILABLE',
|
|
1436
|
+
`redis ${name} for session '${sessionId}' is invalid`,
|
|
1437
|
+
)
|
|
1438
|
+
}
|
|
1439
|
+
return index
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* A range read that met a gap. A clear moves the old lifetime's log aside, so
|
|
1444
|
+
* a read whose bounds predate it finds nothing where it expected events;
|
|
1445
|
+
* anywhere else a gap is corruption.
|
|
1446
|
+
*/
|
|
1447
|
+
const rangeGap = (sessionId: string, message: string): A2Error =>
|
|
1448
|
+
Object.assign(
|
|
1449
|
+
new A2Error(
|
|
1450
|
+
'STORE_UNAVAILABLE',
|
|
1451
|
+
`redis event range for session '${sessionId}' ${message}`,
|
|
1452
|
+
),
|
|
1453
|
+
{ rangeGap: true },
|
|
1454
|
+
)
|
|
1455
|
+
|
|
1456
|
+
const isRangeGap = (error: unknown): boolean =>
|
|
1457
|
+
error instanceof A2Error &&
|
|
1458
|
+
(error as { rangeGap?: boolean }).rangeGap === true
|
|
1459
|
+
|
|
1460
|
+
/**
|
|
1461
|
+
* Run a whole read, bounds included. A gap means a clear raced it, so the
|
|
1462
|
+
* read runs once more from fresh bounds. A second gap, from another clear or
|
|
1463
|
+
* from corruption, surfaces as the retryable STORE_UNAVAILABLE.
|
|
1464
|
+
*/
|
|
1465
|
+
const retryAfterGap = async <T>(read: () => Promise<T>): Promise<T> => {
|
|
1466
|
+
try {
|
|
1467
|
+
return await read()
|
|
1468
|
+
} catch (error) {
|
|
1469
|
+
if (!isRangeGap(error)) throw error
|
|
1470
|
+
return read()
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
761
1474
|
const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
762
1475
|
try {
|
|
763
1476
|
return await fn()
|
|
@@ -772,13 +1485,16 @@ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
|
|
|
772
1485
|
export function createRedisStoreCore(
|
|
773
1486
|
options: RedisStoreCoreOptions,
|
|
774
1487
|
): RedisStoreCore {
|
|
775
|
-
const { call, clock } = options
|
|
1488
|
+
const { call, callBytes, clock } = options
|
|
776
1489
|
const generateId = options.ids
|
|
777
1490
|
const prefix = options.keyPrefix
|
|
778
1491
|
|
|
779
1492
|
const storeKey = (s: string): string => `${prefix}:${s}:log`
|
|
780
1493
|
const countKey = (s: string): string => `${prefix}:${s}:count`
|
|
781
1494
|
const metaKey = (s: string): string => `${prefix}:${s}:meta`
|
|
1495
|
+
const metaChunkKey = (s: string, index: number): string =>
|
|
1496
|
+
`${metaKey(s)}:${Math.floor((index - 1) / META_CHUNK_SIZE)}`
|
|
1497
|
+
const metaSplitKey = (s: string): string => `${prefix}:${s}:meta-split`
|
|
782
1498
|
const pendingKey = (s: string): string => `${prefix}:${s}:pending`
|
|
783
1499
|
const readyKey = (s: string): string => `${prefix}:${s}:ready`
|
|
784
1500
|
const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
|
|
@@ -790,9 +1506,15 @@ export function createRedisStoreCore(
|
|
|
790
1506
|
`${prefix}:${s}:snapshot-pins:`
|
|
791
1507
|
const snapshotPinCountsKey = (s: string): string =>
|
|
792
1508
|
`${prefix}:${s}:snapshot-pin-counts`
|
|
793
|
-
const watchedKey = (s: string): string => `${prefix}:${s}:watched`
|
|
794
1509
|
const presenceKey = (s: string): string => `${prefix}:${s}:presence`
|
|
795
|
-
|
|
1510
|
+
// Scripts derive this from the counter key; keep the two in step.
|
|
1511
|
+
const clearKey = (s: string): string => `${prefix}:${s}:clear`
|
|
1512
|
+
// One ownership key per event id; reclaim deletes those naming cleared events.
|
|
1513
|
+
// Session ids are raw and may contain colons, so a raw id could name a
|
|
1514
|
+
// session key (`ids:count`). A JSON string with escaped colons starts with
|
|
1515
|
+
// a quote and has no colon, so it never matches a session key's suffix.
|
|
1516
|
+
const idKey = (id: string): string =>
|
|
1517
|
+
`${prefix}:ids:${JSON.stringify(id).replaceAll(':', '\\u003a')}`
|
|
796
1518
|
|
|
797
1519
|
const evalScript = (
|
|
798
1520
|
script: string,
|
|
@@ -800,23 +1522,37 @@ export function createRedisStoreCore(
|
|
|
800
1522
|
args: Array<string | number>,
|
|
801
1523
|
): Promise<unknown> => call('EVAL', script, keys.length, ...keys, ...args)
|
|
802
1524
|
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
)
|
|
1525
|
+
const readRangeBounds = async (
|
|
1526
|
+
sessionId: string,
|
|
1527
|
+
): Promise<{ frontier: number; metaSplit: number; clearIndex: number }> => {
|
|
1528
|
+
const [count, split, cleared] = (await call(
|
|
1529
|
+
'MGET',
|
|
1530
|
+
countKey(sessionId),
|
|
1531
|
+
metaSplitKey(sessionId),
|
|
1532
|
+
clearKey(sessionId),
|
|
1533
|
+
)) as [unknown, unknown, unknown]
|
|
1534
|
+
const frontier = toIndex({ raw: count, sessionId, name: 'event count' })
|
|
1535
|
+
return {
|
|
1536
|
+
frontier,
|
|
1537
|
+
metaSplit:
|
|
1538
|
+
split === null
|
|
1539
|
+
? frontier
|
|
1540
|
+
: toIndex({ raw: split, sessionId, name: 'metadata split' }),
|
|
1541
|
+
clearIndex: toIndex({ raw: cleared, sessionId, name: 'clear boundary' }),
|
|
810
1542
|
}
|
|
811
|
-
return frontier
|
|
812
1543
|
}
|
|
813
1544
|
|
|
814
|
-
const readRangePage = async (
|
|
815
|
-
sessionId
|
|
816
|
-
afterIndex
|
|
817
|
-
throughIndex
|
|
818
|
-
|
|
819
|
-
|
|
1545
|
+
const readRangePage = async ({
|
|
1546
|
+
sessionId,
|
|
1547
|
+
afterIndex,
|
|
1548
|
+
throughIndex,
|
|
1549
|
+
metaSplit,
|
|
1550
|
+
}: {
|
|
1551
|
+
sessionId: string
|
|
1552
|
+
afterIndex: number
|
|
1553
|
+
throughIndex: number
|
|
1554
|
+
metaSplit: number
|
|
1555
|
+
}): Promise<StoredEvent[]> => {
|
|
820
1556
|
if (throughIndex <= afterIndex) return []
|
|
821
1557
|
const entries = (await call(
|
|
822
1558
|
'XRANGE',
|
|
@@ -824,70 +1560,45 @@ export function createRedisStoreCore(
|
|
|
824
1560
|
`${afterIndex + 1}-0`,
|
|
825
1561
|
`${throughIndex}-0`,
|
|
826
1562
|
'COUNT',
|
|
827
|
-
|
|
1563
|
+
READ_PAGE_SIZE,
|
|
828
1564
|
)) as StreamEntry[]
|
|
829
1565
|
if (entries.length === 0) return []
|
|
830
1566
|
|
|
831
|
-
const
|
|
1567
|
+
const fieldsByKey = new Map<string, string[]>()
|
|
1568
|
+
for (const entry of entries) {
|
|
832
1569
|
const index = entryIndex(entry[0])
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
(
|
|
837
|
-
|
|
838
|
-
|
|
1570
|
+
const key =
|
|
1571
|
+
index <= metaSplit ? metaKey(sessionId) : metaChunkKey(sessionId, index)
|
|
1572
|
+
const fields = fieldsByKey.get(key) ?? []
|
|
1573
|
+
fields.push(
|
|
1574
|
+
...STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`),
|
|
1575
|
+
)
|
|
1576
|
+
fieldsByKey.set(key, fields)
|
|
1577
|
+
}
|
|
1578
|
+
const lookups = [...fieldsByKey]
|
|
1579
|
+
const results = await Promise.allSettled(
|
|
1580
|
+
lookups.map(([key, fields]) => call('HMGET', key, ...fields)),
|
|
1581
|
+
)
|
|
839
1582
|
const meta: Record<string, string> = {}
|
|
840
|
-
for (let
|
|
841
|
-
const
|
|
842
|
-
if (
|
|
843
|
-
|
|
1583
|
+
for (let position = 0; position < lookups.length; position += 1) {
|
|
1584
|
+
const result = results[position]!
|
|
1585
|
+
if (result.status === 'rejected') throw result.reason
|
|
1586
|
+
const fields = lookups[position]![1]
|
|
1587
|
+
const values = (result.value as Array<string | null> | null) ?? []
|
|
1588
|
+
for (let i = 0; i < fields.length; i += 1) {
|
|
1589
|
+
const value = values[i]
|
|
1590
|
+
if (value !== null && value !== undefined) {
|
|
1591
|
+
meta[fields[i]!] = value
|
|
1592
|
+
}
|
|
844
1593
|
}
|
|
845
1594
|
}
|
|
846
1595
|
return entries.map((entry) => {
|
|
847
|
-
const
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const claimHolder = meta[`${idx}:ch`]
|
|
854
|
-
const claimExpiresAt = meta[`${idx}:ce`]
|
|
855
|
-
const failedAt = meta[`${idx}:f`]
|
|
856
|
-
const firstClaimedAt = meta[`${idx}:fc`]
|
|
857
|
-
const lastClaimedAt = meta[`${idx}:lc`]
|
|
858
|
-
const lastFailedAt = meta[`${idx}:lf`]
|
|
859
|
-
const lastFailedAttempt = meta[`${idx}:lfa`]
|
|
860
|
-
return Object.assign(event, {
|
|
861
|
-
cause: toCause(fields['cause']),
|
|
862
|
-
lane: fields['lane'] === '' ? null : fields['lane']!,
|
|
863
|
-
processedAt:
|
|
864
|
-
processedAt === undefined ? null : new Date(Number(processedAt)),
|
|
865
|
-
processedByAttempt:
|
|
866
|
-
processedByAttempt === undefined ? null : Number(processedByAttempt),
|
|
867
|
-
returnedEventIds:
|
|
868
|
-
returnedEventIds === undefined
|
|
869
|
-
? null
|
|
870
|
-
: decodeReturnedEventIds(returnedEventIds),
|
|
871
|
-
firstClaimedAt:
|
|
872
|
-
firstClaimedAt === undefined
|
|
873
|
-
? null
|
|
874
|
-
: new Date(Number(firstClaimedAt)),
|
|
875
|
-
lastClaimedAt:
|
|
876
|
-
lastClaimedAt === undefined ? null : new Date(Number(lastClaimedAt)),
|
|
877
|
-
attemptCount: Number(meta[`${idx}:d`] ?? 0),
|
|
878
|
-
claimHolder: claimHolder ?? null,
|
|
879
|
-
claimExpiresAt:
|
|
880
|
-
claimExpiresAt === undefined
|
|
881
|
-
? null
|
|
882
|
-
: new Date(Number(claimExpiresAt)),
|
|
883
|
-
failureCount: Number(meta[`${idx}:a`] ?? 0),
|
|
884
|
-
lastFailedAt:
|
|
885
|
-
lastFailedAt === undefined ? null : new Date(Number(lastFailedAt)),
|
|
886
|
-
lastFailedAttempt:
|
|
887
|
-
lastFailedAttempt === undefined ? null : Number(lastFailedAttempt),
|
|
888
|
-
lastError: meta[`${idx}:e`] ?? null,
|
|
889
|
-
failedAt: failedAt === undefined ? null : new Date(Number(failedAt)),
|
|
890
|
-
})
|
|
1596
|
+
const idx = entryIndex(entry[0])
|
|
1597
|
+
return toStoredEvent(
|
|
1598
|
+
sessionId,
|
|
1599
|
+
entry,
|
|
1600
|
+
(suffix) => meta[`${idx}:${suffix}`],
|
|
1601
|
+
)
|
|
891
1602
|
})
|
|
892
1603
|
}
|
|
893
1604
|
|
|
@@ -910,18 +1621,12 @@ export function createRedisStoreCore(
|
|
|
910
1621
|
READ_PAGE_SIZE,
|
|
911
1622
|
)) as StreamEntry[]
|
|
912
1623
|
if (entries.length === 0) {
|
|
913
|
-
throw
|
|
914
|
-
'STORE_UNAVAILABLE',
|
|
915
|
-
`redis event range for session '${sessionId}' ended before index ${throughIndex}`,
|
|
916
|
-
)
|
|
1624
|
+
throw rangeGap(sessionId, `ended before index ${throughIndex}`)
|
|
917
1625
|
}
|
|
918
1626
|
for (let position = 0; position < entries.length; position += 1) {
|
|
919
1627
|
const index = entryIndex(entries[position]![0])
|
|
920
1628
|
if (index !== nextIndex + position || index > throughIndex) {
|
|
921
|
-
throw
|
|
922
|
-
'STORE_UNAVAILABLE',
|
|
923
|
-
`redis event range for session '${sessionId}' is not contiguous`,
|
|
924
|
-
)
|
|
1629
|
+
throw rangeGap(sessionId, 'is not contiguous')
|
|
925
1630
|
}
|
|
926
1631
|
}
|
|
927
1632
|
events.push(...entries.map((entry) => toEvent(sessionId, entry)))
|
|
@@ -972,34 +1677,43 @@ export function createRedisStoreCore(
|
|
|
972
1677
|
sessionId: string,
|
|
973
1678
|
afterIndex: number,
|
|
974
1679
|
throughIndex?: number,
|
|
975
|
-
): Promise<StoredEvent[]> =>
|
|
976
|
-
|
|
977
|
-
|
|
1680
|
+
): Promise<StoredEvent[]> =>
|
|
1681
|
+
(await readBoundedRange(sessionId, afterIndex, throughIndex)).rows
|
|
1682
|
+
|
|
1683
|
+
/** `readRange`, with the clear boundary its rows start at. */
|
|
1684
|
+
const readBoundedRange = async (
|
|
1685
|
+
sessionId: string,
|
|
1686
|
+
afterIndex: number,
|
|
1687
|
+
throughIndex?: number,
|
|
1688
|
+
): Promise<{ rows: StoredEvent[]; clearIndex: number }> => {
|
|
1689
|
+
if (throughIndex !== undefined && throughIndex <= afterIndex)
|
|
1690
|
+
return { rows: [], clearIndex: 0 }
|
|
1691
|
+
const {
|
|
1692
|
+
frontier: currentFrontier,
|
|
1693
|
+
metaSplit,
|
|
1694
|
+
clearIndex,
|
|
1695
|
+
} = await readRangeBounds(sessionId)
|
|
978
1696
|
const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier)
|
|
979
|
-
|
|
1697
|
+
// Reads start at the latest clear marker.
|
|
1698
|
+
const after = Math.max(afterIndex, clearIndex - 1)
|
|
1699
|
+
if (frontier <= after) return { rows: [], clearIndex }
|
|
980
1700
|
|
|
981
1701
|
const rows: StoredEvent[] = []
|
|
982
|
-
let nextIndex =
|
|
1702
|
+
let nextIndex = after + 1
|
|
983
1703
|
while (nextIndex <= frontier) {
|
|
984
1704
|
// oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range
|
|
985
|
-
const page = await readRangePage(
|
|
1705
|
+
const page = await readRangePage({
|
|
986
1706
|
sessionId,
|
|
987
|
-
nextIndex - 1,
|
|
988
|
-
frontier,
|
|
989
|
-
|
|
990
|
-
)
|
|
1707
|
+
afterIndex: nextIndex - 1,
|
|
1708
|
+
throughIndex: frontier,
|
|
1709
|
+
metaSplit,
|
|
1710
|
+
})
|
|
991
1711
|
if (page.length === 0) {
|
|
992
|
-
throw
|
|
993
|
-
'STORE_UNAVAILABLE',
|
|
994
|
-
`redis event range for session '${sessionId}' ended before index ${frontier}`,
|
|
995
|
-
)
|
|
1712
|
+
throw rangeGap(sessionId, `ended before index ${frontier}`)
|
|
996
1713
|
}
|
|
997
1714
|
for (let position = 0; position < page.length; position += 1) {
|
|
998
1715
|
if (page[position]!.index !== nextIndex + position) {
|
|
999
|
-
throw
|
|
1000
|
-
'STORE_UNAVAILABLE',
|
|
1001
|
-
`redis event range for session '${sessionId}' is not contiguous`,
|
|
1002
|
-
)
|
|
1716
|
+
throw rangeGap(sessionId, 'is not contiguous')
|
|
1003
1717
|
}
|
|
1004
1718
|
}
|
|
1005
1719
|
const lastIndex = page.at(-1)!.index
|
|
@@ -1012,11 +1726,45 @@ export function createRedisStoreCore(
|
|
|
1012
1726
|
rows.push(...page)
|
|
1013
1727
|
nextIndex = lastIndex + 1
|
|
1014
1728
|
}
|
|
1015
|
-
return rows
|
|
1729
|
+
return { rows, clearIndex }
|
|
1016
1730
|
}
|
|
1017
1731
|
|
|
1732
|
+
const readStream = options.readStream
|
|
1018
1733
|
return {
|
|
1019
|
-
|
|
1734
|
+
...(readStream
|
|
1735
|
+
? {
|
|
1736
|
+
streamEvents: async function* ({
|
|
1737
|
+
sessionId,
|
|
1738
|
+
afterIndex,
|
|
1739
|
+
signal,
|
|
1740
|
+
}: {
|
|
1741
|
+
sessionId: string
|
|
1742
|
+
afterIndex: number
|
|
1743
|
+
signal: AbortSignal
|
|
1744
|
+
}): AsyncGenerator<SerializedEvent[], void> {
|
|
1745
|
+
try {
|
|
1746
|
+
for await (const entries of readStream({
|
|
1747
|
+
key: storeKey(sessionId),
|
|
1748
|
+
from: `${afterIndex + 1}-0`,
|
|
1749
|
+
signal,
|
|
1750
|
+
})) {
|
|
1751
|
+
signal.throwIfAborted()
|
|
1752
|
+
yield entries.map((entry) =>
|
|
1753
|
+
toSerializedEvent(sessionId, entry),
|
|
1754
|
+
)
|
|
1755
|
+
}
|
|
1756
|
+
} catch (cause) {
|
|
1757
|
+
if (cause instanceof A2Error) throw cause
|
|
1758
|
+
throw new A2Error(
|
|
1759
|
+
'STORE_UNAVAILABLE',
|
|
1760
|
+
'redis stream read failed',
|
|
1761
|
+
{ cause },
|
|
1762
|
+
)
|
|
1763
|
+
}
|
|
1764
|
+
},
|
|
1765
|
+
}
|
|
1766
|
+
: {}),
|
|
1767
|
+
async append(sessionId, events, appendOptions) {
|
|
1020
1768
|
return wrap(async () => {
|
|
1021
1769
|
const supplied = events.filter((e) => e.id !== undefined)
|
|
1022
1770
|
const suppliedIds = supplied.map((e) => e.id as string)
|
|
@@ -1048,17 +1796,18 @@ export function createRedisStoreCore(
|
|
|
1048
1796
|
e.settled ? '1' : '0',
|
|
1049
1797
|
]),
|
|
1050
1798
|
`${prefix}:${sessionId}:notify`,
|
|
1799
|
+
appendOptions?.originIndex ?? '',
|
|
1051
1800
|
]
|
|
1052
1801
|
const reply = (await evalScript(
|
|
1053
1802
|
APPEND_LUA,
|
|
1054
1803
|
[
|
|
1055
1804
|
storeKey(sessionId),
|
|
1056
1805
|
countKey(sessionId),
|
|
1057
|
-
idsKey,
|
|
1058
1806
|
metaKey(sessionId),
|
|
1059
1807
|
pendingKey(sessionId),
|
|
1060
1808
|
readyKey(sessionId),
|
|
1061
|
-
|
|
1809
|
+
metaSplitKey(sessionId),
|
|
1810
|
+
...withIds.map((e) => idKey(e.id)),
|
|
1062
1811
|
],
|
|
1063
1812
|
args,
|
|
1064
1813
|
)) as string[]
|
|
@@ -1078,6 +1827,11 @@ export function createRedisStoreCore(
|
|
|
1078
1827
|
`no event at index ${reply[1]} in session '${sessionId}'`,
|
|
1079
1828
|
)
|
|
1080
1829
|
}
|
|
1830
|
+
if (verdict === 'cleared') {
|
|
1831
|
+
throw sessionClearedError(
|
|
1832
|
+
`scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`,
|
|
1833
|
+
)
|
|
1834
|
+
}
|
|
1081
1835
|
if (verdict === 'superseded') {
|
|
1082
1836
|
throw new A2Error(
|
|
1083
1837
|
'SUPERSEDED_ATTEMPT',
|
|
@@ -1091,13 +1845,23 @@ export function createRedisStoreCore(
|
|
|
1091
1845
|
)
|
|
1092
1846
|
}
|
|
1093
1847
|
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
1848
|
const hasPending = reply[1] === '1'
|
|
1097
|
-
const
|
|
1098
|
-
|
|
1099
|
-
const rows = await
|
|
1100
|
-
|
|
1849
|
+
const firstIndex = Math.min(...reply.slice(2).map(Number))
|
|
1850
|
+
// A valid retry occupies exactly events.length consecutive indexes.
|
|
1851
|
+
const { rows: existing, clearIndex } = await retryAfterGap(() =>
|
|
1852
|
+
readBoundedRange(
|
|
1853
|
+
sessionId,
|
|
1854
|
+
firstIndex - 1,
|
|
1855
|
+
firstIndex + events.length - 1,
|
|
1856
|
+
),
|
|
1857
|
+
)
|
|
1858
|
+
if (firstIndex < clearIndex) {
|
|
1859
|
+
// Cleared after the script matched the retry: the ids are free.
|
|
1860
|
+
throw new A2Error(
|
|
1861
|
+
'STORE_UNAVAILABLE',
|
|
1862
|
+
`session '${sessionId}' was cleared while an append retry replayed; retry it`,
|
|
1863
|
+
)
|
|
1864
|
+
}
|
|
1101
1865
|
return {
|
|
1102
1866
|
events: idempotentReplay(events, existing),
|
|
1103
1867
|
hasPending,
|
|
@@ -1134,28 +1898,42 @@ export function createRedisStoreCore(
|
|
|
1134
1898
|
},
|
|
1135
1899
|
|
|
1136
1900
|
async read(sessionId, opts) {
|
|
1137
|
-
return wrap(
|
|
1138
|
-
|
|
1139
|
-
sessionId,
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
)
|
|
1143
|
-
return rows
|
|
1144
|
-
})
|
|
1901
|
+
return wrap(() =>
|
|
1902
|
+
retryAfterGap(() =>
|
|
1903
|
+
readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex),
|
|
1904
|
+
),
|
|
1905
|
+
)
|
|
1145
1906
|
},
|
|
1146
1907
|
|
|
1147
1908
|
async readHistory(sessionId, opts) {
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1909
|
+
const after = opts?.afterIndex ?? 0
|
|
1910
|
+
if (opts?.throughIndex !== undefined && opts.throughIndex <= after)
|
|
1911
|
+
return []
|
|
1912
|
+
return wrap(() =>
|
|
1913
|
+
retryAfterGap(async () => {
|
|
1914
|
+
const [count, cleared] = (await call(
|
|
1915
|
+
'MGET',
|
|
1916
|
+
countKey(sessionId),
|
|
1917
|
+
clearKey(sessionId),
|
|
1918
|
+
)) as [unknown, unknown]
|
|
1919
|
+
const frontier = toIndex({
|
|
1920
|
+
raw: count,
|
|
1921
|
+
sessionId,
|
|
1922
|
+
name: 'event count',
|
|
1923
|
+
})
|
|
1924
|
+
const clearIndex = toIndex({
|
|
1925
|
+
raw: cleared,
|
|
1926
|
+
sessionId,
|
|
1927
|
+
name: 'clear boundary',
|
|
1928
|
+
})
|
|
1929
|
+
// Reads start at the latest clear marker.
|
|
1930
|
+
return readEventRange(
|
|
1931
|
+
sessionId,
|
|
1932
|
+
Math.max(after, clearIndex - 1),
|
|
1933
|
+
Math.min(opts?.throughIndex ?? frontier, frontier),
|
|
1934
|
+
)
|
|
1935
|
+
}),
|
|
1936
|
+
)
|
|
1159
1937
|
},
|
|
1160
1938
|
|
|
1161
1939
|
async claimAvailable({
|
|
@@ -1177,6 +1955,7 @@ export function createRedisStoreCore(
|
|
|
1177
1955
|
metaKey(sessionId),
|
|
1178
1956
|
pendingKey(sessionId),
|
|
1179
1957
|
readyKey(sessionId),
|
|
1958
|
+
metaSplitKey(sessionId),
|
|
1180
1959
|
],
|
|
1181
1960
|
[holder, now, expiresAt, excluded.length, ...excluded],
|
|
1182
1961
|
)) as unknown[]
|
|
@@ -1190,7 +1969,7 @@ export function createRedisStoreCore(
|
|
|
1190
1969
|
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
1191
1970
|
const reply = (await evalScript(
|
|
1192
1971
|
RENEW_CLAIMS_LUA,
|
|
1193
|
-
[countKey(sessionId), metaKey(sessionId)],
|
|
1972
|
+
[countKey(sessionId), metaKey(sessionId), metaSplitKey(sessionId)],
|
|
1194
1973
|
[
|
|
1195
1974
|
holder,
|
|
1196
1975
|
now,
|
|
@@ -1223,13 +2002,13 @@ export function createRedisStoreCore(
|
|
|
1223
2002
|
[
|
|
1224
2003
|
storeKey(sessionId),
|
|
1225
2004
|
countKey(sessionId),
|
|
1226
|
-
idsKey,
|
|
1227
2005
|
metaKey(sessionId),
|
|
1228
2006
|
pendingKey(sessionId),
|
|
1229
2007
|
readyKey(sessionId),
|
|
1230
|
-
watchedKey(sessionId),
|
|
1231
2008
|
snapshotEventPinsKey(sessionId, index),
|
|
1232
2009
|
snapshotPinCountsKey(sessionId),
|
|
2010
|
+
metaSplitKey(sessionId),
|
|
2011
|
+
...ids.map((id) => idKey(id)),
|
|
1233
2012
|
],
|
|
1234
2013
|
[
|
|
1235
2014
|
sessionId,
|
|
@@ -1295,13 +2074,16 @@ export function createRedisStoreCore(
|
|
|
1295
2074
|
if (events.length === 0) {
|
|
1296
2075
|
return { outcome: 'completed', events: [] }
|
|
1297
2076
|
}
|
|
1298
|
-
const
|
|
1299
|
-
const
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
2077
|
+
const firstIndex = Math.min(...reply.slice(1).map(Number))
|
|
2078
|
+
const { rows: existing, clearIndex } = await retryAfterGap(() =>
|
|
2079
|
+
readBoundedRange(
|
|
2080
|
+
sessionId,
|
|
2081
|
+
firstIndex - 1,
|
|
2082
|
+
firstIndex + events.length - 1,
|
|
2083
|
+
),
|
|
2084
|
+
)
|
|
2085
|
+
// Cleared after the script matched the retry.
|
|
2086
|
+
if (index <= clearIndex) return { outcome: 'superseded' }
|
|
1305
2087
|
return {
|
|
1306
2088
|
outcome: 'completed',
|
|
1307
2089
|
events: idempotentReplay(events, existing),
|
|
@@ -1322,10 +2104,13 @@ export function createRedisStoreCore(
|
|
|
1322
2104
|
readyKey(sessionId),
|
|
1323
2105
|
snapshotEventPinsKey(sessionId, index),
|
|
1324
2106
|
snapshotPinCountsKey(sessionId),
|
|
2107
|
+
metaSplitKey(sessionId),
|
|
1325
2108
|
],
|
|
1326
2109
|
[index, attempt, error, maxFailures, clock.now().getTime()],
|
|
1327
2110
|
)) as string[]
|
|
1328
2111
|
const outcome = reply[0]
|
|
2112
|
+
if (outcome === 'cleared')
|
|
2113
|
+
return { outcome: 'superseded', failureCount: 0 }
|
|
1329
2114
|
if (outcome === 'missing') {
|
|
1330
2115
|
throw new TypeError(
|
|
1331
2116
|
`no event at index ${index} in session '${sessionId}'`,
|
|
@@ -1338,62 +2123,114 @@ export function createRedisStoreCore(
|
|
|
1338
2123
|
})
|
|
1339
2124
|
},
|
|
1340
2125
|
|
|
1341
|
-
async
|
|
2126
|
+
async deadLetters(sessionId) {
|
|
1342
2127
|
return wrap(async () => {
|
|
1343
2128
|
const reply = (await evalScript(
|
|
1344
|
-
|
|
2129
|
+
DEAD_LETTERS_LUA,
|
|
1345
2130
|
[
|
|
1346
|
-
|
|
1347
|
-
snapHistoryKey(sessionId, reducerName),
|
|
2131
|
+
storeKey(sessionId),
|
|
1348
2132
|
countKey(sessionId),
|
|
2133
|
+
metaKey(sessionId),
|
|
2134
|
+
pendingKey(sessionId),
|
|
2135
|
+
metaSplitKey(sessionId),
|
|
1349
2136
|
],
|
|
1350
|
-
[
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
]
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
const currentFrontier = Number(reply[2])
|
|
1364
|
-
if (
|
|
1365
|
-
!Number.isSafeInteger(currentFrontier) ||
|
|
1366
|
-
currentFrontier < 0 ||
|
|
1367
|
-
(headIndex !== null &&
|
|
1368
|
-
(!Number.isSafeInteger(headIndex) || headIndex < 0)) ||
|
|
1369
|
-
(parsed !== null &&
|
|
1370
|
-
(!Number.isSafeInteger(parsed.index) || parsed.index < 0))
|
|
1371
|
-
) {
|
|
1372
|
-
throw new A2Error(
|
|
1373
|
-
'STORE_UNAVAILABLE',
|
|
1374
|
-
`redis state frontier for session '${sessionId}' is invalid`,
|
|
2137
|
+
[],
|
|
2138
|
+
)) as unknown[]
|
|
2139
|
+
const events: StoredEvent[] = []
|
|
2140
|
+
for (let i = 0; i + 1 < reply.length; i += 2) {
|
|
2141
|
+
const columns = reply[i + 1] as Array<string | null>
|
|
2142
|
+
events.push(
|
|
2143
|
+
toStoredEvent(
|
|
2144
|
+
sessionId,
|
|
2145
|
+
reply[i] as StreamEntry,
|
|
2146
|
+
(suffix) =>
|
|
2147
|
+
columns[STORED_EVENT_META_SUFFIXES.indexOf(suffix)] ??
|
|
2148
|
+
undefined,
|
|
2149
|
+
),
|
|
1375
2150
|
)
|
|
1376
2151
|
}
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
2152
|
+
return events
|
|
2153
|
+
})
|
|
2154
|
+
},
|
|
2155
|
+
|
|
2156
|
+
async redrive({ sessionId, index, action }) {
|
|
2157
|
+
return wrap(async () => {
|
|
2158
|
+
const [redriven, hasPending] = (await evalScript(
|
|
2159
|
+
REDRIVE_LUA,
|
|
2160
|
+
[
|
|
2161
|
+
countKey(sessionId),
|
|
2162
|
+
metaKey(sessionId),
|
|
2163
|
+
pendingKey(sessionId),
|
|
2164
|
+
readyKey(sessionId),
|
|
2165
|
+
metaSplitKey(sessionId),
|
|
2166
|
+
],
|
|
2167
|
+
[index, action, clock.now().getTime()],
|
|
2168
|
+
)) as [string, string]
|
|
2169
|
+
return { redriven: redriven === '1', hasPending: hasPending === '1' }
|
|
1394
2170
|
})
|
|
1395
2171
|
},
|
|
1396
2172
|
|
|
2173
|
+
async readState(sessionId, reducerName, stateOptions) {
|
|
2174
|
+
return wrap(() =>
|
|
2175
|
+
retryAfterGap(async () => {
|
|
2176
|
+
const reply = (await evalScript(
|
|
2177
|
+
READ_STATE_LUA,
|
|
2178
|
+
[
|
|
2179
|
+
snapKey(sessionId, reducerName),
|
|
2180
|
+
snapHistoryKey(sessionId, reducerName),
|
|
2181
|
+
countKey(sessionId),
|
|
2182
|
+
],
|
|
2183
|
+
[
|
|
2184
|
+
stateOptions?.snapshotThroughIndex ??
|
|
2185
|
+
stateOptions?.throughIndex ??
|
|
2186
|
+
'',
|
|
2187
|
+
],
|
|
2188
|
+
)) as [number | string, string, string, string]
|
|
2189
|
+
const rawHead = reply[0]
|
|
2190
|
+
const raw = reply[1]
|
|
2191
|
+
const headIndex = rawHead === '' ? null : Number(rawHead)
|
|
2192
|
+
const parsed =
|
|
2193
|
+
raw === ''
|
|
2194
|
+
? null
|
|
2195
|
+
: (JSON.parse(raw) as { index: number; state: unknown })
|
|
2196
|
+
const currentFrontier = Number(reply[2])
|
|
2197
|
+
if (
|
|
2198
|
+
!Number.isSafeInteger(currentFrontier) ||
|
|
2199
|
+
currentFrontier < 0 ||
|
|
2200
|
+
(headIndex !== null &&
|
|
2201
|
+
(!Number.isSafeInteger(headIndex) || headIndex < 0)) ||
|
|
2202
|
+
(parsed !== null &&
|
|
2203
|
+
(!Number.isSafeInteger(parsed.index) || parsed.index < 0))
|
|
2204
|
+
) {
|
|
2205
|
+
throw new A2Error(
|
|
2206
|
+
'STORE_UNAVAILABLE',
|
|
2207
|
+
`redis state frontier for session '${sessionId}' is invalid`,
|
|
2208
|
+
)
|
|
2209
|
+
}
|
|
2210
|
+
const frontier = Math.min(
|
|
2211
|
+
currentFrontier,
|
|
2212
|
+
stateOptions?.throughIndex ?? currentFrontier,
|
|
2213
|
+
)
|
|
2214
|
+
const snapshot = parsed
|
|
2215
|
+
const clearIndex = Number(reply[3] ?? 0)
|
|
2216
|
+
// The fold starts at the latest clear marker when no checkpoint is newer.
|
|
2217
|
+
const events = await readEventRange(
|
|
2218
|
+
sessionId,
|
|
2219
|
+
Math.max(snapshot?.index ?? 0, clearIndex - 1),
|
|
2220
|
+
frontier,
|
|
2221
|
+
)
|
|
2222
|
+
return {
|
|
2223
|
+
...(clearIndex > 0 ? { clearIndex } : {}),
|
|
2224
|
+
headIndex,
|
|
2225
|
+
snapshot: snapshot
|
|
2226
|
+
? { index: snapshot.index, state: snapshot.state }
|
|
2227
|
+
: null,
|
|
2228
|
+
events,
|
|
2229
|
+
}
|
|
2230
|
+
}),
|
|
2231
|
+
)
|
|
2232
|
+
},
|
|
2233
|
+
|
|
1397
2234
|
async putSnapshots(sessionId, reducerName, writes) {
|
|
1398
2235
|
const byIndex = new Map<
|
|
1399
2236
|
number,
|
|
@@ -1431,6 +2268,8 @@ export function createRedisStoreCore(
|
|
|
1431
2268
|
index: write.index,
|
|
1432
2269
|
state: write.state,
|
|
1433
2270
|
updatedAt: clock.now().getTime(),
|
|
2271
|
+
// This reducer is in the session's registry (PUT_SNAPSHOTS_LUA).
|
|
2272
|
+
r: 1,
|
|
1434
2273
|
}),
|
|
1435
2274
|
write.pinEventIndexes.size,
|
|
1436
2275
|
...write.pinEventIndexes,
|
|
@@ -1444,8 +2283,9 @@ export function createRedisStoreCore(
|
|
|
1444
2283
|
countKey(sessionId),
|
|
1445
2284
|
metaKey(sessionId),
|
|
1446
2285
|
snapshotPinCountsKey(sessionId),
|
|
2286
|
+
metaSplitKey(sessionId),
|
|
1447
2287
|
],
|
|
1448
|
-
args,
|
|
2288
|
+
[...args, reducerName],
|
|
1449
2289
|
)
|
|
1450
2290
|
})
|
|
1451
2291
|
},
|
|
@@ -1540,6 +2380,105 @@ export function createRedisStoreCore(
|
|
|
1540
2380
|
return wrap(() => readRange(sessionId, afterIndex, throughIndex))
|
|
1541
2381
|
},
|
|
1542
2382
|
|
|
2383
|
+
async readTail(sessionId, fromIndex, throughIndex) {
|
|
2384
|
+
return wrap(async () => {
|
|
2385
|
+
const newest = (await call(
|
|
2386
|
+
'XREVRANGE',
|
|
2387
|
+
storeKey(sessionId),
|
|
2388
|
+
throughIndex === undefined ? '+' : `${throughIndex}-0`,
|
|
2389
|
+
`${fromIndex}-0`,
|
|
2390
|
+
'COUNT',
|
|
2391
|
+
READ_PAGE_SIZE,
|
|
2392
|
+
)) as StreamEntry[]
|
|
2393
|
+
const page = newest
|
|
2394
|
+
.toReversed()
|
|
2395
|
+
.map((entry) => toEvent(sessionId, entry))
|
|
2396
|
+
for (let position = 1; position < page.length; position += 1) {
|
|
2397
|
+
if (page[position]!.index !== page[position - 1]!.index + 1) {
|
|
2398
|
+
throw new A2Error(
|
|
2399
|
+
'STORE_UNAVAILABLE',
|
|
2400
|
+
`redis event range for session '${sessionId}' is not contiguous`,
|
|
2401
|
+
)
|
|
2402
|
+
}
|
|
2403
|
+
}
|
|
2404
|
+
const oldest = page[0]?.index
|
|
2405
|
+
return oldest === undefined || oldest <= fromIndex ? page : null
|
|
2406
|
+
})
|
|
2407
|
+
},
|
|
2408
|
+
|
|
2409
|
+
async clear({ sessionId, ifLastIndex, cause }) {
|
|
2410
|
+
return wrap(async () => {
|
|
2411
|
+
const markerId = generateId()
|
|
2412
|
+
const reply = (await evalScript(
|
|
2413
|
+
CLEAR_LUA,
|
|
2414
|
+
[
|
|
2415
|
+
storeKey(sessionId),
|
|
2416
|
+
countKey(sessionId),
|
|
2417
|
+
metaKey(sessionId),
|
|
2418
|
+
metaSplitKey(sessionId),
|
|
2419
|
+
idKey(markerId),
|
|
2420
|
+
pendingKey(sessionId),
|
|
2421
|
+
readyKey(sessionId),
|
|
2422
|
+
],
|
|
2423
|
+
[
|
|
2424
|
+
sessionId,
|
|
2425
|
+
clock.now().getTime(),
|
|
2426
|
+
ifLastIndex ?? '',
|
|
2427
|
+
cause?.index ?? '',
|
|
2428
|
+
cause?.attempt ?? '',
|
|
2429
|
+
markerId,
|
|
2430
|
+
`${prefix}:${sessionId}:notify`,
|
|
2431
|
+
],
|
|
2432
|
+
)) as string[]
|
|
2433
|
+
if (reply[0] === 'superseded') {
|
|
2434
|
+
throw supersededAttemptError(sessionId, cause!)
|
|
2435
|
+
}
|
|
2436
|
+
if (reply[0] === 'no_parent') {
|
|
2437
|
+
throw new TypeError(
|
|
2438
|
+
`no event at index ${reply[1]} in session '${sessionId}'`,
|
|
2439
|
+
)
|
|
2440
|
+
}
|
|
2441
|
+
if (reply[0] === 'foreign') {
|
|
2442
|
+
throw new A2Error(
|
|
2443
|
+
'PARTIAL_DUPLICATE_BATCH',
|
|
2444
|
+
`the clear marker id '${markerId}' already exists`,
|
|
2445
|
+
)
|
|
2446
|
+
}
|
|
2447
|
+
return {
|
|
2448
|
+
cleared: reply[1] === '1',
|
|
2449
|
+
index: Number(reply[2]),
|
|
2450
|
+
clearIndex: Number(reply[3]),
|
|
2451
|
+
}
|
|
2452
|
+
})
|
|
2453
|
+
},
|
|
2454
|
+
|
|
2455
|
+
async reclaim(sessionId) {
|
|
2456
|
+
return wrap(async () => {
|
|
2457
|
+
const [log, ...pairs] = (await evalScript(
|
|
2458
|
+
RECLAIM_BATCH_LUA,
|
|
2459
|
+
[countKey(sessionId)],
|
|
2460
|
+
[READ_PAGE_SIZE],
|
|
2461
|
+
)) as string[]
|
|
2462
|
+
if (log === undefined) return { deleted: 0, done: true }
|
|
2463
|
+
// Id keys are built here, like append's: they use JSON.stringify,
|
|
2464
|
+
// whose escaping cjson does not reproduce.
|
|
2465
|
+
const ids: string[] = []
|
|
2466
|
+
for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]!)
|
|
2467
|
+
const [deleted, done] = (await evalScript(
|
|
2468
|
+
RECLAIM_LUA,
|
|
2469
|
+
[
|
|
2470
|
+
log,
|
|
2471
|
+
countKey(sessionId),
|
|
2472
|
+
metaKey(sessionId),
|
|
2473
|
+
metaSplitKey(sessionId),
|
|
2474
|
+
...ids.map((id) => idKey(id)),
|
|
2475
|
+
],
|
|
2476
|
+
[sessionId, snapshotEventPinsPrefix(sessionId), ids.length, ...pairs],
|
|
2477
|
+
)) as [number, number]
|
|
2478
|
+
return { deleted: Number(deleted), done: Number(done) === 1 }
|
|
2479
|
+
})
|
|
2480
|
+
},
|
|
2481
|
+
|
|
1543
2482
|
async readEvents(sessionId, afterIndex) {
|
|
1544
2483
|
return wrap(async () => {
|
|
1545
2484
|
const entries = (await call(
|
|
@@ -1553,9 +2492,27 @@ export function createRedisStoreCore(
|
|
|
1553
2492
|
return entries.map((entry) => toEvent(sessionId, entry))
|
|
1554
2493
|
})
|
|
1555
2494
|
},
|
|
1556
|
-
|
|
1557
|
-
|
|
1558
|
-
|
|
2495
|
+
async readFeedPage(sessionId, afterIndex) {
|
|
2496
|
+
return wrap(async () => {
|
|
2497
|
+
const range = [
|
|
2498
|
+
storeKey(sessionId),
|
|
2499
|
+
`${afterIndex + 1}-0`,
|
|
2500
|
+
'+',
|
|
2501
|
+
'COUNT',
|
|
2502
|
+
FEED_PAGE_SIZE,
|
|
2503
|
+
] as const
|
|
2504
|
+
if (callBytes) {
|
|
2505
|
+
const entries = (await callBytes(
|
|
2506
|
+
'XRANGE',
|
|
2507
|
+
...range,
|
|
2508
|
+
)) as ByteStreamEntry[]
|
|
2509
|
+
return entries.map((entry) =>
|
|
2510
|
+
bytesToSerializedEvent(sessionId, entry),
|
|
2511
|
+
)
|
|
2512
|
+
}
|
|
2513
|
+
const entries = (await call('XRANGE', ...range)) as StreamEntry[]
|
|
2514
|
+
return entries.map((entry) => toSerializedEvent(sessionId, entry))
|
|
2515
|
+
})
|
|
1559
2516
|
},
|
|
1560
2517
|
}
|
|
1561
2518
|
}
|