experimental-a2 0.19.0 → 0.21.1
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 +50 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
- package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
- package/dist/{actor-shared-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +54 -43
- package/dist/actor.js.map +1 -1
- package/dist/{ai-Cz3hqVhk.d.ts → ai-CvEnqVfo.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
- package/dist/ai-CzE672Q3.js.map +1 -0
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +36 -9
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
- package/dist/ai-harness-Bfk-Lycn.d.ts.map +1 -0
- package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-dQeJaR1t.js} +17 -4
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +4 -4
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +14 -17
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +84 -75
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BhHLoNKF.js → client-9IeTsiAs.js} +5 -5
- package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
- package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
- package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
- package/dist/contract-CWukt01d.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
- package/dist/{internal-CscSY7K_.js → internal-Bc4PvfHk.js} +10 -3
- package/dist/internal-Bc4PvfHk.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/otel.js.map +1 -1
- package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
- package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
- package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
- package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
- package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
- package/dist/runtime-consumer-BWmht60z.js +12 -0
- package/dist/runtime-consumer-BWmht60z.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +7 -7
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +5 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
- package/dist/server-BshFZ0eL.d.ts.map +1 -0
- package/dist/{server-D-Y4pqwu.js → server-DvIB8kj5.js} +423 -135
- package/dist/server-DvIB8kj5.js.map +1 -0
- package/dist/server.browser.js +1 -1
- package/dist/server.browser.js.map +1 -1
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
- package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
- package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
- package/dist/session-clear-wHd5GCpZ.js.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-Z2qjw50n.d.ts +523 -0
- package/dist/store-Z2qjw50n.d.ts.map +1 -0
- package/dist/store-codec-DwrWlgQx.js +65 -0
- package/dist/store-codec-DwrWlgQx.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +393 -210
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +1595 -673
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
- package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
- package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +517 -287
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
- package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
- package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
- package/dist/wire-CZGqNZRx.js.map +1 -0
- package/docs/01-quickstart.mdx +28 -19
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +9 -1
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +13 -12
- package/docs/agents/03-conversations.mdx +31 -21
- package/docs/agents/04-harnesses.mdx +7 -3
- package/docs/agents/meta.ts +1 -1
- package/docs/client/03-presence.mdx +18 -14
- package/docs/client/meta.ts +1 -1
- package/docs/contract/01-events.mdx +2 -2
- package/docs/contract/02-state.mdx +18 -14
- package/docs/contract/03-sessions.mdx +32 -25
- package/docs/index.mdx +31 -26
- package/docs/reference/01-core.mdx +12 -12
- package/docs/reference/02-server.mdx +247 -192
- package/docs/reference/04-ai.mdx +29 -20
- package/docs/reference/05-telemetry.mdx +146 -0
- package/docs/reference/06-errors.mdx +24 -18
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +112 -0
- package/docs/schedulers/02-vercel-queues.mdx +87 -0
- package/docs/schedulers/03-qstash.mdx +123 -0
- package/docs/schedulers/meta.ts +1 -0
- package/docs/server/01-consumers.mdx +415 -0
- package/docs/server/02-timers.mdx +33 -23
- package/docs/server/03-cancellation.mdx +52 -44
- package/docs/server/04-routes.mdx +4 -1
- package/docs/server/05-sql-projections.mdx +391 -0
- package/docs/server/06-guarantees.mdx +60 -50
- package/docs/stores/01-overview.mdx +37 -0
- package/docs/stores/02-redis.mdx +57 -0
- package/docs/stores/03-redis-http.mdx +56 -0
- package/docs/stores/04-postgres.mdx +172 -0
- package/docs/stores/05-sqlite.mdx +36 -0
- package/docs/stores/06-memory.mdx +22 -0
- package/docs/stores/meta.ts +1 -0
- package/docs/upgrades/01-overview.mdx +30 -0
- package/docs/upgrades/79-0-21.mdx +165 -0
- package/docs/upgrades/80-0-20.mdx +43 -0
- package/docs/upgrades/81-0-19.mdx +33 -0
- package/docs/upgrades/82-0-18.mdx +33 -0
- package/docs/upgrades/83-0-17.mdx +69 -0
- package/docs/upgrades/meta.ts +3 -0
- package/examples/playground/DEPLOY.md +48 -11
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/chat.test.tsx +421 -330
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/server.ts +4 -4
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/a2slack/a2slack.css +1067 -0
- package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
- package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
- package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
- package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
- package/examples/playground/app/a2slack/consumers/search.ts +37 -0
- package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
- package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
- package/examples/playground/app/a2slack/db/a2.ts +206 -0
- package/examples/playground/app/a2slack/db/index.ts +73 -0
- package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
- package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
- package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
- package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
- package/examples/playground/app/a2slack/db/queries.ts +263 -0
- package/examples/playground/app/a2slack/db/schema.ts +145 -0
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
- package/examples/playground/app/a2slack/drills/harness.ts +244 -0
- package/examples/playground/app/a2slack/events/route.ts +36 -0
- package/examples/playground/app/a2slack/identity.ts +44 -0
- package/examples/playground/app/a2slack/inbox/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/page.tsx +101 -0
- package/examples/playground/app/a2slack/lag.ts +73 -0
- package/examples/playground/app/a2slack/layout.tsx +43 -0
- package/examples/playground/app/a2slack/messages/route.ts +17 -0
- package/examples/playground/app/a2slack/model.test.ts +208 -0
- package/examples/playground/app/a2slack/model.ts +369 -0
- package/examples/playground/app/a2slack/models.test.ts +72 -0
- package/examples/playground/app/a2slack/page.tsx +10 -0
- package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
- package/examples/playground/app/a2slack/search/route.ts +40 -0
- package/examples/playground/app/a2slack/search-index.ts +108 -0
- package/examples/playground/app/a2slack/server.test.ts +315 -0
- package/examples/playground/app/a2slack/server.ts +55 -0
- package/examples/playground/app/a2slack/sessions.ts +50 -0
- package/examples/playground/app/a2slack/thread/route.ts +8 -0
- package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
- package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
- package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
- package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
- package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
- package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
- package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
- package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
- package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
- package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
- package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
- package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
- package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
- package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
- package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
- package/examples/playground/app/agent/ingress.test.ts +41 -17
- package/examples/playground/app/agent/model-input.test.tsx +92 -43
- package/examples/playground/app/agent/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +38 -65
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/activity-history.test.tsx +60 -18
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- package/examples/playground/app/components/fast-link.test.tsx +3 -5
- package/examples/playground/app/initial-state.test.tsx +86 -45
- package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
- package/examples/playground/app/orders/page.tsx +1 -1
- package/examples/playground/app/orders/server.ts +49 -30
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
- package/examples/playground/app/recovery/page.tsx +4 -4
- package/examples/playground/app/recovery/server.ts +23 -32
- package/examples/playground/drizzle.config.ts +14 -0
- package/examples/playground/next.config.ts +6 -1
- package/examples/playground/package.json +13 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/test/agent.ts +35 -0
- package/examples/playground/test/model.ts +149 -0
- package/examples/playground/test/serve.ts +184 -0
- package/examples/playground/test/server-only.ts +1 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.config.ts +27 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +7 -2
- package/sql/postgres/drizzle-schema.ts +206 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +276 -0
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +154 -115
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +41 -9
- package/src/ai-harness.ts +28 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +26 -3
- package/src/errors.ts +9 -6
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +17 -6
- package/src/otel.ts +1 -1
- package/src/postgres-notifications.ts +0 -22
- package/src/postgres-resources.ts +6 -9
- package/src/postgres-schema.ts +121 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-qstash.ts +12 -5
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +10 -4
- package/src/server.browser.ts +1 -1
- package/src/server.ts +839 -238
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +581 -255
- package/src/store-postgres.ts +1984 -943
- package/src/store-redis-core.ts +1624 -1152
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +891 -486
- package/src/store.ts +227 -97
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
- package/dist/ai-harness-CV6rXbB3.d.ts.map +0 -1
- package/dist/ai-mMiWul1T.js.map +0 -1
- package/dist/contract-BYKASlCV.js.map +0 -1
- package/dist/idempotent-replay-JdgMQRRm.js +0 -21
- package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
- package/dist/internal-CscSY7K_.js.map +0 -1
- package/dist/server-D-Y4pqwu.js.map +0 -1
- package/dist/server-DYAATFuT.d.ts.map +0 -1
- package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
- package/dist/session-clear-DGI4jTQ5.js.map +0 -1
- package/dist/store-CyLUy86v.d.ts +0 -404
- package/dist/store-CyLUy86v.d.ts.map +0 -1
- package/dist/store-codec-DTG0Ftek.js +0 -10
- package/dist/store-codec-DTG0Ftek.js.map +0 -1
- package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
- package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/reference/05-adapters.mdx +0 -351
- package/docs/server/01-handlers.mdx +0 -200
- package/docs/server/05-production.mdx +0 -323
|
@@ -0,0 +1,2919 @@
|
|
|
1
|
+
import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-wHd5GCpZ.js";
|
|
2
|
+
import { _ as nullProtoRecord, r as NOTIFY_TIMINGS, x as stateTailReaders } from "./internal-Bc4PvfHk.js";
|
|
3
|
+
import { i as streamActivities, n as pollingStream, r as createStreamActivity, t as defaultSleep } from "./store-polling-DYNyxCev.js";
|
|
4
|
+
import { n as isSerializedEvent, r as parseSerializedEvent, t as bufferedEvents } from "./stream-buffer-Cx7AAPGq.js";
|
|
5
|
+
import { a as idempotentReplay, n as decodeCause } from "./store-codec-DwrWlgQx.js";
|
|
6
|
+
import { t as pollPresence } from "./store-presence-polling-CzQ0wdtM.js";
|
|
7
|
+
//#region src/presence-recovery.ts
|
|
8
|
+
const key = ({ participant, field }) => JSON.stringify([participant, field]);
|
|
9
|
+
function presenceRecovery({ read, deliver, isActive }) {
|
|
10
|
+
const fields = /* @__PURE__ */ new Map();
|
|
11
|
+
const reading = /* @__PURE__ */ new Set();
|
|
12
|
+
let generation = 0;
|
|
13
|
+
let latest;
|
|
14
|
+
const remember = ({ participant, field, seen, at }) => {
|
|
15
|
+
const id = key({
|
|
16
|
+
participant,
|
|
17
|
+
field
|
|
18
|
+
});
|
|
19
|
+
const previous = fields.get(id);
|
|
20
|
+
if (!previous || previous.at.getTime() < at.getTime() || previous.at.getTime() === at.getTime() && previous.seen !== seen) fields.set(id, {
|
|
21
|
+
participant,
|
|
22
|
+
field,
|
|
23
|
+
seen,
|
|
24
|
+
at
|
|
25
|
+
});
|
|
26
|
+
};
|
|
27
|
+
const snapshot = async ({ reconcile }) => {
|
|
28
|
+
const run = generation;
|
|
29
|
+
const before = new Map(fields);
|
|
30
|
+
const changed = /* @__PURE__ */ new Set();
|
|
31
|
+
reading.add(changed);
|
|
32
|
+
try {
|
|
33
|
+
const rows = await read();
|
|
34
|
+
if (!isActive()) return rows;
|
|
35
|
+
if (run !== generation) return latest;
|
|
36
|
+
const live = /* @__PURE__ */ new Set();
|
|
37
|
+
for (const row of rows) {
|
|
38
|
+
const id = key(row);
|
|
39
|
+
live.add(id);
|
|
40
|
+
if (changed.has(id)) continue;
|
|
41
|
+
remember(row);
|
|
42
|
+
if (reconcile) deliver({
|
|
43
|
+
participant: row.participant,
|
|
44
|
+
values: { [row.field]: row.value },
|
|
45
|
+
seen: row.seen,
|
|
46
|
+
at: row.at
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
if (reconcile) for (const [id, prior] of before) {
|
|
50
|
+
if (live.has(id) || changed.has(id) || fields.get(id) !== prior) continue;
|
|
51
|
+
fields.delete(id);
|
|
52
|
+
deliver({
|
|
53
|
+
participant: prior.participant,
|
|
54
|
+
values: { [prior.field]: null },
|
|
55
|
+
seen: prior.seen,
|
|
56
|
+
at: prior.at
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return rows;
|
|
60
|
+
} finally {
|
|
61
|
+
reading.delete(changed);
|
|
62
|
+
if (reading.size === 0) latest = void 0;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
async read() {
|
|
67
|
+
let pending = latest;
|
|
68
|
+
while (pending) {
|
|
69
|
+
await pending;
|
|
70
|
+
if (latest === pending) break;
|
|
71
|
+
pending = latest;
|
|
72
|
+
}
|
|
73
|
+
return snapshot({ reconcile: false });
|
|
74
|
+
},
|
|
75
|
+
async reconnect() {
|
|
76
|
+
generation += 1;
|
|
77
|
+
latest = snapshot({ reconcile: true });
|
|
78
|
+
await latest;
|
|
79
|
+
},
|
|
80
|
+
update(patch) {
|
|
81
|
+
for (const [field, value] of Object.entries(patch.values)) {
|
|
82
|
+
if (value === void 0) continue;
|
|
83
|
+
const id = key({
|
|
84
|
+
participant: patch.participant,
|
|
85
|
+
field
|
|
86
|
+
});
|
|
87
|
+
const previous = fields.get(id);
|
|
88
|
+
if (previous && previous.at.getTime() > patch.at.getTime()) continue;
|
|
89
|
+
for (const changed of reading) changed.add(id);
|
|
90
|
+
if (value === null) fields.delete(id);
|
|
91
|
+
else remember({
|
|
92
|
+
participant: patch.participant,
|
|
93
|
+
field,
|
|
94
|
+
seen: patch.seen,
|
|
95
|
+
at: patch.at
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
const FEED_PAGE_SIZE = 1e3;
|
|
102
|
+
const READ_CONCURRENCY = 16;
|
|
103
|
+
/** The key layout this core writes; any other is rejected. */
|
|
104
|
+
const REDIS_LAYOUT = "cursors-2";
|
|
105
|
+
const LAYOUT_ERROR = "A2_LAYOUT";
|
|
106
|
+
const META_CHUNK_SIZE = 1e3;
|
|
107
|
+
/** A head row's dispatch columns, in the order scripts return them. */
|
|
108
|
+
const HEAD_COLUMNS = `{ 'd', 'fc', 'lc', 'ch', 'ce', 'a', 'lf', 'lfa', 'e', 'f' }`;
|
|
109
|
+
/**
|
|
110
|
+
* Upstash bills every call a script makes, so scripts read the counter,
|
|
111
|
+
* clear marker, layout, typed-type index, and id keys with one MGET
|
|
112
|
+
* (`read_bounds`), read rows with one HMGET per chunk (`read_rows`), and
|
|
113
|
+
* buffer hash writes (`put`, `drop`) into one HSET and one HDEL per hash
|
|
114
|
+
* (`flush`). Buffered writes are invisible to reads until flushed, so a
|
|
115
|
+
* script must not read a field it has buffered. Every script binds KEYS[1]
|
|
116
|
+
* to the session's counter and KEYS[2] to the prefix's layout key, and
|
|
117
|
+
* derives the session's other keys.
|
|
118
|
+
*/
|
|
119
|
+
const BASE_LUA = `
|
|
120
|
+
local function sliced(command, key, args)
|
|
121
|
+
local results = {}
|
|
122
|
+
for first = 1, #args, 1000 do
|
|
123
|
+
local last = math.min(first + 999, #args)
|
|
124
|
+
local reply
|
|
125
|
+
if key then
|
|
126
|
+
reply = redis.call(command, key, unpack(args, first, last))
|
|
127
|
+
else
|
|
128
|
+
reply = redis.call(command, unpack(args, first, last))
|
|
129
|
+
end
|
|
130
|
+
if type(reply) == 'table' then
|
|
131
|
+
for _, value in ipairs(reply) do results[#results + 1] = value end
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
return results
|
|
135
|
+
end
|
|
136
|
+
local COUNTER, SCHEMA = KEYS[1], KEYS[2]
|
|
137
|
+
local SESSION = string.sub(COUNTER, 1, -6)
|
|
138
|
+
local LOG, CLEAR = SESSION .. 'log', SESSION .. 'clear'
|
|
139
|
+
local META, READY, CURSORS = SESSION .. 'heads', SESSION .. 'runnable', SESSION .. 'cursors'
|
|
140
|
+
local PIN_COUNTS, TYPED = SESSION .. 'pins', SESSION .. 'typed'
|
|
141
|
+
local CLEAR_TYPE = '${CLEAR_EVENT_TYPE}'
|
|
142
|
+
local total, boundary, schema_missing = 0, 0, false
|
|
143
|
+
-- The event types typed log cursors read: type → { from, last }. The type's
|
|
144
|
+
-- index holds every event of it above \`from\`, and no event of it lies above
|
|
145
|
+
-- \`last\`.
|
|
146
|
+
local typed, typed_dirty = {}, false
|
|
147
|
+
-- Reads the counter, the latest clear marker, the layout, the typed types,
|
|
148
|
+
-- and the extra keys in one MGET. Returns the extra keys' values, or nil when
|
|
149
|
+
-- the prefix holds another layout.
|
|
150
|
+
local function read_bounds(extra)
|
|
151
|
+
local keys = { COUNTER, CLEAR, SCHEMA, TYPED }
|
|
152
|
+
for _, key in ipairs(extra or {}) do keys[#keys + 1] = key end
|
|
153
|
+
local values = sliced('MGET', nil, keys)
|
|
154
|
+
if values[3] and values[3] ~= '${REDIS_LAYOUT}' then return nil end
|
|
155
|
+
total = tonumber(values[1] or '0')
|
|
156
|
+
boundary = tonumber(values[2] or '0')
|
|
157
|
+
schema_missing = not values[3]
|
|
158
|
+
if values[4] then typed = cjson.decode(values[4]) end
|
|
159
|
+
local rest = {}
|
|
160
|
+
for i = 5, #keys do rest[i - 4] = values[i] end
|
|
161
|
+
return rest
|
|
162
|
+
end
|
|
163
|
+
local function entry_index(id) return tonumber(string.match(id, '^(%d+)')) end
|
|
164
|
+
local function cid_of(consumer, tag)
|
|
165
|
+
return tostring(string.len(consumer)) .. ':' .. consumer .. tag
|
|
166
|
+
end
|
|
167
|
+
local function parse_cid(cid)
|
|
168
|
+
local colon = string.find(cid, ':', 1, true)
|
|
169
|
+
local n = tonumber(string.sub(cid, 1, colon - 1))
|
|
170
|
+
return string.sub(cid, colon + 1, colon + n), string.sub(cid, colon + n + 1)
|
|
171
|
+
end
|
|
172
|
+
-- A cursor's rows: its consumer, then '#' for an event cursor or 'L' .. lane
|
|
173
|
+
-- for a lane or log cursor, as requests name it.
|
|
174
|
+
local function row_of(consumer, tag)
|
|
175
|
+
local kind = string.sub(tag, 1, 1)
|
|
176
|
+
if kind == '#' then return cid_of(consumer, '#') end
|
|
177
|
+
if kind == 'G' then return cid_of(consumer, 'L' .. string.sub(tag, 2)) end
|
|
178
|
+
return cid_of(consumer, tag)
|
|
179
|
+
end
|
|
180
|
+
local function chunk_key(idx)
|
|
181
|
+
return META .. ':' .. tostring(math.floor((idx - 1) / ${META_CHUNK_SIZE}))
|
|
182
|
+
end
|
|
183
|
+
local function row_name(idx, row, suffix)
|
|
184
|
+
return tostring(idx) .. ':' .. row .. ':' .. suffix
|
|
185
|
+
end
|
|
186
|
+
-- Rows of (index, row) refs, aligned with refs; one HMGET per chunk.
|
|
187
|
+
local function read_rows(refs, suffixes)
|
|
188
|
+
local groups, order = {}, {}
|
|
189
|
+
for position, ref in ipairs(refs) do
|
|
190
|
+
local key = chunk_key(ref[1])
|
|
191
|
+
local group = groups[key]
|
|
192
|
+
if not group then
|
|
193
|
+
group = { positions = {}, names = {} }
|
|
194
|
+
groups[key] = group
|
|
195
|
+
order[#order + 1] = key
|
|
196
|
+
end
|
|
197
|
+
group.positions[#group.positions + 1] = position
|
|
198
|
+
for _, suffix in ipairs(suffixes) do
|
|
199
|
+
group.names[#group.names + 1] = row_name(ref[1], ref[2], suffix)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
local rows = {}
|
|
203
|
+
for _, key in ipairs(order) do
|
|
204
|
+
local group = groups[key]
|
|
205
|
+
local values = sliced('HMGET', key, group.names)
|
|
206
|
+
for n, position in ipairs(group.positions) do
|
|
207
|
+
local row = {}
|
|
208
|
+
for column = 1, #suffixes do row[column] = values[(n - 1) * #suffixes + column] end
|
|
209
|
+
rows[position] = row
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
return rows
|
|
213
|
+
end
|
|
214
|
+
local writes, write_order, deletes, delete_order = {}, {}, {}, {}
|
|
215
|
+
local function put(key, field, value)
|
|
216
|
+
local fields = writes[key]
|
|
217
|
+
if not fields then
|
|
218
|
+
fields = {}
|
|
219
|
+
writes[key] = fields
|
|
220
|
+
write_order[#write_order + 1] = key
|
|
221
|
+
end
|
|
222
|
+
fields[#fields + 1] = field
|
|
223
|
+
fields[#fields + 1] = value
|
|
224
|
+
end
|
|
225
|
+
local function drop(key, field)
|
|
226
|
+
local fields = deletes[key]
|
|
227
|
+
if not fields then
|
|
228
|
+
fields = {}
|
|
229
|
+
deletes[key] = fields
|
|
230
|
+
delete_order[#delete_order + 1] = key
|
|
231
|
+
end
|
|
232
|
+
fields[#fields + 1] = field
|
|
233
|
+
end
|
|
234
|
+
local function flush()
|
|
235
|
+
for _, key in ipairs(write_order) do sliced('HSET', key, writes[key]) end
|
|
236
|
+
for _, key in ipairs(delete_order) do sliced('HDEL', key, deletes[key]) end
|
|
237
|
+
writes, write_order, deletes, delete_order = {}, {}, {}, {}
|
|
238
|
+
end
|
|
239
|
+
local function put_row(idx, row, suffix, value)
|
|
240
|
+
put(chunk_key(idx), row_name(idx, row, suffix), value)
|
|
241
|
+
end
|
|
242
|
+
local function drop_row(idx, row, suffix)
|
|
243
|
+
drop(chunk_key(idx), row_name(idx, row, suffix))
|
|
244
|
+
end
|
|
245
|
+
-- A request names a cursor by tag: '#' for an event cursor, 'L' .. lane for a
|
|
246
|
+
-- lane or log cursor. A claimed row records its kind: '#', or the cursor's tag.
|
|
247
|
+
local function owns(kind, tag)
|
|
248
|
+
if not kind then return false end
|
|
249
|
+
if tag == '#' then return kind == '#' end
|
|
250
|
+
return kind == tag or kind == 'G' .. string.sub(tag, 2)
|
|
251
|
+
end
|
|
252
|
+
local function cid_for(consumer, kind, idx)
|
|
253
|
+
if kind == '#' then return cid_of(consumer, '#' .. tostring(idx)) end
|
|
254
|
+
return cid_of(consumer, kind)
|
|
255
|
+
end
|
|
256
|
+
-- One type's index, named by the type's bytes in hex so no type can spell
|
|
257
|
+
-- another key of the session.
|
|
258
|
+
local function type_key(name)
|
|
259
|
+
return SESSION .. 'type:' .. (string.gsub(name, '.', function(c)
|
|
260
|
+
return string.format('%02x', string.byte(c))
|
|
261
|
+
end))
|
|
262
|
+
end
|
|
263
|
+
local ready_changes, ready_order = {}, {}
|
|
264
|
+
local function ready_set(cid, score)
|
|
265
|
+
if ready_changes[cid] == nil then ready_order[#ready_order + 1] = cid end
|
|
266
|
+
ready_changes[cid] = score
|
|
267
|
+
end
|
|
268
|
+
local function flush_ready()
|
|
269
|
+
local adds, removes = {}, {}
|
|
270
|
+
for _, cid in ipairs(ready_order) do
|
|
271
|
+
local score = ready_changes[cid]
|
|
272
|
+
if score then
|
|
273
|
+
adds[#adds + 1] = score
|
|
274
|
+
adds[#adds + 1] = cid
|
|
275
|
+
else
|
|
276
|
+
removes[#removes + 1] = cid
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
sliced('ZADD', READY, adds)
|
|
280
|
+
sliced('ZREM', READY, removes)
|
|
281
|
+
ready_changes, ready_order = {}, {}
|
|
282
|
+
end
|
|
283
|
+
local function release_pins(idx, row, pins)
|
|
284
|
+
if not pins or pins == '' then return end
|
|
285
|
+
for _, ref in ipairs(cjson.decode(pins)) do
|
|
286
|
+
local count = redis.call('HINCRBY', PIN_COUNTS, ref, -1)
|
|
287
|
+
if count <= 0 then
|
|
288
|
+
redis.call('HDEL', PIN_COUNTS, ref)
|
|
289
|
+
local checkpoint = cjson.decode(ref)
|
|
290
|
+
redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
put_row(idx, row, 'pins', '')
|
|
294
|
+
end
|
|
295
|
+
`;
|
|
296
|
+
/**
|
|
297
|
+
* Cursor movement for scripts that add events or move cursors. Lane and log
|
|
298
|
+
* cursor records are read once per script (`load_states`) and written back
|
|
299
|
+
* once (`flush_states`). A record whose head lies at or below the latest clear
|
|
300
|
+
* marker is idle, and a log cursor counts from the marker.
|
|
301
|
+
*
|
|
302
|
+
* A typed log cursor finds its next head in its types' indexes, one bounded
|
|
303
|
+
* lookup per type, skipped when `typed` shows no newer event of it. When an
|
|
304
|
+
* index does not reach back far enough yet (a typed backfill, or a type a
|
|
305
|
+
* cursor newly reads), the lookup returns the type, and the script returns
|
|
306
|
+
* `{ 'index', type, after }` before writing anything: the caller indexes
|
|
307
|
+
* that type down to `after` (INDEX_TYPE_LUA, one page per call) and runs the
|
|
308
|
+
* operation again.
|
|
309
|
+
*/
|
|
310
|
+
const CURSOR_LUA = `
|
|
311
|
+
local served_all, served_set, served_logs = true, {}, {}
|
|
312
|
+
local pending_fast = false
|
|
313
|
+
local function types_of(raw)
|
|
314
|
+
if not raw or raw == '*' then return nil end
|
|
315
|
+
local set = {}
|
|
316
|
+
for _, name in ipairs(cjson.decode(raw)) do set[name] = true end
|
|
317
|
+
return set
|
|
318
|
+
end
|
|
319
|
+
local function takes(types, kind)
|
|
320
|
+
return kind ~= CLEAR_TYPE and (types == nil or types[kind] == true)
|
|
321
|
+
end
|
|
322
|
+
-- The served consumers: '' for every consumer, else [[name] or
|
|
323
|
+
-- [name, lane, typesJson or '*', backfill]].
|
|
324
|
+
local function use_served(raw)
|
|
325
|
+
if raw == '' then return end
|
|
326
|
+
served_all = false
|
|
327
|
+
for _, entry in ipairs(cjson.decode(raw)) do
|
|
328
|
+
served_set[entry[1]] = true
|
|
329
|
+
if entry[2] then
|
|
330
|
+
served_logs[#served_logs + 1] = {
|
|
331
|
+
name = entry[1], lane = entry[2], ty = entry[3],
|
|
332
|
+
types = types_of(entry[3]), backfill = entry[4] == 1,
|
|
333
|
+
}
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
337
|
+
local function served_has(name) return served_all or served_set[name] == true end
|
|
338
|
+
-- Whether a served consumer has a live head. Newest heads come first: a
|
|
339
|
+
-- removed consumer's backlog is older than the work a deployment serves.
|
|
340
|
+
local function has_pending()
|
|
341
|
+
if pending_fast then return '1' end
|
|
342
|
+
if served_all then return redis.call('ZCOUNT', READY, 1, '+inf') > 0 and '1' or '0' end
|
|
343
|
+
if next(served_set) == nil then return '0' end
|
|
344
|
+
local page = redis.call('ZREVRANGEBYSCORE', READY, '+inf', 1, 'LIMIT', 0, 32)
|
|
345
|
+
for _, cid in ipairs(page) do
|
|
346
|
+
if served_set[(parse_cid(cid))] then return '1' end
|
|
347
|
+
end
|
|
348
|
+
if #page < 32 then return '0' end
|
|
349
|
+
for _, cid in ipairs(redis.call('ZREVRANGEBYSCORE', READY, '+inf', 1, 'LIMIT', 32, -1)) do
|
|
350
|
+
if served_set[(parse_cid(cid))] then return '1' end
|
|
351
|
+
end
|
|
352
|
+
return '0'
|
|
353
|
+
end
|
|
354
|
+
local function targets_of(raw)
|
|
355
|
+
if raw == '' then return {} end
|
|
356
|
+
return cjson.decode(raw)
|
|
357
|
+
end
|
|
358
|
+
-- Starts indexing a type: every later event of it.
|
|
359
|
+
local function register(name, from)
|
|
360
|
+
typed[name] = { from, from }
|
|
361
|
+
typed_dirty = true
|
|
362
|
+
end
|
|
363
|
+
-- The first stored event in (after, total] of a type in \`types\` (nil: any
|
|
364
|
+
-- type), or false. Nil and a type when that type's index can't answer yet.
|
|
365
|
+
local function stored_head(after, types)
|
|
366
|
+
if types == nil then
|
|
367
|
+
if after + 1 <= total then return after + 1 end
|
|
368
|
+
return false
|
|
369
|
+
end
|
|
370
|
+
local best = false
|
|
371
|
+
for name in pairs(types) do
|
|
372
|
+
local entry = typed[name]
|
|
373
|
+
if entry == nil then
|
|
374
|
+
if after < total then return nil, name end
|
|
375
|
+
register(name, total)
|
|
376
|
+
elseif entry[2] > after then
|
|
377
|
+
if entry[1] > after then return nil, name end
|
|
378
|
+
local found = redis.call('ZRANGEBYSCORE', type_key(name),
|
|
379
|
+
'(' .. tostring(after), tostring(total), 'LIMIT', 0, 1)
|
|
380
|
+
if #found > 0 then
|
|
381
|
+
local idx = tonumber(found[1])
|
|
382
|
+
if not best or idx < best then best = idx end
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
return best
|
|
387
|
+
end
|
|
388
|
+
-- The first event after \`after\` a log cursor takes: stored, then in the
|
|
389
|
+
-- batch being appended.
|
|
390
|
+
local function next_log_head(after, types, batch)
|
|
391
|
+
local head, missing = stored_head(after, types)
|
|
392
|
+
if head == nil then return nil, missing end
|
|
393
|
+
if head then return head end
|
|
394
|
+
for _, event in ipairs(batch) do
|
|
395
|
+
if event.idx > after and takes(types, event.type) then return event.idx end
|
|
396
|
+
end
|
|
397
|
+
return false
|
|
398
|
+
end
|
|
399
|
+
-- Indexes a batch's events of the types typed log cursors read.
|
|
400
|
+
local function index_batch(batch)
|
|
401
|
+
local adds, order = {}, {}
|
|
402
|
+
for _, event in ipairs(batch) do
|
|
403
|
+
local entry = typed[event.type]
|
|
404
|
+
if entry then
|
|
405
|
+
local list = adds[event.type]
|
|
406
|
+
if not list then
|
|
407
|
+
list = {}
|
|
408
|
+
adds[event.type] = list
|
|
409
|
+
order[#order + 1] = event.type
|
|
410
|
+
end
|
|
411
|
+
list[#list + 1] = event.idx
|
|
412
|
+
list[#list + 1] = tostring(event.idx)
|
|
413
|
+
entry[2] = event.idx
|
|
414
|
+
typed_dirty = true
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
for _, name in ipairs(order) do sliced('ZADD', type_key(name), adds[name]) end
|
|
418
|
+
end
|
|
419
|
+
local states, state_order = {}, {}
|
|
420
|
+
local function load_states(cids)
|
|
421
|
+
local need, names = {}, {}
|
|
422
|
+
for _, cid in ipairs(cids) do
|
|
423
|
+
local state = states[cid]
|
|
424
|
+
if (state == nil or state.partial) and not need[cid] then
|
|
425
|
+
need[cid] = true
|
|
426
|
+
need[#need + 1] = cid
|
|
427
|
+
names[#names + 1] = cid .. ':h'
|
|
428
|
+
names[#names + 1] = cid .. ':p'
|
|
429
|
+
names[#names + 1] = cid .. ':t'
|
|
430
|
+
names[#names + 1] = cid .. ':ty'
|
|
431
|
+
end
|
|
432
|
+
end
|
|
433
|
+
if #need == 0 then return end
|
|
434
|
+
local values = sliced('HMGET', CURSORS, names)
|
|
435
|
+
for n, cid in ipairs(need) do
|
|
436
|
+
local h, p, t, ty = values[n * 4 - 3], values[n * 4 - 2], values[n * 4 - 1], values[n * 4]
|
|
437
|
+
local tail = (t and t ~= '') and tonumber(t) or false
|
|
438
|
+
local state = states[cid]
|
|
439
|
+
if state then
|
|
440
|
+
state.t = tail
|
|
441
|
+
state.partial = nil
|
|
442
|
+
else
|
|
443
|
+
local _, tag = parse_cid(cid)
|
|
444
|
+
state = {
|
|
445
|
+
kind = string.sub(tag, 1, 1), exists = p ~= false,
|
|
446
|
+
h = (h and h ~= '') and tonumber(h) or false,
|
|
447
|
+
p = tonumber(p or '0'), t = tail, ty = ty or false,
|
|
448
|
+
}
|
|
449
|
+
if state.h and state.h <= boundary then state.h = false end
|
|
450
|
+
if state.kind == 'G' and state.exists and state.p < boundary then state.p = boundary end
|
|
451
|
+
states[cid] = state
|
|
452
|
+
state_order[#state_order + 1] = cid
|
|
453
|
+
end
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
local function flush_states()
|
|
457
|
+
for _, cid in ipairs(state_order) do
|
|
458
|
+
local state = states[cid]
|
|
459
|
+
if state.dirty then
|
|
460
|
+
put(CURSORS, cid .. ':h', state.h and tostring(state.h) or '')
|
|
461
|
+
put(CURSORS, cid .. ':p', tostring(state.p))
|
|
462
|
+
if state.kind == 'L' then
|
|
463
|
+
if not state.partial then put(CURSORS, cid .. ':t', state.t and tostring(state.t) or '') end
|
|
464
|
+
elseif state.ty then
|
|
465
|
+
put(CURSORS, cid .. ':ty', state.ty)
|
|
466
|
+
end
|
|
467
|
+
end
|
|
468
|
+
end
|
|
469
|
+
end
|
|
470
|
+
-- Moves a cursor past its head \`idx\`. \`kind\` is the head row's kind;
|
|
471
|
+
-- \`batch\` is the returned events, appended after the stored log. Writes
|
|
472
|
+
-- nothing; returns nil and a type when that type's index must reach back
|
|
473
|
+
-- to \`idx\` first.
|
|
474
|
+
local function advance(consumer, kind, idx, row_next, batch)
|
|
475
|
+
if kind == '#' then
|
|
476
|
+
ready_set(cid_of(consumer, '#' .. tostring(idx)), false)
|
|
477
|
+
return true
|
|
478
|
+
end
|
|
479
|
+
local cid = cid_of(consumer, kind)
|
|
480
|
+
local state = states[cid]
|
|
481
|
+
local next_head
|
|
482
|
+
if string.sub(kind, 1, 1) == 'L' then
|
|
483
|
+
next_head = row_next and tonumber(row_next) or false
|
|
484
|
+
if not state then
|
|
485
|
+
state = { kind = 'L', exists = true, p = idx, h = false, t = false, ty = false, partial = true }
|
|
486
|
+
states[cid] = state
|
|
487
|
+
state_order[#state_order + 1] = cid
|
|
488
|
+
end
|
|
489
|
+
if not next_head then
|
|
490
|
+
state.t = false
|
|
491
|
+
state.partial = nil
|
|
492
|
+
end
|
|
493
|
+
else
|
|
494
|
+
local lane, raw = string.sub(kind, 2), nil
|
|
495
|
+
for _, spec in ipairs(served_logs) do
|
|
496
|
+
if spec.name == consumer and spec.lane == lane then raw = spec.ty end
|
|
497
|
+
end
|
|
498
|
+
if not raw then
|
|
499
|
+
load_states({ cid })
|
|
500
|
+
raw = states[cid].ty or '*'
|
|
501
|
+
end
|
|
502
|
+
local head, missing = next_log_head(idx, types_of(raw), batch)
|
|
503
|
+
if head == nil then return nil, missing end
|
|
504
|
+
next_head = head
|
|
505
|
+
state = states[cid]
|
|
506
|
+
if not state then
|
|
507
|
+
state = { kind = 'G', exists = true, p = idx, h = false, t = false, ty = raw }
|
|
508
|
+
states[cid] = state
|
|
509
|
+
state_order[#state_order + 1] = cid
|
|
510
|
+
end
|
|
511
|
+
state.ty = raw
|
|
512
|
+
end
|
|
513
|
+
state.h = next_head
|
|
514
|
+
state.p = idx
|
|
515
|
+
state.exists = true
|
|
516
|
+
state.dirty = true
|
|
517
|
+
ready_set(cid, next_head)
|
|
518
|
+
return true
|
|
519
|
+
end
|
|
520
|
+
-- Reads what an appended batch's cursor work needs: its lane targets'
|
|
521
|
+
-- records, and the head of each served log consumer whose types it carries.
|
|
522
|
+
-- Writes nothing; returns nil, a type, and an index when that type's index
|
|
523
|
+
-- must reach back to the index first.
|
|
524
|
+
local log_plan = {}
|
|
525
|
+
local function plan(batch, base)
|
|
526
|
+
local need = {}
|
|
527
|
+
for _, event in ipairs(batch) do
|
|
528
|
+
for _, target in ipairs(event.targets) do
|
|
529
|
+
if target[2] ~= '#' then need[#need + 1] = cid_of(target[1], target[2]) end
|
|
530
|
+
end
|
|
531
|
+
end
|
|
532
|
+
for _, spec in ipairs(served_logs) do
|
|
533
|
+
for _, event in ipairs(batch) do
|
|
534
|
+
if takes(spec.types, event.type) then
|
|
535
|
+
local cid = cid_of(spec.name, 'G' .. spec.lane)
|
|
536
|
+
log_plan[#log_plan + 1] = { spec = spec, first = event.idx, cid = cid }
|
|
537
|
+
need[#need + 1] = cid
|
|
538
|
+
break
|
|
539
|
+
end
|
|
540
|
+
end
|
|
541
|
+
end
|
|
542
|
+
load_states(need)
|
|
543
|
+
for _, log in ipairs(log_plan) do
|
|
544
|
+
local state, spec = states[log.cid], log.spec
|
|
545
|
+
if not state.exists then
|
|
546
|
+
if spec.backfill then
|
|
547
|
+
local head, missing = stored_head(boundary, spec.types)
|
|
548
|
+
if head == nil then return nil, missing, boundary end
|
|
549
|
+
log.p, log.h = boundary, head or log.first
|
|
550
|
+
else
|
|
551
|
+
-- Nothing it takes is stored after its start: index its types from there.
|
|
552
|
+
for name in pairs(spec.types or {}) do
|
|
553
|
+
if typed[name] == nil then register(name, base) end
|
|
554
|
+
end
|
|
555
|
+
log.p, log.h = base, log.first
|
|
556
|
+
end
|
|
557
|
+
elseif not state.h then
|
|
558
|
+
-- Not the batch's first: a deployment that doesn't serve this consumer
|
|
559
|
+
-- may have appended events it takes since it went idle.
|
|
560
|
+
local head, missing = stored_head(state.p, spec.types)
|
|
561
|
+
if head == nil then return nil, missing, state.p end
|
|
562
|
+
log.h = head or log.first
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
return true
|
|
566
|
+
end
|
|
567
|
+
-- Starts the cursor work \`plan\` read: each event's targets, and each
|
|
568
|
+
-- planned log cursor.
|
|
569
|
+
local function enqueue(batch)
|
|
570
|
+
for _, event in ipairs(batch) do
|
|
571
|
+
for _, target in ipairs(event.targets) do
|
|
572
|
+
local consumer, tag = target[1], target[2]
|
|
573
|
+
if served_has(consumer) then pending_fast = true end
|
|
574
|
+
if tag == '#' then
|
|
575
|
+
ready_set(cid_of(consumer, '#' .. tostring(event.idx)), event.idx)
|
|
576
|
+
else
|
|
577
|
+
local cid = cid_of(consumer, tag)
|
|
578
|
+
local state = states[cid]
|
|
579
|
+
if state.h and state.t then
|
|
580
|
+
put_row(state.t, row_of(consumer, tag), 'next', tostring(event.idx))
|
|
581
|
+
else
|
|
582
|
+
state.h = event.idx
|
|
583
|
+
if state.p < boundary then state.p = boundary end
|
|
584
|
+
ready_set(cid, event.idx)
|
|
585
|
+
end
|
|
586
|
+
state.t = event.idx
|
|
587
|
+
state.exists = true
|
|
588
|
+
state.dirty = true
|
|
589
|
+
end
|
|
590
|
+
end
|
|
591
|
+
end
|
|
592
|
+
for _, log in ipairs(log_plan) do
|
|
593
|
+
pending_fast = true
|
|
594
|
+
local state = states[log.cid]
|
|
595
|
+
if not state.exists then
|
|
596
|
+
state.exists, state.p, state.h = true, log.p, log.h
|
|
597
|
+
ready_set(log.cid, log.h)
|
|
598
|
+
elseif not state.h then
|
|
599
|
+
state.h = log.h
|
|
600
|
+
ready_set(log.cid, log.h)
|
|
601
|
+
end
|
|
602
|
+
state.ty = log.spec.ty
|
|
603
|
+
state.dirty = true
|
|
604
|
+
end
|
|
605
|
+
end
|
|
606
|
+
-- Event-id owners for the extra keys read_bounds returned. A record this
|
|
607
|
+
-- session made below its latest clear is free.
|
|
608
|
+
local function owners_of(values, session)
|
|
609
|
+
local owners = {}
|
|
610
|
+
for i = 1, #values do
|
|
611
|
+
local owner = values[i]
|
|
612
|
+
if owner then
|
|
613
|
+
local rec = cjson.decode(owner)
|
|
614
|
+
if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
|
|
615
|
+
end
|
|
616
|
+
owners[i] = owner
|
|
617
|
+
end
|
|
618
|
+
return owners
|
|
619
|
+
end
|
|
620
|
+
`;
|
|
621
|
+
const WAKE_PAYLOAD_LIMIT = 32768;
|
|
622
|
+
/**
|
|
623
|
+
* Wake-up messages carry the events they announce, so a subscribed reader
|
|
624
|
+
* that is caught up applies them without reading the stream:
|
|
625
|
+
* `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
|
|
626
|
+
* are the stored JSON text, inlined verbatim. A batch over
|
|
627
|
+
* WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
|
|
628
|
+
* with XRANGE.
|
|
629
|
+
*/
|
|
630
|
+
const WAKE_LUA = `
|
|
631
|
+
local wake_entries, wake_bytes = {}, 0
|
|
632
|
+
local function wake_add(field, id, typ, payload)
|
|
633
|
+
if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
|
|
634
|
+
wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
|
|
635
|
+
return
|
|
636
|
+
end
|
|
637
|
+
local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
|
|
638
|
+
cjson.encode(typ) .. ',' .. payload .. ']'
|
|
639
|
+
wake_bytes = wake_bytes + #entry + 1
|
|
640
|
+
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
641
|
+
wake_entries[#wake_entries + 1] = entry
|
|
642
|
+
end
|
|
643
|
+
end
|
|
644
|
+
local function wake(channel, frontier, created_at)
|
|
645
|
+
local message = '{"f":' .. frontier .. '}'
|
|
646
|
+
if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
|
|
647
|
+
message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
|
|
648
|
+
table.concat(wake_entries, ',') .. ']}'
|
|
649
|
+
end
|
|
650
|
+
redis.pcall('PUBLISH', channel, message)
|
|
651
|
+
end
|
|
652
|
+
`;
|
|
653
|
+
/**
|
|
654
|
+
* KEYS: counter, layout, then one id ownership key per event.
|
|
655
|
+
* ARGV: sessionId, nowMs, n, served consumers, notify channel, origin index
|
|
656
|
+
* or '', then per event: id, type, payloadJson, causeJson or '', targets.
|
|
657
|
+
* Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
|
|
658
|
+
* ['dup', hasPending, ...indexes] | ['partial', count] |
|
|
659
|
+
* ['foreign', eventId] | ['superseded', position] | ['cleared', originIndex] |
|
|
660
|
+
* ['index', type, after].
|
|
661
|
+
*/
|
|
662
|
+
const APPEND_LUA = `${BASE_LUA}${CURSOR_LUA}${WAKE_LUA}
|
|
663
|
+
local session_id, now, n = ARGV[1], ARGV[2], tonumber(ARGV[3])
|
|
664
|
+
use_served(ARGV[4])
|
|
665
|
+
if n == 0 then return { 'empty', has_pending() } end
|
|
666
|
+
local id_keys = {}
|
|
667
|
+
for i = 1, n do id_keys[i] = KEYS[2 + i] end
|
|
668
|
+
local values = read_bounds(id_keys)
|
|
669
|
+
if not values then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
670
|
+
local owners = owners_of(values, session_id)
|
|
671
|
+
local base = total
|
|
672
|
+
local dups = {}
|
|
673
|
+
for i = 1, n do
|
|
674
|
+
if owners[i] then
|
|
675
|
+
local rec = cjson.decode(owners[i])
|
|
676
|
+
if rec[1] ~= session_id then return { 'foreign', ARGV[6 + (i - 1) * 5 + 1] } end
|
|
677
|
+
dups[#dups + 1] = tostring(rec[2])
|
|
678
|
+
end
|
|
679
|
+
end
|
|
680
|
+
if #dups == n then
|
|
681
|
+
local res = { 'dup', has_pending() }
|
|
682
|
+
for i = 1, #dups do res[#res + 1] = dups[i] end
|
|
683
|
+
return res
|
|
684
|
+
end
|
|
685
|
+
if #dups > 0 then return { 'partial', tostring(#dups) } end
|
|
686
|
+
local origin = ARGV[6]
|
|
687
|
+
if origin ~= '' and tonumber(origin) <= boundary then return { 'cleared', origin } end
|
|
688
|
+
-- The attempt-currency fence: a cause's head row must still be its cursor's
|
|
689
|
+
-- head, at that attempt, not dead-lettered.
|
|
690
|
+
local refs, fenced = {}, {}
|
|
691
|
+
for i = 1, n do
|
|
692
|
+
local raw = ARGV[6 + (i - 1) * 5 + 4]
|
|
693
|
+
if raw ~= '' then
|
|
694
|
+
local cause = cjson.decode(raw)
|
|
695
|
+
local index = tonumber(cause.index)
|
|
696
|
+
if index <= boundary then return { 'superseded', tostring(i) } end
|
|
697
|
+
local tag = type(cause.lane) == 'string' and ('L' .. cause.lane) or '#'
|
|
698
|
+
refs[#refs + 1] = { index, row_of(cause.consumer, tag) }
|
|
699
|
+
fenced[#fenced + 1] = {
|
|
700
|
+
position = i, consumer = cause.consumer, attempt = tonumber(cause.attempt), tag = tag,
|
|
701
|
+
}
|
|
702
|
+
end
|
|
703
|
+
end
|
|
704
|
+
local rows = read_rows(refs, { 'd', 'f', 'p', 'l' })
|
|
705
|
+
for k, cause in ipairs(fenced) do
|
|
706
|
+
local row = rows[k]
|
|
707
|
+
if tonumber(row[1] or '0') ~= cause.attempt or row[2] or row[3] or not owns(row[4], cause.tag) then
|
|
708
|
+
return { 'superseded', tostring(cause.position) }
|
|
709
|
+
end
|
|
710
|
+
if served_has(cause.consumer) then pending_fast = true end
|
|
711
|
+
end
|
|
712
|
+
local batch = {}
|
|
713
|
+
for i = 1, n do
|
|
714
|
+
local o = 6 + (i - 1) * 5
|
|
715
|
+
batch[i] = { idx = base + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 5]) }
|
|
716
|
+
end
|
|
717
|
+
local planned, missing, after = plan(batch, base)
|
|
718
|
+
if not planned then return { 'index', missing, tostring(after) } end
|
|
719
|
+
local owner_writes = {}
|
|
720
|
+
for i = 1, n do
|
|
721
|
+
local o = 6 + (i - 1) * 5
|
|
722
|
+
local field = tostring(base + i)
|
|
723
|
+
redis.call('XADD', LOG, field .. '-0',
|
|
724
|
+
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
725
|
+
'created_at', now, 'cause', ARGV[o + 4])
|
|
726
|
+
owner_writes[#owner_writes + 1] = KEYS[2 + i]
|
|
727
|
+
owner_writes[#owner_writes + 1] = cjson.encode({ session_id, base + i })
|
|
728
|
+
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
729
|
+
end
|
|
730
|
+
index_batch(batch)
|
|
731
|
+
enqueue(batch)
|
|
732
|
+
owner_writes[#owner_writes + 1] = COUNTER
|
|
733
|
+
owner_writes[#owner_writes + 1] = tostring(base + n)
|
|
734
|
+
if schema_missing then
|
|
735
|
+
owner_writes[#owner_writes + 1] = SCHEMA
|
|
736
|
+
owner_writes[#owner_writes + 1] = '${REDIS_LAYOUT}'
|
|
737
|
+
end
|
|
738
|
+
if typed_dirty then
|
|
739
|
+
owner_writes[#owner_writes + 1] = TYPED
|
|
740
|
+
owner_writes[#owner_writes + 1] = cjson.encode(typed)
|
|
741
|
+
end
|
|
742
|
+
sliced('MSET', nil, owner_writes)
|
|
743
|
+
flush_states()
|
|
744
|
+
flush()
|
|
745
|
+
flush_ready()
|
|
746
|
+
local pending = has_pending()
|
|
747
|
+
wake(ARGV[5], tostring(base + n), now)
|
|
748
|
+
return { 'ok', tostring(base + 1), pending }
|
|
749
|
+
`;
|
|
750
|
+
/**
|
|
751
|
+
* KEYS: counter, layout.
|
|
752
|
+
* ARGV: type, after. Indexes one page of the stored log below the type's
|
|
753
|
+
* index, toward `after`, registering the type first if no cursor read it
|
|
754
|
+
* yet. Returns ['1'] once the index reaches `after`, else ['0'].
|
|
755
|
+
*/
|
|
756
|
+
const INDEX_TYPE_LUA = `${BASE_LUA}
|
|
757
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
758
|
+
local name, after = ARGV[1], tonumber(ARGV[2])
|
|
759
|
+
local entry = typed[name]
|
|
760
|
+
if not entry then
|
|
761
|
+
entry = { total, total }
|
|
762
|
+
typed[name] = entry
|
|
763
|
+
end
|
|
764
|
+
if entry[1] > after then
|
|
765
|
+
local entries = redis.call('XREVRANGE', LOG, tostring(entry[1]) .. '-0',
|
|
766
|
+
tostring(after + 1) .. '-0', 'COUNT', 256)
|
|
767
|
+
local adds = {}
|
|
768
|
+
for _, stored in ipairs(entries) do
|
|
769
|
+
local fields = stored[2]
|
|
770
|
+
for j = 1, #fields, 2 do
|
|
771
|
+
if fields[j] == 'type' then
|
|
772
|
+
if fields[j + 1] == name then
|
|
773
|
+
local idx = entry_index(stored[1])
|
|
774
|
+
adds[#adds + 1] = idx
|
|
775
|
+
adds[#adds + 1] = tostring(idx)
|
|
776
|
+
end
|
|
777
|
+
break
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
sliced('ZADD', type_key(name), adds)
|
|
782
|
+
if #entries < 256 then
|
|
783
|
+
entry[1] = after
|
|
784
|
+
else
|
|
785
|
+
entry[1] = entry_index(entries[#entries][1]) - 1
|
|
786
|
+
end
|
|
787
|
+
end
|
|
788
|
+
redis.call('SET', TYPED, cjson.encode(typed))
|
|
789
|
+
return { entry[1] <= after and '1' or '0' }
|
|
790
|
+
`;
|
|
791
|
+
/**
|
|
792
|
+
* KEYS: counter, layout.
|
|
793
|
+
* ARGV: holder, nowMs, expiresAtMs, maxFailures or '', served consumers,
|
|
794
|
+
* the highest head to claim or '', excluded count, then per excluded head:
|
|
795
|
+
* consumer, tag.
|
|
796
|
+
* Live heads are the ready set's positive scores, so an empty one is enough
|
|
797
|
+
* to report 'settled'. A claimed result ends with the clear marker.
|
|
798
|
+
*/
|
|
799
|
+
const CLAIM_AVAILABLE_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
800
|
+
local ready = redis.call('ZRANGEBYSCORE', READY, 1, ARGV[6] == '' and '+inf' or ARGV[6], 'WITHSCORES')
|
|
801
|
+
if #ready == 0 then return { 'settled' } end
|
|
802
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
803
|
+
local holder, now, expires_at = ARGV[1], tonumber(ARGV[2]), ARGV[3]
|
|
804
|
+
local max_failures = tonumber(ARGV[4])
|
|
805
|
+
use_served(ARGV[5])
|
|
806
|
+
local excluded = {}
|
|
807
|
+
for i = 1, tonumber(ARGV[7]) do
|
|
808
|
+
excluded[ARGV[7 + (i - 1) * 2 + 1] .. '\\0' .. ARGV[7 + (i - 1) * 2 + 2]] = true
|
|
809
|
+
end
|
|
810
|
+
local candidates = {}
|
|
811
|
+
for i = 1, #ready, 2 do
|
|
812
|
+
local cid, idx = ready[i], tonumber(ready[i + 1])
|
|
813
|
+
local consumer, tag = parse_cid(cid)
|
|
814
|
+
local kind = string.sub(tag, 1, 1)
|
|
815
|
+
local key = consumer .. '\\0' .. (kind == 'G' and ('L' .. string.sub(tag, 2)) or tag)
|
|
816
|
+
if served_has(consumer) and not excluded[key] then
|
|
817
|
+
candidates[#candidates + 1] = {
|
|
818
|
+
idx, row_of(consumer, tag), consumer = consumer, cid = cid,
|
|
819
|
+
kind = kind == '#' and '#' or tag,
|
|
820
|
+
}
|
|
821
|
+
end
|
|
822
|
+
end
|
|
823
|
+
local rows = read_rows(candidates, { 'd', 'fc', 'lc', 'ch', 'ce', 'a', 'lf', 'lfa', 'e', 'f', 'pins' })
|
|
824
|
+
local takes_over, retry_at, wanted = {}, nil, {}
|
|
825
|
+
for position, candidate in ipairs(candidates) do
|
|
826
|
+
local row = rows[position]
|
|
827
|
+
local expires = tonumber(row[5] or '0')
|
|
828
|
+
if expires > now then
|
|
829
|
+
if not retry_at or expires < retry_at then retry_at = expires end
|
|
830
|
+
else
|
|
831
|
+
takes_over[#takes_over + 1] = { candidate = candidate, row = row }
|
|
832
|
+
wanted[candidate[1]] = true
|
|
833
|
+
end
|
|
834
|
+
end
|
|
835
|
+
if #takes_over == 0 then
|
|
836
|
+
if retry_at then return { 'busy', tostring(retry_at) } end
|
|
837
|
+
return { 'settled' }
|
|
838
|
+
end
|
|
839
|
+
-- One XRANGE per run of consecutive claimed indexes.
|
|
840
|
+
local indexes, entries = {}, {}
|
|
841
|
+
for idx in pairs(wanted) do indexes[#indexes + 1] = idx end
|
|
842
|
+
table.sort(indexes)
|
|
843
|
+
local first = 1
|
|
844
|
+
while first <= #indexes do
|
|
845
|
+
local last = first
|
|
846
|
+
while last < #indexes and indexes[last + 1] == indexes[last] + 1 do last = last + 1 end
|
|
847
|
+
local range = redis.call('XRANGE', LOG, tostring(indexes[first]) .. '-0', tostring(indexes[last]) .. '-0')
|
|
848
|
+
for _, entry in ipairs(range) do entries[entry_index(entry[1])] = entry end
|
|
849
|
+
first = last + 1
|
|
850
|
+
end
|
|
851
|
+
for _, idx in ipairs(indexes) do
|
|
852
|
+
if not entries[idx] then return { 'missing', tostring(idx) } end
|
|
853
|
+
end
|
|
854
|
+
local result = { 'claimed', tostring(#takes_over) }
|
|
855
|
+
for _, take in ipairs(takes_over) do
|
|
856
|
+
local candidate, row = take.candidate, take.row
|
|
857
|
+
local idx, at = candidate[1], candidate[2]
|
|
858
|
+
local attempt = tostring(tonumber(row[1] or '0') + 1)
|
|
859
|
+
-- A claim still held here lapsed: its attempt never finished.
|
|
860
|
+
local lapsed = row[4] ~= false
|
|
861
|
+
local failures = tonumber(row[6] or '0')
|
|
862
|
+
if lapsed then
|
|
863
|
+
failures = failures + 1
|
|
864
|
+
put_row(idx, at, 'a', tostring(failures))
|
|
865
|
+
end
|
|
866
|
+
put_row(idx, at, 'd', attempt)
|
|
867
|
+
local columns
|
|
868
|
+
if lapsed and max_failures and failures >= max_failures then
|
|
869
|
+
-- That takeover spent the last failed attempt: dead-letter, unclaimed.
|
|
870
|
+
-- Advancing the attempt fences the lapsed one; nothing runs.
|
|
871
|
+
put_row(idx, at, 'f', ARGV[2])
|
|
872
|
+
drop_row(idx, at, 'ch')
|
|
873
|
+
drop_row(idx, at, 'ce')
|
|
874
|
+
ready_set(candidate.cid, -idx)
|
|
875
|
+
release_pins(idx, at, row[11])
|
|
876
|
+
columns = { attempt, row[2], row[3], false, false, tostring(failures), row[7], row[8], row[9], ARGV[2] }
|
|
877
|
+
else
|
|
878
|
+
local first_claimed = row[2] or ARGV[2]
|
|
879
|
+
put_row(idx, at, 'fc', first_claimed)
|
|
880
|
+
put_row(idx, at, 'lc', ARGV[2])
|
|
881
|
+
put_row(idx, at, 'ch', holder)
|
|
882
|
+
put_row(idx, at, 'ce', expires_at)
|
|
883
|
+
put_row(idx, at, 'l', candidate.kind)
|
|
884
|
+
columns = { attempt, first_claimed, ARGV[2], holder, expires_at, tostring(failures), row[7], row[8], row[9], false }
|
|
885
|
+
end
|
|
886
|
+
result[#result + 1] = { candidate.consumer, candidate.kind, tostring(idx), columns, entries[idx] }
|
|
887
|
+
end
|
|
888
|
+
flush()
|
|
889
|
+
flush_ready()
|
|
890
|
+
result[#result + 1] = tostring(boundary)
|
|
891
|
+
result[#result + 1] = tostring(total)
|
|
892
|
+
return result
|
|
893
|
+
`;
|
|
894
|
+
/**
|
|
895
|
+
* KEYS: counter, layout.
|
|
896
|
+
* ARGV: holder, nowMs, expiresAtMs, n, then per claim: consumer, tag, index,
|
|
897
|
+
* attempt. Returns [renewedCount, ...renewed, ...superseded] as 1-based
|
|
898
|
+
* positions in the request.
|
|
899
|
+
*/
|
|
900
|
+
const RENEW_CLAIMS_LUA = `${BASE_LUA}
|
|
901
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
902
|
+
local holder, now, expires_at = ARGV[1], tonumber(ARGV[2]), ARGV[3]
|
|
903
|
+
local refs, items, superseded = {}, {}, {}
|
|
904
|
+
for i = 1, tonumber(ARGV[4]) do
|
|
905
|
+
local o = 4 + (i - 1) * 4
|
|
906
|
+
local idx = tonumber(ARGV[o + 3])
|
|
907
|
+
if idx <= boundary then
|
|
908
|
+
superseded[#superseded + 1] = tostring(i)
|
|
909
|
+
else
|
|
910
|
+
local row = row_of(ARGV[o + 1], ARGV[o + 2])
|
|
911
|
+
refs[#refs + 1] = { idx, row }
|
|
912
|
+
items[#items + 1] = { position = i, idx = idx, row = row, tag = ARGV[o + 2], attempt = tonumber(ARGV[o + 4]) }
|
|
913
|
+
end
|
|
914
|
+
end
|
|
915
|
+
local rows = read_rows(refs, { 'd', 'ce', 'p', 'f', 'ch', 'l' })
|
|
916
|
+
local renewed = {}
|
|
917
|
+
for k, item in ipairs(items) do
|
|
918
|
+
local row = rows[k]
|
|
919
|
+
local attempt = tonumber(row[1] or '0')
|
|
920
|
+
if row[6] and not owns(row[6], item.tag) then
|
|
921
|
+
-- Another cursor's head: this one never claimed it.
|
|
922
|
+
elseif row[3] or attempt > item.attempt then
|
|
923
|
+
superseded[#superseded + 1] = tostring(item.position)
|
|
924
|
+
elseif attempt == item.attempt and tonumber(expires_at) > now and
|
|
925
|
+
not row[4] and row[5] == holder and tonumber(row[2] or '0') > now then
|
|
926
|
+
put_row(item.idx, item.row, 'ce', expires_at)
|
|
927
|
+
renewed[#renewed + 1] = tostring(item.position)
|
|
928
|
+
end
|
|
929
|
+
end
|
|
930
|
+
flush()
|
|
931
|
+
local result = { tostring(#renewed) }
|
|
932
|
+
for i = 1, #renewed do result[#result + 1] = renewed[i] end
|
|
933
|
+
for i = 1, #superseded do result[#result + 1] = superseded[i] end
|
|
934
|
+
return result
|
|
935
|
+
`;
|
|
936
|
+
/**
|
|
937
|
+
* KEYS: counter, layout, then one id ownership key per child.
|
|
938
|
+
* ARGV: sessionId, nowMs, consumer, tag, index, attempt, child causeJson,
|
|
939
|
+
* served consumers, notify channel, returned idsJson, n, then per child: id,
|
|
940
|
+
* type, payloadJson, targets.
|
|
941
|
+
*/
|
|
942
|
+
const COMPLETE_ATTEMPT_LUA = `${BASE_LUA}${CURSOR_LUA}${WAKE_LUA}
|
|
943
|
+
local session_id, now = ARGV[1], ARGV[2]
|
|
944
|
+
local consumer, tag, idx, attempt = ARGV[3], ARGV[4], tonumber(ARGV[5]), tonumber(ARGV[6])
|
|
945
|
+
local at = row_of(consumer, tag)
|
|
946
|
+
local n = tonumber(ARGV[11])
|
|
947
|
+
local id_keys = {}
|
|
948
|
+
for i = 1, n do id_keys[i] = KEYS[2 + i] end
|
|
949
|
+
local values = read_bounds(id_keys)
|
|
950
|
+
if not values then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
951
|
+
local owners = owners_of(values, session_id)
|
|
952
|
+
if idx <= boundary then return { 'superseded' } end
|
|
953
|
+
if idx < 1 or idx > total then return { 'missing' } end
|
|
954
|
+
local row = read_rows({ { idx, at } }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'l', 'next', 'pins' })[1]
|
|
955
|
+
if row[2] then
|
|
956
|
+
-- The cursor moved past this head: a retry of its completion replays.
|
|
957
|
+
if not owns(row[7], tag) then return { 'superseded' } end
|
|
958
|
+
if not row[3] or tonumber(row[3]) ~= attempt then return { 'superseded' } end
|
|
959
|
+
local recorded = cjson.decode(row[4] or '[]')
|
|
960
|
+
if #recorded ~= n then return { 'partial' } end
|
|
961
|
+
for i = 1, n do
|
|
962
|
+
if recorded[i] ~= ARGV[11 + (i - 1) * 4 + 1] then return { 'partial' } end
|
|
963
|
+
end
|
|
964
|
+
local result = { 'duplicate' }
|
|
965
|
+
for i = 1, n do
|
|
966
|
+
if not owners[i] then return { 'partial' } end
|
|
967
|
+
local rec = cjson.decode(owners[i])
|
|
968
|
+
if rec[1] ~= session_id then return { 'partial' } end
|
|
969
|
+
result[#result + 1] = tostring(rec[2])
|
|
970
|
+
end
|
|
971
|
+
return result
|
|
972
|
+
end
|
|
973
|
+
if tonumber(row[1] or '0') ~= attempt or not row[5] or row[6] or not owns(row[7], tag) then
|
|
974
|
+
return { 'superseded' }
|
|
975
|
+
end
|
|
976
|
+
for i = 1, n do
|
|
977
|
+
if owners[i] then return { 'partial' } end
|
|
978
|
+
end
|
|
979
|
+
use_served(ARGV[8])
|
|
980
|
+
local batch = {}
|
|
981
|
+
for i = 1, n do
|
|
982
|
+
local o = 11 + (i - 1) * 4
|
|
983
|
+
batch[i] = { idx = total + i, type = ARGV[o + 2], targets = targets_of(ARGV[o + 4]) }
|
|
984
|
+
end
|
|
985
|
+
local moved, missing = advance(consumer, row[7], idx, row[8], batch)
|
|
986
|
+
if not moved then return { 'index', missing, tostring(idx) } end
|
|
987
|
+
local planned, unindexed, after = plan(batch, total)
|
|
988
|
+
if not planned then return { 'index', unindexed, tostring(after) } end
|
|
989
|
+
put_row(idx, at, 'p', now)
|
|
990
|
+
put_row(idx, at, 'pa', ARGV[6])
|
|
991
|
+
put_row(idx, at, 'ri', ARGV[10])
|
|
992
|
+
release_pins(idx, at, row[9])
|
|
993
|
+
local owner_writes = {}
|
|
994
|
+
for i = 1, n do
|
|
995
|
+
local o = 11 + (i - 1) * 4
|
|
996
|
+
local field = tostring(total + i)
|
|
997
|
+
redis.call('XADD', LOG, field .. '-0',
|
|
998
|
+
'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
|
|
999
|
+
'created_at', now, 'cause', ARGV[7])
|
|
1000
|
+
owner_writes[#owner_writes + 1] = KEYS[2 + i]
|
|
1001
|
+
owner_writes[#owner_writes + 1] = cjson.encode({ session_id, total + i })
|
|
1002
|
+
wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
|
|
1003
|
+
end
|
|
1004
|
+
index_batch(batch)
|
|
1005
|
+
enqueue(batch)
|
|
1006
|
+
if n > 0 then
|
|
1007
|
+
owner_writes[#owner_writes + 1] = COUNTER
|
|
1008
|
+
owner_writes[#owner_writes + 1] = tostring(total + n)
|
|
1009
|
+
if schema_missing then
|
|
1010
|
+
owner_writes[#owner_writes + 1] = SCHEMA
|
|
1011
|
+
owner_writes[#owner_writes + 1] = '${REDIS_LAYOUT}'
|
|
1012
|
+
end
|
|
1013
|
+
end
|
|
1014
|
+
if typed_dirty then
|
|
1015
|
+
owner_writes[#owner_writes + 1] = TYPED
|
|
1016
|
+
owner_writes[#owner_writes + 1] = cjson.encode(typed)
|
|
1017
|
+
end
|
|
1018
|
+
sliced('MSET', nil, owner_writes)
|
|
1019
|
+
flush_states()
|
|
1020
|
+
flush()
|
|
1021
|
+
flush_ready()
|
|
1022
|
+
if n > 0 then wake(ARGV[9], tostring(total + n), now) end
|
|
1023
|
+
return { 'ok', tostring(total + 1) }
|
|
1024
|
+
`;
|
|
1025
|
+
/**
|
|
1026
|
+
* KEYS: counter, layout.
|
|
1027
|
+
* ARGV: consumer, tag, index, attempt, error, maxFailures, nowMs, retryAtMs
|
|
1028
|
+
* or ''.
|
|
1029
|
+
*/
|
|
1030
|
+
const FAIL_ATTEMPT_LUA = `${BASE_LUA}
|
|
1031
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1032
|
+
local consumer, tag, idx, attempt = ARGV[1], ARGV[2], tonumber(ARGV[3]), tonumber(ARGV[4])
|
|
1033
|
+
local at = row_of(consumer, tag)
|
|
1034
|
+
if idx <= boundary then return { 'superseded', '0' } end
|
|
1035
|
+
if idx < 1 or idx > total then return { 'missing', '0' } end
|
|
1036
|
+
local row = read_rows({ { idx, at } }, { 'a', 'd', 'p', 'f', 'lfa', 'ch', 'l', 'pins' })[1]
|
|
1037
|
+
if row[3] or (row[7] and not owns(row[7], tag)) then return { 'superseded', '0' } end
|
|
1038
|
+
local failures = tonumber(row[1] or '0')
|
|
1039
|
+
if tonumber(row[2] or '0') ~= attempt then return { 'superseded', tostring(failures) } end
|
|
1040
|
+
if not row[6] and row[5] and tonumber(row[5]) == attempt then
|
|
1041
|
+
return { row[4] and 'dead_lettered' or 'failed', tostring(failures) }
|
|
1042
|
+
end
|
|
1043
|
+
if row[4] then return { 'dead_lettered', tostring(failures) } end
|
|
1044
|
+
if not row[6] then return { 'superseded', tostring(failures) } end
|
|
1045
|
+
failures = failures + 1
|
|
1046
|
+
local dead = failures >= tonumber(ARGV[6])
|
|
1047
|
+
put_row(idx, at, 'a', tostring(failures))
|
|
1048
|
+
put_row(idx, at, 'e', ARGV[5])
|
|
1049
|
+
put_row(idx, at, 'lf', ARGV[7])
|
|
1050
|
+
put_row(idx, at, 'lfa', ARGV[4])
|
|
1051
|
+
drop_row(idx, at, 'ch')
|
|
1052
|
+
if dead then
|
|
1053
|
+
put_row(idx, at, 'f', ARGV[7])
|
|
1054
|
+
drop_row(idx, at, 'ce')
|
|
1055
|
+
ready_set(cid_for(consumer, row[7], idx), -idx)
|
|
1056
|
+
release_pins(idx, at, row[8])
|
|
1057
|
+
elseif ARGV[8] ~= '' then
|
|
1058
|
+
put_row(idx, at, 'ce', ARGV[8])
|
|
1059
|
+
else
|
|
1060
|
+
drop_row(idx, at, 'ce')
|
|
1061
|
+
end
|
|
1062
|
+
flush()
|
|
1063
|
+
flush_ready()
|
|
1064
|
+
return { dead and 'dead_lettered' or 'failed', tostring(failures) }
|
|
1065
|
+
`;
|
|
1066
|
+
/**
|
|
1067
|
+
* KEYS: counter, layout. ARGV: consumer, index, action, nowMs, served
|
|
1068
|
+
* consumers. Returns [redriven, hasPending] | ['index', type, after]. Acts
|
|
1069
|
+
* only on the consumer's dead-lettered head at the index, above the clear
|
|
1070
|
+
* marker.
|
|
1071
|
+
*/
|
|
1072
|
+
const REDRIVE_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
1073
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1074
|
+
use_served(ARGV[5])
|
|
1075
|
+
local consumer, idx = ARGV[1], tonumber(ARGV[2])
|
|
1076
|
+
local function result(redriven) return { redriven, has_pending() } end
|
|
1077
|
+
if idx <= boundary or idx < 1 or idx > total then return result('0') end
|
|
1078
|
+
local cid, tag = nil, nil
|
|
1079
|
+
for _, member in ipairs(redis.call('ZRANGEBYSCORE', READY, -idx, -idx)) do
|
|
1080
|
+
local owner, member_tag = parse_cid(member)
|
|
1081
|
+
if owner == consumer then
|
|
1082
|
+
cid, tag = member, member_tag
|
|
1083
|
+
break
|
|
1084
|
+
end
|
|
1085
|
+
end
|
|
1086
|
+
if not cid then return result('0') end
|
|
1087
|
+
local kind = string.sub(tag, 1, 1) == '#' and '#' or tag
|
|
1088
|
+
local at = row_of(consumer, tag)
|
|
1089
|
+
if ARGV[3] == 'skip' then
|
|
1090
|
+
local row = read_rows({ { idx, at } }, { 'next' })[1]
|
|
1091
|
+
local moved, missing = advance(consumer, kind, idx, row[1], {})
|
|
1092
|
+
if not moved then return { 'index', missing, tostring(idx) } end
|
|
1093
|
+
put_row(idx, at, 'p', ARGV[4])
|
|
1094
|
+
flush_states()
|
|
1095
|
+
if typed_dirty then redis.call('SET', TYPED, cjson.encode(typed)) end
|
|
1096
|
+
else
|
|
1097
|
+
drop_row(idx, at, 'f')
|
|
1098
|
+
drop_row(idx, at, 'a')
|
|
1099
|
+
ready_set(cid, idx)
|
|
1100
|
+
end
|
|
1101
|
+
flush()
|
|
1102
|
+
flush_ready()
|
|
1103
|
+
return result('1')
|
|
1104
|
+
`;
|
|
1105
|
+
/**
|
|
1106
|
+
* KEYS: counter, layout. ARGV: consumer, lane, types JSON or '*', backfill
|
|
1107
|
+
* ('1' or '0'). Brings one log cursor up to date without an append, as
|
|
1108
|
+
* `plan` and `enqueue` would for an append carrying its types: a missing
|
|
1109
|
+
* cursor starts at the clear marker with backfill, else at the head with
|
|
1110
|
+
* nothing to run; an idle one takes the next stored event of its types; one
|
|
1111
|
+
* with a head, claimed or not, keeps it. Records the types, and writes
|
|
1112
|
+
* nothing when nothing changed. Returns ['ok', position, head or nil, head
|
|
1113
|
+
* columns, session head] | ['index', type, after].
|
|
1114
|
+
*/
|
|
1115
|
+
const WAKE_LOG_LUA = `${BASE_LUA}${CURSOR_LUA}
|
|
1116
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1117
|
+
local consumer, lane, ty = ARGV[1], ARGV[2], ARGV[3]
|
|
1118
|
+
local types = types_of(ty)
|
|
1119
|
+
local cid = cid_of(consumer, 'G' .. lane)
|
|
1120
|
+
load_states({ cid })
|
|
1121
|
+
local state = states[cid]
|
|
1122
|
+
local had_head = state.h
|
|
1123
|
+
-- Nothing after the latest clear: a missing cursor stays missing.
|
|
1124
|
+
if not state.exists and total == boundary then
|
|
1125
|
+
return { 'ok', tostring(boundary), false, {}, tostring(total) }
|
|
1126
|
+
end
|
|
1127
|
+
if not state.exists then
|
|
1128
|
+
state.exists, state.p = true, total
|
|
1129
|
+
if ARGV[4] == '1' then
|
|
1130
|
+
local head, missing = stored_head(boundary, types)
|
|
1131
|
+
if head == nil then return { 'index', missing, tostring(boundary) } end
|
|
1132
|
+
state.p, state.h = boundary, head
|
|
1133
|
+
else
|
|
1134
|
+
-- Nothing it takes is stored after its start: index its types from there.
|
|
1135
|
+
for name in pairs(types or {}) do
|
|
1136
|
+
if typed[name] == nil then register(name, total) end
|
|
1137
|
+
end
|
|
1138
|
+
end
|
|
1139
|
+
state.dirty = true
|
|
1140
|
+
elseif not state.h then
|
|
1141
|
+
-- A deployment that doesn't serve this consumer may have appended events
|
|
1142
|
+
-- it takes since it went idle.
|
|
1143
|
+
local head, missing = stored_head(state.p, types)
|
|
1144
|
+
if head == nil then return { 'index', missing, tostring(state.p) } end
|
|
1145
|
+
if head then state.h, state.dirty = head, true end
|
|
1146
|
+
end
|
|
1147
|
+
if state.ty ~= ty then state.ty, state.dirty = ty, true end
|
|
1148
|
+
local columns = {}
|
|
1149
|
+
if had_head then
|
|
1150
|
+
columns = read_rows({ { state.h, row_of(consumer, 'G' .. lane) } }, ${HEAD_COLUMNS})[1]
|
|
1151
|
+
elseif state.h then
|
|
1152
|
+
ready_set(cid, state.h)
|
|
1153
|
+
end
|
|
1154
|
+
flush_states()
|
|
1155
|
+
if typed_dirty then redis.call('SET', TYPED, cjson.encode(typed)) end
|
|
1156
|
+
flush()
|
|
1157
|
+
flush_ready()
|
|
1158
|
+
return { 'ok', tostring(state.p), state.h and tostring(state.h) or false, columns, tostring(total) }
|
|
1159
|
+
`;
|
|
1160
|
+
/**
|
|
1161
|
+
* KEYS: counter, layout. Returns the dead-lettered heads above the clear
|
|
1162
|
+
* marker as [consumer, tag, index, columns, entry] tuples.
|
|
1163
|
+
*/
|
|
1164
|
+
const DEAD_LETTERS_LUA = `${BASE_LUA}
|
|
1165
|
+
local dead = redis.call('ZRANGEBYSCORE', READY, '-inf', '(0', 'WITHSCORES')
|
|
1166
|
+
if #dead == 0 then return {} end
|
|
1167
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1168
|
+
local refs, items = {}, {}
|
|
1169
|
+
for i = 1, #dead, 2 do
|
|
1170
|
+
local consumer, tag = parse_cid(dead[i])
|
|
1171
|
+
local idx = -tonumber(dead[i + 1])
|
|
1172
|
+
if idx > boundary then
|
|
1173
|
+
refs[#refs + 1] = { idx, row_of(consumer, tag) }
|
|
1174
|
+
items[#items + 1] = { consumer, tag, idx }
|
|
1175
|
+
end
|
|
1176
|
+
end
|
|
1177
|
+
local rows = read_rows(refs, ${HEAD_COLUMNS})
|
|
1178
|
+
local result = {}
|
|
1179
|
+
for k, item in ipairs(items) do
|
|
1180
|
+
local entries = redis.call('XRANGE', LOG, tostring(item[3]) .. '-0', tostring(item[3]) .. '-0')
|
|
1181
|
+
if #entries > 0 then
|
|
1182
|
+
result[#result + 1] = { item[1], item[2], tostring(item[3]), rows[k], entries[1] }
|
|
1183
|
+
end
|
|
1184
|
+
end
|
|
1185
|
+
return result
|
|
1186
|
+
`;
|
|
1187
|
+
/**
|
|
1188
|
+
* KEYS: counter, layout. Returns every listed cursor as [consumer, tag,
|
|
1189
|
+
* position, head or nil, columns]: lane and log cursors from the cursor
|
|
1190
|
+
* hash, read against the clear marker, then the event cursors with a head.
|
|
1191
|
+
*/
|
|
1192
|
+
const CURSORS_LUA = `${BASE_LUA}
|
|
1193
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1194
|
+
local listed = {}
|
|
1195
|
+
local fields = redis.call('HGETALL', CURSORS)
|
|
1196
|
+
local records, order = {}, {}
|
|
1197
|
+
for i = 1, #fields, 2 do
|
|
1198
|
+
local at = string.find(fields[i], ':[^:]*$')
|
|
1199
|
+
local cid, suffix = string.sub(fields[i], 1, at - 1), string.sub(fields[i], at + 1)
|
|
1200
|
+
if not records[cid] then
|
|
1201
|
+
records[cid] = {}
|
|
1202
|
+
order[#order + 1] = cid
|
|
1203
|
+
end
|
|
1204
|
+
records[cid][suffix] = fields[i + 1]
|
|
1205
|
+
end
|
|
1206
|
+
for _, cid in ipairs(order) do
|
|
1207
|
+
local record = records[cid]
|
|
1208
|
+
local consumer, tag = parse_cid(cid)
|
|
1209
|
+
local head = (record.h and record.h ~= '') and tonumber(record.h) or false
|
|
1210
|
+
local position = tonumber(record.p or '0')
|
|
1211
|
+
if head and head <= boundary then head = false end
|
|
1212
|
+
local kind = string.sub(tag, 1, 1)
|
|
1213
|
+
if kind == 'G' and position < boundary then position = boundary end
|
|
1214
|
+
if kind == 'G' or head or position > boundary then
|
|
1215
|
+
listed[#listed + 1] = { consumer, tag, position, head }
|
|
1216
|
+
end
|
|
1217
|
+
end
|
|
1218
|
+
local ready = redis.call('ZRANGE', READY, 0, -1, 'WITHSCORES')
|
|
1219
|
+
for i = 1, #ready, 2 do
|
|
1220
|
+
local consumer, tag = parse_cid(ready[i])
|
|
1221
|
+
if string.sub(tag, 1, 1) == '#' then
|
|
1222
|
+
local head = math.abs(tonumber(ready[i + 1]))
|
|
1223
|
+
listed[#listed + 1] = { consumer, '#', head - 1, head }
|
|
1224
|
+
end
|
|
1225
|
+
end
|
|
1226
|
+
local refs, positions = {}, {}
|
|
1227
|
+
for position, cursor in ipairs(listed) do
|
|
1228
|
+
if cursor[4] then
|
|
1229
|
+
refs[#refs + 1] = { cursor[4], row_of(cursor[1], cursor[2]) }
|
|
1230
|
+
positions[#refs] = position
|
|
1231
|
+
end
|
|
1232
|
+
end
|
|
1233
|
+
local rows = read_rows(refs, ${HEAD_COLUMNS})
|
|
1234
|
+
local columns = {}
|
|
1235
|
+
for k, position in ipairs(positions) do columns[position] = rows[k] end
|
|
1236
|
+
local result = {}
|
|
1237
|
+
for position, cursor in ipairs(listed) do
|
|
1238
|
+
result[#result + 1] = {
|
|
1239
|
+
cursor[1], cursor[2], tostring(cursor[3]),
|
|
1240
|
+
cursor[4] and tostring(cursor[4]) or false, columns[position] or {},
|
|
1241
|
+
}
|
|
1242
|
+
end
|
|
1243
|
+
return result
|
|
1244
|
+
`;
|
|
1245
|
+
/**
|
|
1246
|
+
* KEYS: counter, layout, head, history.
|
|
1247
|
+
* ARGV: write count, then per write: index, snapshot JSON, pin count, then
|
|
1248
|
+
* per pin: consumer, tag, index; finally the reducer name. Writes from
|
|
1249
|
+
* before the latest clear are dropped, and a head from before it is
|
|
1250
|
+
* replaced as if absent. A pin holds only on an unfinished, live head: a
|
|
1251
|
+
* claimed row says so itself, an unclaimed head by its cursor's ready score.
|
|
1252
|
+
*/
|
|
1253
|
+
const PUT_SNAPSHOTS_LUA = `${BASE_LUA}
|
|
1254
|
+
if not read_bounds() then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1255
|
+
local HEAD, HISTORY = KEYS[3], KEYS[4]
|
|
1256
|
+
local entries, refs, pins = {}, {}, {}
|
|
1257
|
+
local offset = 2
|
|
1258
|
+
for w = 1, tonumber(ARGV[1]) do
|
|
1259
|
+
local index = tonumber(ARGV[offset])
|
|
1260
|
+
local pin_count = tonumber(ARGV[offset + 2])
|
|
1261
|
+
local entry = { index = index, snapshot = ARGV[offset + 1], pins = {} }
|
|
1262
|
+
for p = 1, pin_count do
|
|
1263
|
+
local o = offset + 2 + (p - 1) * 3
|
|
1264
|
+
local pin = { consumer = ARGV[o + 1], tag = ARGV[o + 2], idx = tonumber(ARGV[o + 3]) }
|
|
1265
|
+
if index >= boundary and pin.idx > boundary and pin.idx <= total then
|
|
1266
|
+
pin.row = row_of(pin.consumer, pin.tag)
|
|
1267
|
+
refs[#refs + 1] = { pin.idx, pin.row }
|
|
1268
|
+
pins[#pins + 1] = pin
|
|
1269
|
+
entry.pins[#entry.pins + 1] = pin
|
|
1270
|
+
end
|
|
1271
|
+
end
|
|
1272
|
+
entries[w] = entry
|
|
1273
|
+
offset = offset + 3 + pin_count * 3
|
|
1274
|
+
end
|
|
1275
|
+
local function live(consumer, tag, idx)
|
|
1276
|
+
return tonumber(redis.call('ZSCORE', READY, cid_of(consumer, tag)) or '0') == idx
|
|
1277
|
+
end
|
|
1278
|
+
local rows = read_rows(refs, { 'pins', 'p', 'f', 'l' })
|
|
1279
|
+
local held, held_order = {}, {}
|
|
1280
|
+
for k, pin in ipairs(pins) do
|
|
1281
|
+
local row = rows[k]
|
|
1282
|
+
local valid
|
|
1283
|
+
if row[2] or row[3] then
|
|
1284
|
+
valid = false
|
|
1285
|
+
elseif row[4] then
|
|
1286
|
+
valid = owns(row[4], pin.tag)
|
|
1287
|
+
elseif pin.tag == '#' then
|
|
1288
|
+
valid = live(pin.consumer, '#' .. tostring(pin.idx), pin.idx)
|
|
1289
|
+
else
|
|
1290
|
+
valid = live(pin.consumer, pin.tag, pin.idx) or
|
|
1291
|
+
live(pin.consumer, 'G' .. string.sub(pin.tag, 2), pin.idx)
|
|
1292
|
+
end
|
|
1293
|
+
if valid then
|
|
1294
|
+
local key = tostring(pin.idx) .. ':' .. pin.row
|
|
1295
|
+
if not held[key] then
|
|
1296
|
+
local list = (row[1] and row[1] ~= '') and cjson.decode(row[1]) or {}
|
|
1297
|
+
local set = {}
|
|
1298
|
+
for _, ref in ipairs(list) do set[ref] = true end
|
|
1299
|
+
held[key] = { list = list, set = set, idx = pin.idx, row = pin.row }
|
|
1300
|
+
held_order[#held_order + 1] = key
|
|
1301
|
+
end
|
|
1302
|
+
pin.held = held[key]
|
|
1303
|
+
end
|
|
1304
|
+
end
|
|
1305
|
+
local snapshots = {}
|
|
1306
|
+
for _, entry in ipairs(entries) do
|
|
1307
|
+
local ref = cjson.encode({ HISTORY, tostring(entry.index) })
|
|
1308
|
+
for _, pin in ipairs(entry.pins) do
|
|
1309
|
+
if pin.held and not pin.held.set[ref] then
|
|
1310
|
+
pin.held.set[ref] = true
|
|
1311
|
+
pin.held.list[#pin.held.list + 1] = ref
|
|
1312
|
+
pin.held.dirty = true
|
|
1313
|
+
redis.call('HINCRBY', PIN_COUNTS, ref, 1)
|
|
1314
|
+
end
|
|
1315
|
+
end
|
|
1316
|
+
if entry.index >= boundary then
|
|
1317
|
+
snapshots[#snapshots + 1] = { index = entry.index, snapshot = entry.snapshot, ref = ref }
|
|
1318
|
+
end
|
|
1319
|
+
end
|
|
1320
|
+
for _, key in ipairs(held_order) do
|
|
1321
|
+
local row = held[key]
|
|
1322
|
+
if row.dirty then put_row(row.idx, row.row, 'pins', cjson.encode(row.list)) end
|
|
1323
|
+
end
|
|
1324
|
+
flush()
|
|
1325
|
+
if #snapshots == 0 then return 1 end
|
|
1326
|
+
|
|
1327
|
+
local head = redis.call('GET', HEAD)
|
|
1328
|
+
local head_index = nil
|
|
1329
|
+
local registered = false
|
|
1330
|
+
if head then
|
|
1331
|
+
local record = cjson.decode(head)
|
|
1332
|
+
head_index = tonumber(record['index'])
|
|
1333
|
+
registered = record['r'] == 1
|
|
1334
|
+
end
|
|
1335
|
+
if head_index and head_index < boundary then
|
|
1336
|
+
head, head_index, registered = nil, nil, false
|
|
1337
|
+
redis.call('ZREMRANGEBYSCORE', HISTORY, '-inf', '(' .. boundary)
|
|
1338
|
+
end
|
|
1339
|
+
-- Deletion finds each reducer's keys through this set instead of a scan.
|
|
1340
|
+
-- Heads written since carry the mark, so a reducer registers once.
|
|
1341
|
+
if not registered then
|
|
1342
|
+
redis.call('SADD', SESSION .. 'snap-reducers', ARGV[#ARGV])
|
|
1343
|
+
end
|
|
1344
|
+
local latest = snapshots[#snapshots]
|
|
1345
|
+
local writes_head = not head_index or latest.index >= head_index
|
|
1346
|
+
local advances = not head_index or latest.index > head_index
|
|
1347
|
+
local final_head_index = advances and latest.index or head_index
|
|
1348
|
+
|
|
1349
|
+
if advances and head and tonumber(redis.call('HGET', PIN_COUNTS, cjson.encode({ HISTORY, tostring(head_index) })) or '0') > 0 then
|
|
1350
|
+
redis.call('ZREMRANGEBYSCORE', HISTORY, head_index, head_index)
|
|
1351
|
+
redis.call('ZADD', HISTORY, head_index, head)
|
|
1352
|
+
end
|
|
1353
|
+
|
|
1354
|
+
for _, write in ipairs(snapshots) do
|
|
1355
|
+
if write.index < final_head_index then
|
|
1356
|
+
redis.call('ZREMRANGEBYSCORE', HISTORY, write.index, write.index)
|
|
1357
|
+
if tonumber(redis.call('HGET', PIN_COUNTS, write.ref) or '0') > 0 then
|
|
1358
|
+
redis.call('ZADD', HISTORY, write.index, write.snapshot)
|
|
1359
|
+
end
|
|
1360
|
+
end
|
|
1361
|
+
end
|
|
1362
|
+
|
|
1363
|
+
if writes_head then
|
|
1364
|
+
redis.call('SET', HEAD, latest.snapshot)
|
|
1365
|
+
redis.call('ZREMRANGEBYSCORE', HISTORY, latest.index, latest.index)
|
|
1366
|
+
end
|
|
1367
|
+
return 1
|
|
1368
|
+
`;
|
|
1369
|
+
/**
|
|
1370
|
+
* KEYS: presence hash. ARGV: atMs, ttlMs, n, then per entry:
|
|
1371
|
+
* hashField, valueJson ('' deletes), patchFieldJson; finally channel and
|
|
1372
|
+
* message prefix. Wire fragments stay JSON text to preserve arrays
|
|
1373
|
+
* and numeric precision through Lua. Field-wise LWW by the sender's
|
|
1374
|
+
* `at`: a stored entry with a strictly newer stamp survives; ties go
|
|
1375
|
+
* to the incoming write (memory-tier parity). Returns the applied
|
|
1376
|
+
* 1-based entry positions. The whole-key PEXPIRE is the coarse bound,
|
|
1377
|
+
* sound because core sends one uniform ttlMs; the per-entry `e` stamp
|
|
1378
|
+
* is the fine bound (the injected storage clock at the applied set
|
|
1379
|
+
* plus ttlMs — never the sender stamp), pruned on read against the
|
|
1380
|
+
* same clock.
|
|
1381
|
+
*/
|
|
1382
|
+
const PRESENCE_SET_LUA = `
|
|
1383
|
+
local at = tonumber(ARGV[1])
|
|
1384
|
+
local applied = {}
|
|
1385
|
+
local published = {}
|
|
1386
|
+
local n = tonumber(ARGV[3])
|
|
1387
|
+
for i = 1, n do
|
|
1388
|
+
local field = ARGV[3 + (i - 1) * 3 + 1]
|
|
1389
|
+
local value = ARGV[3 + (i - 1) * 3 + 2]
|
|
1390
|
+
local existing = redis.call('HGET', KEYS[1], field)
|
|
1391
|
+
if not existing or tonumber(cjson.decode(existing)['a']) <= at then
|
|
1392
|
+
if value == '' then
|
|
1393
|
+
redis.call('HDEL', KEYS[1], field)
|
|
1394
|
+
else
|
|
1395
|
+
redis.call('HSET', KEYS[1], field, value)
|
|
1396
|
+
end
|
|
1397
|
+
applied[#applied + 1] = tostring(i)
|
|
1398
|
+
published[#published + 1] = ARGV[3 + (i - 1) * 3 + 3]
|
|
1399
|
+
end
|
|
1400
|
+
end
|
|
1401
|
+
if #applied > 0 then
|
|
1402
|
+
redis.call('PEXPIRE', KEYS[1], ARGV[2])
|
|
1403
|
+
redis.pcall('PUBLISH', ARGV[4 + n * 3], ARGV[5 + n * 3] .. table.concat(published, ',') .. '}}')
|
|
1404
|
+
end
|
|
1405
|
+
return applied
|
|
1406
|
+
`;
|
|
1407
|
+
/**
|
|
1408
|
+
* KEYS: presence hash. ARGV: nowMs (the injected clock, so tests can
|
|
1409
|
+
* time-travel expiry against a real server). Returns the live
|
|
1410
|
+
* [field, value, ...] pairs; entries at or past their fine-bound
|
|
1411
|
+
* expiry are HDELed in the same atomic step, so a stale reader can
|
|
1412
|
+
* never sweep a concurrently refreshed field.
|
|
1413
|
+
*/
|
|
1414
|
+
const PRESENCE_READ_LUA = `
|
|
1415
|
+
local now = tonumber(ARGV[1])
|
|
1416
|
+
local rows = redis.call('HGETALL', KEYS[1])
|
|
1417
|
+
local live = {}
|
|
1418
|
+
for i = 1, #rows, 2 do
|
|
1419
|
+
if tonumber(cjson.decode(rows[i + 1])['e']) <= now then
|
|
1420
|
+
redis.call('HDEL', KEYS[1], rows[i])
|
|
1421
|
+
else
|
|
1422
|
+
live[#live + 1] = rows[i]
|
|
1423
|
+
live[#live + 1] = rows[i + 1]
|
|
1424
|
+
end
|
|
1425
|
+
end
|
|
1426
|
+
return live
|
|
1427
|
+
`;
|
|
1428
|
+
/**
|
|
1429
|
+
* KEYS: counter, layout, head, history. ARGV: snapshot frontier or ''.
|
|
1430
|
+
* Returns one atomic multiversion-cache-plus-log-frontier read, with the
|
|
1431
|
+
* clear boundary; checkpoints from before it are ignored.
|
|
1432
|
+
*/
|
|
1433
|
+
const READ_STATE_LUA = `
|
|
1434
|
+
local session = string.sub(KEYS[1], 1, -6)
|
|
1435
|
+
local counts = redis.call('MGET', KEYS[1], session .. 'clear', KEYS[2])
|
|
1436
|
+
if counts[3] and counts[3] ~= '${REDIS_LAYOUT}' then
|
|
1437
|
+
return redis.error_reply('${LAYOUT_ERROR}')
|
|
1438
|
+
end
|
|
1439
|
+
local boundary = tonumber(counts[2] or '0')
|
|
1440
|
+
local head = redis.call('GET', KEYS[3])
|
|
1441
|
+
local head_index = head and cjson.decode(head)['index'] or nil
|
|
1442
|
+
if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
|
|
1443
|
+
return redis.error_reply('invalid snapshot head index')
|
|
1444
|
+
end
|
|
1445
|
+
if head_index and head_index < boundary then
|
|
1446
|
+
head = nil
|
|
1447
|
+
head_index = nil
|
|
1448
|
+
end
|
|
1449
|
+
local cap = ARGV[1]
|
|
1450
|
+
local snapshot = nil
|
|
1451
|
+
if head_index and (cap == '' or head_index <= tonumber(cap)) then
|
|
1452
|
+
snapshot = head
|
|
1453
|
+
else
|
|
1454
|
+
local upper = cap == '' and '+inf' or cap
|
|
1455
|
+
local historical = redis.call('ZREVRANGEBYSCORE', KEYS[4], upper, boundary, 'LIMIT', 0, 1)
|
|
1456
|
+
if #historical > 0 then snapshot = historical[1] end
|
|
1457
|
+
end
|
|
1458
|
+
return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
|
|
1459
|
+
`;
|
|
1460
|
+
/**
|
|
1461
|
+
* What reclaim removes once a session has no moved log left: every
|
|
1462
|
+
* registered reducer's checkpoints below the latest clear and the pin counts
|
|
1463
|
+
* naming them (snapshot writes register each reducer in
|
|
1464
|
+
* {prefix}:{session}:snap-reducers, so no key scan is needed), every head
|
|
1465
|
+
* row below it (whole chunks, then the boundary chunk's older rows), the
|
|
1466
|
+
* type indexes' older entries, and the lane cursors it left idle.
|
|
1467
|
+
*/
|
|
1468
|
+
const SWEEP_LUA = `
|
|
1469
|
+
local function sweep(boundary)
|
|
1470
|
+
for _, reducer in ipairs(redis.call('SMEMBERS', SESSION .. 'snap-reducers')) do
|
|
1471
|
+
local head_key = SESSION .. 'snap:' .. reducer
|
|
1472
|
+
local head = redis.call('GET', head_key)
|
|
1473
|
+
if head and tonumber(cjson.decode(head)['index']) < boundary then
|
|
1474
|
+
redis.call('DEL', head_key)
|
|
1475
|
+
end
|
|
1476
|
+
redis.call('ZREMRANGEBYSCORE', SESSION .. 'snap-history:' .. reducer, '-inf', '(' .. boundary)
|
|
1477
|
+
end
|
|
1478
|
+
local counts = redis.call('HGETALL', PIN_COUNTS)
|
|
1479
|
+
local stale = {}
|
|
1480
|
+
for i = 1, #counts, 2 do
|
|
1481
|
+
if tonumber(cjson.decode(counts[i])[2]) < boundary then stale[#stale + 1] = counts[i] end
|
|
1482
|
+
end
|
|
1483
|
+
sliced('HDEL', PIN_COUNTS, stale)
|
|
1484
|
+
local raw = redis.call('GET', TYPED)
|
|
1485
|
+
if raw then
|
|
1486
|
+
for name in pairs(cjson.decode(raw)) do
|
|
1487
|
+
redis.call('ZREMRANGEBYSCORE', type_key(name), '-inf', '(' .. boundary)
|
|
1488
|
+
end
|
|
1489
|
+
end
|
|
1490
|
+
local last = math.floor((boundary - 1) / ${META_CHUNK_SIZE})
|
|
1491
|
+
local chunks = {}
|
|
1492
|
+
for chunk = 0, last - 1 do chunks[#chunks + 1] = META .. ':' .. tostring(chunk) end
|
|
1493
|
+
sliced('UNLINK', nil, chunks)
|
|
1494
|
+
local older = {}
|
|
1495
|
+
for _, field in ipairs(redis.call('HKEYS', META .. ':' .. tostring(last))) do
|
|
1496
|
+
local idx = tonumber(string.match(field, '^(%d+):'))
|
|
1497
|
+
if idx and idx < boundary then older[#older + 1] = field end
|
|
1498
|
+
end
|
|
1499
|
+
sliced('HDEL', META .. ':' .. tostring(last), older)
|
|
1500
|
+
local fields = redis.call('HGETALL', CURSORS)
|
|
1501
|
+
local records, order = {}, {}
|
|
1502
|
+
for i = 1, #fields, 2 do
|
|
1503
|
+
local at = string.find(fields[i], ':[^:]*$')
|
|
1504
|
+
local cid, suffix = string.sub(fields[i], 1, at - 1), string.sub(fields[i], at + 1)
|
|
1505
|
+
if not records[cid] then
|
|
1506
|
+
records[cid] = {}
|
|
1507
|
+
order[#order + 1] = cid
|
|
1508
|
+
end
|
|
1509
|
+
records[cid][suffix] = fields[i + 1]
|
|
1510
|
+
end
|
|
1511
|
+
local idle, moved = {}, {}
|
|
1512
|
+
for _, cid in ipairs(order) do
|
|
1513
|
+
local record = records[cid]
|
|
1514
|
+
local _, tag = parse_cid(cid)
|
|
1515
|
+
local head = (record.h and record.h ~= '') and tonumber(record.h) or false
|
|
1516
|
+
local position = tonumber(record.p or '0')
|
|
1517
|
+
if head and head <= boundary then head = false end
|
|
1518
|
+
if string.sub(tag, 1, 1) == 'G' then
|
|
1519
|
+
if position < boundary then position = boundary end
|
|
1520
|
+
moved[#moved + 1] = cid .. ':h'
|
|
1521
|
+
moved[#moved + 1] = head and tostring(head) or ''
|
|
1522
|
+
moved[#moved + 1] = cid .. ':p'
|
|
1523
|
+
moved[#moved + 1] = tostring(position)
|
|
1524
|
+
elseif not head and position <= boundary then
|
|
1525
|
+
for suffix in pairs(record) do idle[#idle + 1] = cid .. ':' .. suffix end
|
|
1526
|
+
elseif not head and record.h ~= '' then
|
|
1527
|
+
moved[#moved + 1] = cid .. ':h'
|
|
1528
|
+
moved[#moved + 1] = ''
|
|
1529
|
+
end
|
|
1530
|
+
end
|
|
1531
|
+
sliced('HDEL', CURSORS, idle)
|
|
1532
|
+
sliced('HSET', CURSORS, moved)
|
|
1533
|
+
end
|
|
1534
|
+
`;
|
|
1535
|
+
/**
|
|
1536
|
+
* KEYS: counter, layout, marker id ownership key.
|
|
1537
|
+
* ARGV: sessionId, nowMs, ifLastIndex or '', cause consumer or '', cause tag,
|
|
1538
|
+
* cause index, cause attempt, marker id, notify channel.
|
|
1539
|
+
* Returns ['ok', cleared, head, boundary] | ['superseded'] | ['foreign'].
|
|
1540
|
+
* The old lifetime's log moves aside in one RENAME, so every read of the log
|
|
1541
|
+
* starts at the marker, and its ready set is unlinked, so claims and pending
|
|
1542
|
+
* checks see only the new lifetime; deletion drains the moved log. Type
|
|
1543
|
+
* indexes keep their older entries until then: lookups start above the
|
|
1544
|
+
* marker.
|
|
1545
|
+
*/
|
|
1546
|
+
const CLEAR_LUA = `${BASE_LUA}${WAKE_LUA}
|
|
1547
|
+
local values = read_bounds({ KEYS[3] })
|
|
1548
|
+
if not values then return redis.error_reply('${LAYOUT_ERROR}') end
|
|
1549
|
+
local head = total
|
|
1550
|
+
if ARGV[4] ~= '' then
|
|
1551
|
+
local idx = tonumber(ARGV[6])
|
|
1552
|
+
if idx <= boundary then return { 'superseded' } end
|
|
1553
|
+
local row = read_rows({ { idx, row_of(ARGV[4], ARGV[5]) } }, { 'd', 'f', 'p', 'l' })[1]
|
|
1554
|
+
if tonumber(row[1] or '0') ~= tonumber(ARGV[7]) or row[2] or row[3] or not owns(row[4], ARGV[5]) then
|
|
1555
|
+
return { 'superseded' }
|
|
1556
|
+
end
|
|
1557
|
+
end
|
|
1558
|
+
local expected = ARGV[3]
|
|
1559
|
+
if expected ~= '' and boundary == tonumber(expected) + 1 then
|
|
1560
|
+
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
1561
|
+
end
|
|
1562
|
+
if expected ~= '' and tonumber(expected) ~= head then
|
|
1563
|
+
return { 'ok', '0', tostring(head), tostring(boundary) }
|
|
1564
|
+
end
|
|
1565
|
+
if head == 0 or boundary == head then
|
|
1566
|
+
return { 'ok', '1', tostring(head), tostring(boundary) }
|
|
1567
|
+
end
|
|
1568
|
+
if values[1] then return { 'foreign' } end
|
|
1569
|
+
local field = tostring(head + 1)
|
|
1570
|
+
local moved = SESSION .. 'cleared:' .. field
|
|
1571
|
+
redis.call('RENAME', LOG, moved)
|
|
1572
|
+
redis.call('UNLINK', READY)
|
|
1573
|
+
redis.call('SADD', SESSION .. 'cleared', moved)
|
|
1574
|
+
redis.call('XADD', LOG, field .. '-0',
|
|
1575
|
+
'id', ARGV[8], 'type', CLEAR_TYPE, 'payload', 'null',
|
|
1576
|
+
'created_at', ARGV[2], 'cause', '')
|
|
1577
|
+
local marker = { KEYS[3], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
|
|
1578
|
+
if schema_missing then
|
|
1579
|
+
marker[#marker + 1] = SCHEMA
|
|
1580
|
+
marker[#marker + 1] = '${REDIS_LAYOUT}'
|
|
1581
|
+
end
|
|
1582
|
+
redis.call('MSET', unpack(marker))
|
|
1583
|
+
wake_add(field, ARGV[8], CLEAR_TYPE, 'null')
|
|
1584
|
+
wake(ARGV[9], field, ARGV[2])
|
|
1585
|
+
return { 'ok', '1', field, field }
|
|
1586
|
+
`;
|
|
1587
|
+
/**
|
|
1588
|
+
* KEYS: counter, layout. ARGV: page size.
|
|
1589
|
+
* The next deletion batch: a moved log and up to a page of its [index, id]
|
|
1590
|
+
* pairs, without payloads. Empty moved logs are dropped; with none left it
|
|
1591
|
+
* sweeps what the old lifetime left and returns nothing, so a retry
|
|
1592
|
+
* finishes an interrupted deletion.
|
|
1593
|
+
*/
|
|
1594
|
+
const RECLAIM_BATCH_LUA = `${BASE_LUA}${SWEEP_LUA}
|
|
1595
|
+
local boundary = tonumber(redis.call('GET', CLEAR) or '0')
|
|
1596
|
+
if boundary == 0 then return {} end
|
|
1597
|
+
for _, log in ipairs(redis.call('SMEMBERS', SESSION .. 'cleared')) do
|
|
1598
|
+
local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
|
|
1599
|
+
if #entries > 0 then
|
|
1600
|
+
local batch = { log }
|
|
1601
|
+
for _, entry in ipairs(entries) do
|
|
1602
|
+
local fields = entry[2]
|
|
1603
|
+
for i = 1, #fields, 2 do
|
|
1604
|
+
if fields[i] == 'id' then
|
|
1605
|
+
batch[#batch + 1] = string.match(entry[1], '^(%d+)')
|
|
1606
|
+
batch[#batch + 1] = fields[i + 1]
|
|
1607
|
+
break
|
|
1608
|
+
end
|
|
1609
|
+
end
|
|
1610
|
+
end
|
|
1611
|
+
return batch
|
|
1612
|
+
end
|
|
1613
|
+
redis.call('DEL', log)
|
|
1614
|
+
redis.call('SREM', SESSION .. 'cleared', log)
|
|
1615
|
+
end
|
|
1616
|
+
sweep(boundary)
|
|
1617
|
+
return {}
|
|
1618
|
+
`;
|
|
1619
|
+
/**
|
|
1620
|
+
* KEYS: counter, layout, moved log, then one id ownership key per entry.
|
|
1621
|
+
* ARGV: sessionId, n, then per entry: index, id.
|
|
1622
|
+
* Deletes the listed entries below the latest clear and their id records.
|
|
1623
|
+
* An id record survives when it names another event, so an id reused in the
|
|
1624
|
+
* new lifetime keeps its owner. Returns [deleted, done].
|
|
1625
|
+
*/
|
|
1626
|
+
const RECLAIM_LUA = `${BASE_LUA}${SWEEP_LUA}
|
|
1627
|
+
local boundary = tonumber(redis.call('GET', CLEAR) or '0')
|
|
1628
|
+
local MOVED = KEYS[3]
|
|
1629
|
+
local id_keys, indexes, stream_ids = {}, {}, {}
|
|
1630
|
+
-- A concurrent reclaim may list the same batch; only XDEL says who removed it.
|
|
1631
|
+
local removed = 0
|
|
1632
|
+
for i = 1, tonumber(ARGV[2]) do
|
|
1633
|
+
local field = ARGV[2 + (i - 1) * 2 + 1]
|
|
1634
|
+
if tonumber(field) < boundary then
|
|
1635
|
+
id_keys[#id_keys + 1] = KEYS[3 + i]
|
|
1636
|
+
indexes[#id_keys] = tonumber(field)
|
|
1637
|
+
stream_ids[#id_keys] = field .. '-0'
|
|
1638
|
+
end
|
|
1639
|
+
end
|
|
1640
|
+
if #id_keys > 0 then
|
|
1641
|
+
local owners, stale_ids = sliced('MGET', nil, id_keys), {}
|
|
1642
|
+
for i = 1, #id_keys do
|
|
1643
|
+
if owners[i] then
|
|
1644
|
+
local rec = cjson.decode(owners[i])
|
|
1645
|
+
if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
|
|
1646
|
+
stale_ids[#stale_ids + 1] = id_keys[i]
|
|
1647
|
+
end
|
|
1648
|
+
end
|
|
1649
|
+
end
|
|
1650
|
+
sliced('DEL', nil, stale_ids)
|
|
1651
|
+
removed = redis.call('XDEL', MOVED, unpack(stream_ids))
|
|
1652
|
+
end
|
|
1653
|
+
local done = 0
|
|
1654
|
+
if redis.call('XLEN', MOVED) == 0 then
|
|
1655
|
+
redis.call('DEL', MOVED)
|
|
1656
|
+
redis.call('SREM', SESSION .. 'cleared', MOVED)
|
|
1657
|
+
if redis.call('SCARD', SESSION .. 'cleared') == 0 then
|
|
1658
|
+
sweep(boundary)
|
|
1659
|
+
done = 1
|
|
1660
|
+
end
|
|
1661
|
+
end
|
|
1662
|
+
return { removed, done }
|
|
1663
|
+
`;
|
|
1664
|
+
const fieldMap = (fields) => {
|
|
1665
|
+
const map = {};
|
|
1666
|
+
for (let i = 0; i + 1 < fields.length; i += 2) map[fields[i]] = fields[i + 1];
|
|
1667
|
+
return map;
|
|
1668
|
+
};
|
|
1669
|
+
const entryIndex = (entryId) => Number(entryId.slice(0, entryId.indexOf("-")));
|
|
1670
|
+
const toEvent = (sessionId, entry) => {
|
|
1671
|
+
const fields = fieldMap(entry[1]);
|
|
1672
|
+
return {
|
|
1673
|
+
id: fields["id"],
|
|
1674
|
+
type: fields["type"],
|
|
1675
|
+
payload: JSON.parse(fields["payload"]),
|
|
1676
|
+
index: entryIndex(entry[0]),
|
|
1677
|
+
sessionId,
|
|
1678
|
+
createdAt: new Date(Number(fields["created_at"]))
|
|
1679
|
+
};
|
|
1680
|
+
};
|
|
1681
|
+
/**
|
|
1682
|
+
* A stream entry's causal edge. Releases before consumers (0.17 to 0.20)
|
|
1683
|
+
* recorded only the parent's index and attempt: their edge reads as the
|
|
1684
|
+
* handlers table's, `a2.handlers`, as Postgres's upgrade names it, with no
|
|
1685
|
+
* lane, since nothing reads an earlier release's lane.
|
|
1686
|
+
*/
|
|
1687
|
+
const decodeStreamCause = (raw) => {
|
|
1688
|
+
const parsed = JSON.parse(raw);
|
|
1689
|
+
return decodeCause(parsed.consumer === void 0 ? {
|
|
1690
|
+
consumer: "a2.handlers",
|
|
1691
|
+
lane: null,
|
|
1692
|
+
...parsed
|
|
1693
|
+
} : parsed);
|
|
1694
|
+
};
|
|
1695
|
+
/** A stream entry with its causal edge. */
|
|
1696
|
+
const toStoredEvent = (sessionId, entry) => {
|
|
1697
|
+
const cause = fieldMap(entry[1])["cause"];
|
|
1698
|
+
return Object.assign(toEvent(sessionId, entry), { cause: cause === void 0 || cause === "" ? null : decodeStreamCause(cause) });
|
|
1699
|
+
};
|
|
1700
|
+
const fieldBytes = (name) => new TextEncoder().encode(name);
|
|
1701
|
+
const ID_FIELD = fieldBytes("id");
|
|
1702
|
+
const TYPE_FIELD = fieldBytes("type");
|
|
1703
|
+
const PAYLOAD_FIELD = fieldBytes("payload");
|
|
1704
|
+
const CREATED_AT_FIELD = fieldBytes("created_at");
|
|
1705
|
+
const utf8 = new TextDecoder("utf-8", { ignoreBOM: true });
|
|
1706
|
+
const isField = (name, field) => {
|
|
1707
|
+
if (name.length !== field.length) return false;
|
|
1708
|
+
for (let at = 0; at < field.length; at++) if (name[at] !== field[at]) return false;
|
|
1709
|
+
return true;
|
|
1710
|
+
};
|
|
1711
|
+
const bytesToSerializedEvent = (sessionId, entry) => {
|
|
1712
|
+
const values = entry[1];
|
|
1713
|
+
let id;
|
|
1714
|
+
let type;
|
|
1715
|
+
let payloadJson;
|
|
1716
|
+
let createdAt;
|
|
1717
|
+
for (let at = 0; at + 1 < values.length; at += 2) {
|
|
1718
|
+
const name = values[at];
|
|
1719
|
+
if (isField(name, ID_FIELD)) id = utf8.decode(values[at + 1]);
|
|
1720
|
+
else if (isField(name, TYPE_FIELD)) type = utf8.decode(values[at + 1]);
|
|
1721
|
+
else if (isField(name, PAYLOAD_FIELD)) payloadJson = utf8.decode(values[at + 1]);
|
|
1722
|
+
else if (isField(name, CREATED_AT_FIELD)) createdAt = utf8.decode(values[at + 1]);
|
|
1723
|
+
}
|
|
1724
|
+
return {
|
|
1725
|
+
id,
|
|
1726
|
+
type,
|
|
1727
|
+
payloadJson,
|
|
1728
|
+
index: entryIndex(utf8.decode(entry[0])),
|
|
1729
|
+
sessionId,
|
|
1730
|
+
createdAtMs: Number(createdAt)
|
|
1731
|
+
};
|
|
1732
|
+
};
|
|
1733
|
+
const toSerializedEvent = (sessionId, entry) => {
|
|
1734
|
+
const fields = fieldMap(entry[1]);
|
|
1735
|
+
return {
|
|
1736
|
+
id: fields["id"],
|
|
1737
|
+
type: fields["type"],
|
|
1738
|
+
payloadJson: fields["payload"],
|
|
1739
|
+
index: entryIndex(entry[0]),
|
|
1740
|
+
sessionId,
|
|
1741
|
+
createdAtMs: Number(fields["created_at"])
|
|
1742
|
+
};
|
|
1743
|
+
};
|
|
1744
|
+
const json = (value) => JSON.stringify(value) ?? "null";
|
|
1745
|
+
/** How scripts name a cursor within its consumer: `#`, or `L` and its lane. */
|
|
1746
|
+
const tagOf = (lane) => lane === null ? "#" : `L${lane}`;
|
|
1747
|
+
/** The lane a cursor tag names: null for an event cursor. */
|
|
1748
|
+
const laneOf = (tag) => tag.startsWith("#") ? null : tag.slice(1);
|
|
1749
|
+
const targetsArg = (targets) => targets === void 0 || targets.length === 0 ? "" : JSON.stringify(targets.map((target) => [target.consumer, tagOf(target.lane)]));
|
|
1750
|
+
/** A log consumer's types, as scripts read them; '*' takes every type. */
|
|
1751
|
+
const logTypesArg = (types) => types === null ? "*" : JSON.stringify(types);
|
|
1752
|
+
/** The served consumers, as scripts read them; '' serves every consumer. */
|
|
1753
|
+
const servedArg = (consumers) => consumers === void 0 ? "" : JSON.stringify(consumers.map((consumer) => consumer.log ? [
|
|
1754
|
+
consumer.name,
|
|
1755
|
+
consumer.log.lane,
|
|
1756
|
+
logTypesArg(consumer.log.types),
|
|
1757
|
+
consumer.log.backfill ? 1 : 0
|
|
1758
|
+
] : [consumer.name]));
|
|
1759
|
+
/** A head's dispatch state from a row's HEAD_COLUMNS. */
|
|
1760
|
+
const dispatchOf = (consumer, lane, columns) => {
|
|
1761
|
+
const column = (at) => {
|
|
1762
|
+
const value = columns?.[at];
|
|
1763
|
+
return value === null || value === void 0 ? null : String(value);
|
|
1764
|
+
};
|
|
1765
|
+
const date = (at) => {
|
|
1766
|
+
const value = column(at);
|
|
1767
|
+
return value === null ? null : new Date(Number(value));
|
|
1768
|
+
};
|
|
1769
|
+
const count = (at) => {
|
|
1770
|
+
const value = column(at);
|
|
1771
|
+
return value === null ? null : Number(value);
|
|
1772
|
+
};
|
|
1773
|
+
return {
|
|
1774
|
+
consumer,
|
|
1775
|
+
lane,
|
|
1776
|
+
firstClaimedAt: date(1),
|
|
1777
|
+
lastClaimedAt: date(2),
|
|
1778
|
+
attemptCount: count(0) ?? 0,
|
|
1779
|
+
claimHolder: column(3),
|
|
1780
|
+
claimExpiresAt: date(4),
|
|
1781
|
+
failureCount: count(5) ?? 0,
|
|
1782
|
+
lastFailedAt: date(6),
|
|
1783
|
+
lastFailedAttempt: count(7),
|
|
1784
|
+
lastError: column(8),
|
|
1785
|
+
failedAt: date(9)
|
|
1786
|
+
};
|
|
1787
|
+
};
|
|
1788
|
+
const toHeadEvent = (sessionId, head) => ({
|
|
1789
|
+
...toStoredEvent(sessionId, head[4]),
|
|
1790
|
+
...dispatchOf(head[0], laneOf(head[1]), head[3])
|
|
1791
|
+
});
|
|
1792
|
+
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
1793
|
+
const byHead = (a, b) => a.index - b.index || compareText(a.consumer, b.consumer);
|
|
1794
|
+
/** Consumer, then lane, with concurrent cursors last by head. */
|
|
1795
|
+
const byCursor = (a, b) => compareText(a.consumer, b.consumer) || (a.lane === null ? b.lane === null ? (a.head ?? 0) - (b.head ?? 0) : 1 : b.lane === null ? -1 : compareText(a.lane, b.lane));
|
|
1796
|
+
const toClaimAvailableResult = (sessionId, reply) => {
|
|
1797
|
+
const outcome = String(reply[0]);
|
|
1798
|
+
if (outcome === "claimed") {
|
|
1799
|
+
const count = Number(reply[1]);
|
|
1800
|
+
const events = reply.slice(2, 2 + count).map((head) => toHeadEvent(sessionId, head)).toSorted(byHead);
|
|
1801
|
+
const clearIndex = Number(reply[2 + count] ?? 0);
|
|
1802
|
+
if (clearIndex > 0) for (const event of events) event.clearIndex = clearIndex;
|
|
1803
|
+
return {
|
|
1804
|
+
outcome: "claimed",
|
|
1805
|
+
events,
|
|
1806
|
+
head: Number(reply[3 + count])
|
|
1807
|
+
};
|
|
1808
|
+
}
|
|
1809
|
+
if (outcome === "busy") return {
|
|
1810
|
+
outcome,
|
|
1811
|
+
retryAt: new Date(Number(reply[1]))
|
|
1812
|
+
};
|
|
1813
|
+
if (outcome === "settled") return { outcome };
|
|
1814
|
+
throw new TypeError(`claim referenced a missing event in session '${sessionId}'`);
|
|
1815
|
+
};
|
|
1816
|
+
const toIndex = ({ raw, sessionId, name }) => {
|
|
1817
|
+
const index = Number(raw ?? 0);
|
|
1818
|
+
if (!Number.isSafeInteger(index) || index < 0) throw new A2Error("STORE_UNAVAILABLE", `redis ${name} for session '${sessionId}' is invalid`);
|
|
1819
|
+
return index;
|
|
1820
|
+
};
|
|
1821
|
+
/**
|
|
1822
|
+
* A range read that met a gap. A clear moves the old lifetime's log aside, so
|
|
1823
|
+
* a read whose bounds predate it finds nothing where it expected events;
|
|
1824
|
+
* anywhere else a gap is corruption.
|
|
1825
|
+
*/
|
|
1826
|
+
const rangeGap = (sessionId, message) => Object.assign(new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' ${message}`), { rangeGap: true });
|
|
1827
|
+
const isRangeGap = (error) => error instanceof A2Error && error.rangeGap === true;
|
|
1828
|
+
/**
|
|
1829
|
+
* Run a whole read, bounds included. A gap means a clear raced it, so the
|
|
1830
|
+
* read runs once more from fresh bounds. A second gap, from another clear or
|
|
1831
|
+
* from corruption, surfaces as the retryable STORE_UNAVAILABLE.
|
|
1832
|
+
*/
|
|
1833
|
+
const retryAfterGap = async (read) => {
|
|
1834
|
+
try {
|
|
1835
|
+
return await read();
|
|
1836
|
+
} catch (error) {
|
|
1837
|
+
if (!isRangeGap(error)) throw error;
|
|
1838
|
+
return read();
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
const wrap = async (fn) => {
|
|
1842
|
+
try {
|
|
1843
|
+
return await fn();
|
|
1844
|
+
} catch (err) {
|
|
1845
|
+
if (err instanceof A2Error || err instanceof TypeError) throw err;
|
|
1846
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
1847
|
+
}
|
|
1848
|
+
};
|
|
1849
|
+
function createRedisStoreCore(options) {
|
|
1850
|
+
const { call, callBytes, clock } = options;
|
|
1851
|
+
const generateId = options.ids;
|
|
1852
|
+
const prefix = options.keyPrefix;
|
|
1853
|
+
const storeKey = (s) => `${prefix}:${s}:log`;
|
|
1854
|
+
const countKey = (s) => `${prefix}:${s}:count`;
|
|
1855
|
+
const clearKey = (s) => `${prefix}:${s}:clear`;
|
|
1856
|
+
const schemaKey = `${prefix}:schema`;
|
|
1857
|
+
const snapKey = (s, r) => `${prefix}:${s}:snap:${r}`;
|
|
1858
|
+
const snapHistoryKey = (s, r) => `${prefix}:${s}:snap-history:${r}`;
|
|
1859
|
+
const presenceKey = (s) => `${prefix}:${s}:presence`;
|
|
1860
|
+
const notifyChannel = (s) => `${prefix}:${s}:notify`;
|
|
1861
|
+
const idKey = (id) => `${prefix}:ids:${JSON.stringify(id).replaceAll(":", "\\u003a")}`;
|
|
1862
|
+
const layoutError = () => new A2Error("STORE_NOT_CONFIGURED", `the redis keys under prefix '${prefix}' hold an A2 layout other than '${REDIS_LAYOUT}', from another release; point the store at a fresh keyPrefix, or give the contract a new name so it starts new sessions`);
|
|
1863
|
+
/** Runs a script over a session: KEYS start with its counter and the layout key. */
|
|
1864
|
+
const evalScript = async (script, sessionId, keys, args) => {
|
|
1865
|
+
const allKeys = [
|
|
1866
|
+
countKey(sessionId),
|
|
1867
|
+
schemaKey,
|
|
1868
|
+
...keys
|
|
1869
|
+
];
|
|
1870
|
+
try {
|
|
1871
|
+
return await call("EVAL", script, allKeys.length, ...allKeys, ...args);
|
|
1872
|
+
} catch (error) {
|
|
1873
|
+
if (String(error?.message).includes(LAYOUT_ERROR)) throw layoutError();
|
|
1874
|
+
throw error;
|
|
1875
|
+
}
|
|
1876
|
+
};
|
|
1877
|
+
/**
|
|
1878
|
+
* Runs a write that may need a type's index to reach further back first.
|
|
1879
|
+
* Such a write returns ['index', type, after] having written nothing, and
|
|
1880
|
+
* runs again once INDEX_TYPE_LUA has indexed that history, a page per call.
|
|
1881
|
+
*/
|
|
1882
|
+
const indexing = async (sessionId, run) => {
|
|
1883
|
+
for (;;) {
|
|
1884
|
+
const reply = await run();
|
|
1885
|
+
if (reply[0] !== "index") return reply;
|
|
1886
|
+
for (;;) {
|
|
1887
|
+
const [done] = await evalScript(INDEX_TYPE_LUA, sessionId, [], [String(reply[1]), Number(reply[2])]);
|
|
1888
|
+
if (done === "1") break;
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
};
|
|
1892
|
+
/** The frontier and clear marker, checking the layout in the same MGET. */
|
|
1893
|
+
const readBounds = async (sessionId) => {
|
|
1894
|
+
const [count, cleared, schema] = await call("MGET", countKey(sessionId), clearKey(sessionId), schemaKey);
|
|
1895
|
+
if (schema !== null && schema !== void 0 && schema !== "cursors-2") throw layoutError();
|
|
1896
|
+
return {
|
|
1897
|
+
frontier: toIndex({
|
|
1898
|
+
raw: count,
|
|
1899
|
+
sessionId,
|
|
1900
|
+
name: "event count"
|
|
1901
|
+
}),
|
|
1902
|
+
clearIndex: toIndex({
|
|
1903
|
+
raw: cleared,
|
|
1904
|
+
sessionId,
|
|
1905
|
+
name: "clear boundary"
|
|
1906
|
+
})
|
|
1907
|
+
};
|
|
1908
|
+
};
|
|
1909
|
+
const readEventPages = async (sessionId, afterIndex, throughIndex) => {
|
|
1910
|
+
if (throughIndex <= afterIndex) return [];
|
|
1911
|
+
const events = [];
|
|
1912
|
+
let nextIndex = afterIndex + 1;
|
|
1913
|
+
while (nextIndex <= throughIndex) {
|
|
1914
|
+
const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${throughIndex}-0`, "COUNT", 256);
|
|
1915
|
+
if (entries.length === 0) throw rangeGap(sessionId, `ended before index ${throughIndex}`);
|
|
1916
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
1917
|
+
const index = entryIndex(entries[position][0]);
|
|
1918
|
+
if (index !== nextIndex + position || index > throughIndex) throw rangeGap(sessionId, "is not contiguous");
|
|
1919
|
+
}
|
|
1920
|
+
events.push(...entries.map((entry) => toEvent(sessionId, entry)));
|
|
1921
|
+
nextIndex = events.at(-1).index + 1;
|
|
1922
|
+
}
|
|
1923
|
+
return events;
|
|
1924
|
+
};
|
|
1925
|
+
const readEventRange = async (sessionId, afterIndex, throughIndex) => {
|
|
1926
|
+
const events = [];
|
|
1927
|
+
for (let start = afterIndex; start < throughIndex; start += 256 * READ_CONCURRENCY) {
|
|
1928
|
+
const ranges = Array.from({ length: Math.min(READ_CONCURRENCY, Math.ceil((throughIndex - start) / 256)) }, (_, position) => {
|
|
1929
|
+
const after = start + position * 256;
|
|
1930
|
+
return readEventPages(sessionId, after, Math.min(after + 256, throughIndex));
|
|
1931
|
+
});
|
|
1932
|
+
const results = await Promise.allSettled(ranges);
|
|
1933
|
+
for (const result of results) {
|
|
1934
|
+
if (result.status === "rejected") throw result.reason;
|
|
1935
|
+
events.push(...result.value);
|
|
1936
|
+
}
|
|
1937
|
+
}
|
|
1938
|
+
return events;
|
|
1939
|
+
};
|
|
1940
|
+
/** Bounded XRANGE pages restore the full-read contract on capped providers. */
|
|
1941
|
+
const readRange = async (sessionId, afterIndex, throughIndex) => (await readBoundedRange(sessionId, afterIndex, throughIndex)).rows;
|
|
1942
|
+
/** `readRange`, with the clear boundary its rows start at. */
|
|
1943
|
+
const readBoundedRange = async (sessionId, afterIndex, throughIndex) => {
|
|
1944
|
+
if (throughIndex !== void 0 && throughIndex <= afterIndex) return {
|
|
1945
|
+
rows: [],
|
|
1946
|
+
clearIndex: 0
|
|
1947
|
+
};
|
|
1948
|
+
const { frontier: currentFrontier, clearIndex } = await readBounds(sessionId);
|
|
1949
|
+
const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier);
|
|
1950
|
+
const after = Math.max(afterIndex, clearIndex - 1);
|
|
1951
|
+
if (frontier <= after) return {
|
|
1952
|
+
rows: [],
|
|
1953
|
+
clearIndex
|
|
1954
|
+
};
|
|
1955
|
+
const rows = [];
|
|
1956
|
+
let nextIndex = after + 1;
|
|
1957
|
+
while (nextIndex <= frontier) {
|
|
1958
|
+
const entries = await call("XRANGE", storeKey(sessionId), `${nextIndex}-0`, `${frontier}-0`, "COUNT", 256);
|
|
1959
|
+
if (entries.length === 0) throw rangeGap(sessionId, `ended before index ${frontier}`);
|
|
1960
|
+
const page = entries.map((entry) => toStoredEvent(sessionId, entry));
|
|
1961
|
+
for (let position = 0; position < page.length; position += 1) if (page[position].index !== nextIndex + position) throw rangeGap(sessionId, "is not contiguous");
|
|
1962
|
+
const lastIndex = page.at(-1).index;
|
|
1963
|
+
if (lastIndex < nextIndex || lastIndex > frontier) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' did not advance`);
|
|
1964
|
+
rows.push(...page);
|
|
1965
|
+
nextIndex = lastIndex + 1;
|
|
1966
|
+
}
|
|
1967
|
+
return {
|
|
1968
|
+
rows,
|
|
1969
|
+
clearIndex
|
|
1970
|
+
};
|
|
1971
|
+
};
|
|
1972
|
+
const readStream = options.readStream;
|
|
1973
|
+
return {
|
|
1974
|
+
...readStream ? { streamEvents: async function* ({ sessionId, afterIndex, signal }) {
|
|
1975
|
+
try {
|
|
1976
|
+
for await (const entries of readStream({
|
|
1977
|
+
key: storeKey(sessionId),
|
|
1978
|
+
from: `${afterIndex + 1}-0`,
|
|
1979
|
+
signal
|
|
1980
|
+
})) {
|
|
1981
|
+
signal.throwIfAborted();
|
|
1982
|
+
yield entries.map((entry) => toSerializedEvent(sessionId, entry));
|
|
1983
|
+
}
|
|
1984
|
+
} catch (cause) {
|
|
1985
|
+
if (cause instanceof A2Error) throw cause;
|
|
1986
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis stream read failed", { cause });
|
|
1987
|
+
}
|
|
1988
|
+
} } : {},
|
|
1989
|
+
async append(sessionId, events, appendOptions) {
|
|
1990
|
+
return wrap(async () => {
|
|
1991
|
+
const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
|
|
1992
|
+
if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
1993
|
+
const now = clock.now().getTime();
|
|
1994
|
+
const withIds = events.map((e) => ({
|
|
1995
|
+
id: e.id ?? generateId(),
|
|
1996
|
+
type: e.type,
|
|
1997
|
+
payload: e.payload,
|
|
1998
|
+
cause: e.cause ? { ...e.cause } : null,
|
|
1999
|
+
targets: e.targets
|
|
2000
|
+
}));
|
|
2001
|
+
const reply = await indexing(sessionId, () => evalScript(APPEND_LUA, sessionId, withIds.map((e) => idKey(e.id)), [
|
|
2002
|
+
sessionId,
|
|
2003
|
+
now,
|
|
2004
|
+
withIds.length,
|
|
2005
|
+
servedArg(appendOptions?.consumers),
|
|
2006
|
+
notifyChannel(sessionId),
|
|
2007
|
+
appendOptions?.originIndex ?? "",
|
|
2008
|
+
...withIds.flatMap((e) => [
|
|
2009
|
+
e.id,
|
|
2010
|
+
e.type,
|
|
2011
|
+
json(e.payload),
|
|
2012
|
+
e.cause ? json(e.cause) : "",
|
|
2013
|
+
targetsArg(e.targets)
|
|
2014
|
+
])
|
|
2015
|
+
]));
|
|
2016
|
+
const [verdict] = reply;
|
|
2017
|
+
if (verdict === "empty") return {
|
|
2018
|
+
events: [],
|
|
2019
|
+
hasPending: reply[1] === "1"
|
|
2020
|
+
};
|
|
2021
|
+
if (verdict === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${reply[1]}' already exists in another session`);
|
|
2022
|
+
if (verdict === "cleared") throw sessionClearedError(`scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`);
|
|
2023
|
+
if (verdict === "superseded") throw supersededAttemptError(sessionId, withIds[Number(reply[1]) - 1].cause);
|
|
2024
|
+
if (verdict === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${reply[1]} already-appended and ${events.length - Number(reply[1])} fresh events`);
|
|
2025
|
+
if (verdict === "dup") {
|
|
2026
|
+
const hasPending = reply[1] === "1";
|
|
2027
|
+
const firstIndex = Math.min(...reply.slice(2).map(Number));
|
|
2028
|
+
const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
|
|
2029
|
+
if (firstIndex < clearIndex) throw new A2Error("STORE_UNAVAILABLE", `session '${sessionId}' was cleared while an append retry replayed; retry it`);
|
|
2030
|
+
return {
|
|
2031
|
+
events: idempotentReplay(events, existing),
|
|
2032
|
+
hasPending
|
|
2033
|
+
};
|
|
2034
|
+
}
|
|
2035
|
+
const base = Number(reply[1]) - 1;
|
|
2036
|
+
return {
|
|
2037
|
+
events: withIds.map((e, i) => ({
|
|
2038
|
+
id: e.id,
|
|
2039
|
+
type: e.type,
|
|
2040
|
+
payload: structuredClone(e.payload),
|
|
2041
|
+
index: base + 1 + i,
|
|
2042
|
+
sessionId,
|
|
2043
|
+
createdAt: new Date(now),
|
|
2044
|
+
cause: e.cause
|
|
2045
|
+
})),
|
|
2046
|
+
hasPending: reply[2] === "1"
|
|
2047
|
+
};
|
|
2048
|
+
});
|
|
2049
|
+
},
|
|
2050
|
+
async cursors(sessionId) {
|
|
2051
|
+
return wrap(async () => {
|
|
2052
|
+
return (await evalScript(CURSORS_LUA, sessionId, [], [])).map(([consumer, tag, position, head, columns]) => Object.assign(dispatchOf(consumer, laneOf(tag), columns), {
|
|
2053
|
+
position: Number(position),
|
|
2054
|
+
head: head === null ? null : Number(head)
|
|
2055
|
+
})).toSorted(byCursor);
|
|
2056
|
+
});
|
|
2057
|
+
},
|
|
2058
|
+
async read(sessionId, opts) {
|
|
2059
|
+
return wrap(() => retryAfterGap(() => readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex)));
|
|
2060
|
+
},
|
|
2061
|
+
async readHistory(sessionId, opts) {
|
|
2062
|
+
const after = opts?.afterIndex ?? 0;
|
|
2063
|
+
if (opts?.throughIndex !== void 0 && opts.throughIndex <= after) return [];
|
|
2064
|
+
return wrap(() => retryAfterGap(async () => {
|
|
2065
|
+
const { frontier, clearIndex } = await readBounds(sessionId);
|
|
2066
|
+
return readEventRange(sessionId, Math.max(after, clearIndex - 1), Math.min(opts?.throughIndex ?? frontier, frontier));
|
|
2067
|
+
}));
|
|
2068
|
+
},
|
|
2069
|
+
async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, exclude, consumers, maxFailures, through }) {
|
|
2070
|
+
return wrap(async () => {
|
|
2071
|
+
const now = clock.now().getTime();
|
|
2072
|
+
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
2073
|
+
const excluded = exclude ?? [];
|
|
2074
|
+
const reply = await evalScript(CLAIM_AVAILABLE_LUA, sessionId, [], [
|
|
2075
|
+
holder,
|
|
2076
|
+
now,
|
|
2077
|
+
expiresAt,
|
|
2078
|
+
maxFailures ?? "",
|
|
2079
|
+
servedArg(consumers),
|
|
2080
|
+
through ?? "",
|
|
2081
|
+
excluded.length,
|
|
2082
|
+
...excluded.flatMap((ref) => [ref.consumer, ref.lane === null ? `#${ref.index}` : `L${ref.lane}`])
|
|
2083
|
+
]);
|
|
2084
|
+
return toClaimAvailableResult(sessionId, reply);
|
|
2085
|
+
});
|
|
2086
|
+
},
|
|
2087
|
+
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
2088
|
+
if (claims.length === 0) return {
|
|
2089
|
+
renewed: [],
|
|
2090
|
+
superseded: []
|
|
2091
|
+
};
|
|
2092
|
+
return wrap(async () => {
|
|
2093
|
+
const now = clock.now().getTime();
|
|
2094
|
+
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
2095
|
+
const reply = await evalScript(RENEW_CLAIMS_LUA, sessionId, [], [
|
|
2096
|
+
holder,
|
|
2097
|
+
now,
|
|
2098
|
+
expiresAt,
|
|
2099
|
+
claims.length,
|
|
2100
|
+
...claims.flatMap((claim) => [
|
|
2101
|
+
claim.consumer,
|
|
2102
|
+
tagOf(claim.lane),
|
|
2103
|
+
claim.index,
|
|
2104
|
+
claim.attempt
|
|
2105
|
+
])
|
|
2106
|
+
]);
|
|
2107
|
+
const renewedCount = Number(reply[0]);
|
|
2108
|
+
const refs = reply.slice(1).map((position) => {
|
|
2109
|
+
const { consumer, lane, index } = claims[Number(position) - 1];
|
|
2110
|
+
return {
|
|
2111
|
+
consumer,
|
|
2112
|
+
lane,
|
|
2113
|
+
index
|
|
2114
|
+
};
|
|
2115
|
+
});
|
|
2116
|
+
return {
|
|
2117
|
+
renewed: refs.slice(0, renewedCount).toSorted(byHead),
|
|
2118
|
+
superseded: refs.slice(renewedCount).toSorted(byHead)
|
|
2119
|
+
};
|
|
2120
|
+
});
|
|
2121
|
+
},
|
|
2122
|
+
async completeAttempt({ sessionId, consumer, lane, index, attempt, events, consumers }) {
|
|
2123
|
+
return wrap(async () => {
|
|
2124
|
+
const ids = events.map((event) => event.id);
|
|
2125
|
+
if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
2126
|
+
const now = clock.now().getTime();
|
|
2127
|
+
const cause = {
|
|
2128
|
+
consumer,
|
|
2129
|
+
lane,
|
|
2130
|
+
index,
|
|
2131
|
+
attempt
|
|
2132
|
+
};
|
|
2133
|
+
const reply = await indexing(sessionId, () => evalScript(COMPLETE_ATTEMPT_LUA, sessionId, ids.map((id) => idKey(id)), [
|
|
2134
|
+
sessionId,
|
|
2135
|
+
now,
|
|
2136
|
+
consumer,
|
|
2137
|
+
tagOf(lane),
|
|
2138
|
+
index,
|
|
2139
|
+
attempt,
|
|
2140
|
+
json(cause),
|
|
2141
|
+
servedArg(consumers),
|
|
2142
|
+
notifyChannel(sessionId),
|
|
2143
|
+
json(ids),
|
|
2144
|
+
events.length,
|
|
2145
|
+
...events.flatMap((event) => [
|
|
2146
|
+
event.id,
|
|
2147
|
+
event.type,
|
|
2148
|
+
json(event.payload),
|
|
2149
|
+
targetsArg(event.targets)
|
|
2150
|
+
])
|
|
2151
|
+
]));
|
|
2152
|
+
const outcome = reply[0];
|
|
2153
|
+
if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
2154
|
+
if (outcome === "partial") throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event ids do not match the completed attempt");
|
|
2155
|
+
if (outcome === "superseded") return { outcome };
|
|
2156
|
+
if (outcome === "ok") {
|
|
2157
|
+
const base = Number(reply[1]) - 1;
|
|
2158
|
+
return {
|
|
2159
|
+
outcome: "completed",
|
|
2160
|
+
events: events.map((event, i) => ({
|
|
2161
|
+
id: event.id,
|
|
2162
|
+
type: event.type,
|
|
2163
|
+
payload: structuredClone(event.payload),
|
|
2164
|
+
index: base + 1 + i,
|
|
2165
|
+
sessionId,
|
|
2166
|
+
createdAt: new Date(now),
|
|
2167
|
+
cause: { ...cause }
|
|
2168
|
+
}))
|
|
2169
|
+
};
|
|
2170
|
+
}
|
|
2171
|
+
if (outcome === "duplicate") {
|
|
2172
|
+
if (events.length === 0) return {
|
|
2173
|
+
outcome: "completed",
|
|
2174
|
+
events: []
|
|
2175
|
+
};
|
|
2176
|
+
const firstIndex = Math.min(...reply.slice(1).map(Number));
|
|
2177
|
+
const { rows: existing, clearIndex } = await retryAfterGap(() => readBoundedRange(sessionId, firstIndex - 1, firstIndex + events.length - 1));
|
|
2178
|
+
if (index <= clearIndex) return { outcome: "superseded" };
|
|
2179
|
+
if (existing.some((row) => row.cause?.consumer !== consumer || row.cause.lane !== lane || row.cause.index !== index || row.cause.attempt !== attempt)) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
|
|
2180
|
+
return {
|
|
2181
|
+
outcome: "completed",
|
|
2182
|
+
events: idempotentReplay(events, existing)
|
|
2183
|
+
};
|
|
2184
|
+
}
|
|
2185
|
+
throw new TypeError(`unexpected completion outcome '${outcome}'`);
|
|
2186
|
+
});
|
|
2187
|
+
},
|
|
2188
|
+
async failAttempt({ sessionId, consumer, lane, index, attempt, error, maxFailures, retryAfterMs }) {
|
|
2189
|
+
return wrap(async () => {
|
|
2190
|
+
const now = clock.now().getTime();
|
|
2191
|
+
const reply = await evalScript(FAIL_ATTEMPT_LUA, sessionId, [], [
|
|
2192
|
+
consumer,
|
|
2193
|
+
tagOf(lane),
|
|
2194
|
+
index,
|
|
2195
|
+
attempt,
|
|
2196
|
+
error,
|
|
2197
|
+
maxFailures,
|
|
2198
|
+
now,
|
|
2199
|
+
retryAfterMs === void 0 ? "" : now + retryAfterMs
|
|
2200
|
+
]);
|
|
2201
|
+
const outcome = reply[0];
|
|
2202
|
+
if (outcome === "missing") throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
2203
|
+
return {
|
|
2204
|
+
outcome,
|
|
2205
|
+
failureCount: Number(reply[1])
|
|
2206
|
+
};
|
|
2207
|
+
});
|
|
2208
|
+
},
|
|
2209
|
+
async deadLetters(sessionId) {
|
|
2210
|
+
return wrap(async () => {
|
|
2211
|
+
return (await evalScript(DEAD_LETTERS_LUA, sessionId, [], [])).map((head) => toHeadEvent(sessionId, head)).toSorted(byHead);
|
|
2212
|
+
});
|
|
2213
|
+
},
|
|
2214
|
+
async redrive({ sessionId, consumer, index, action, consumers }) {
|
|
2215
|
+
return wrap(async () => {
|
|
2216
|
+
const [redriven, hasPending] = await indexing(sessionId, () => evalScript(REDRIVE_LUA, sessionId, [], [
|
|
2217
|
+
consumer,
|
|
2218
|
+
index,
|
|
2219
|
+
action,
|
|
2220
|
+
clock.now().getTime(),
|
|
2221
|
+
servedArg(consumers)
|
|
2222
|
+
]));
|
|
2223
|
+
return {
|
|
2224
|
+
redriven: redriven === "1",
|
|
2225
|
+
hasPending: hasPending === "1"
|
|
2226
|
+
};
|
|
2227
|
+
});
|
|
2228
|
+
},
|
|
2229
|
+
async wakeLog({ sessionId, consumer }) {
|
|
2230
|
+
return wrap(async () => {
|
|
2231
|
+
const { name, log } = consumer;
|
|
2232
|
+
const [, position, head, columns, sessionHead] = await indexing(sessionId, () => evalScript(WAKE_LOG_LUA, sessionId, [], [
|
|
2233
|
+
name,
|
|
2234
|
+
log.lane,
|
|
2235
|
+
logTypesArg(log.types),
|
|
2236
|
+
log.backfill ? 1 : 0
|
|
2237
|
+
]));
|
|
2238
|
+
return {
|
|
2239
|
+
cursor: Object.assign(dispatchOf(name, log.lane, columns), {
|
|
2240
|
+
position: Number(position),
|
|
2241
|
+
head: head === null ? null : Number(head)
|
|
2242
|
+
}),
|
|
2243
|
+
head: Number(sessionHead)
|
|
2244
|
+
};
|
|
2245
|
+
});
|
|
2246
|
+
},
|
|
2247
|
+
async readState(sessionId, reducerName, stateOptions) {
|
|
2248
|
+
return wrap(() => retryAfterGap(async () => {
|
|
2249
|
+
const reply = await evalScript(READ_STATE_LUA, sessionId, [snapKey(sessionId, reducerName), snapHistoryKey(sessionId, reducerName)], [stateOptions?.snapshotThroughIndex ?? stateOptions?.throughIndex ?? ""]);
|
|
2250
|
+
const rawHead = reply[0];
|
|
2251
|
+
const raw = reply[1];
|
|
2252
|
+
const headIndex = rawHead === "" ? null : Number(rawHead);
|
|
2253
|
+
const parsed = raw === "" ? null : JSON.parse(raw);
|
|
2254
|
+
const currentFrontier = Number(reply[2]);
|
|
2255
|
+
if (!Number.isSafeInteger(currentFrontier) || currentFrontier < 0 || headIndex !== null && (!Number.isSafeInteger(headIndex) || headIndex < 0) || parsed !== null && (!Number.isSafeInteger(parsed.index) || parsed.index < 0)) throw new A2Error("STORE_UNAVAILABLE", `redis state frontier for session '${sessionId}' is invalid`);
|
|
2256
|
+
const frontier = Math.min(currentFrontier, stateOptions?.throughIndex ?? currentFrontier);
|
|
2257
|
+
const snapshot = parsed;
|
|
2258
|
+
const clearIndex = Number(reply[3] ?? 0);
|
|
2259
|
+
const events = await readEventRange(sessionId, Math.max(snapshot?.index ?? 0, clearIndex - 1), frontier);
|
|
2260
|
+
return {
|
|
2261
|
+
...clearIndex > 0 ? { clearIndex } : {},
|
|
2262
|
+
headIndex,
|
|
2263
|
+
snapshot: snapshot ? {
|
|
2264
|
+
index: snapshot.index,
|
|
2265
|
+
state: snapshot.state
|
|
2266
|
+
} : null,
|
|
2267
|
+
events
|
|
2268
|
+
};
|
|
2269
|
+
}));
|
|
2270
|
+
},
|
|
2271
|
+
async putSnapshots(sessionId, reducerName, writes) {
|
|
2272
|
+
const byIndex = /* @__PURE__ */ new Map();
|
|
2273
|
+
for (const write of writes) {
|
|
2274
|
+
const existing = byIndex.get(write.index);
|
|
2275
|
+
const pins = existing?.pins ?? /* @__PURE__ */ new Map();
|
|
2276
|
+
for (const pin of write.pins ?? []) pins.set(JSON.stringify([
|
|
2277
|
+
pin.consumer,
|
|
2278
|
+
pin.lane,
|
|
2279
|
+
pin.index
|
|
2280
|
+
]), pin);
|
|
2281
|
+
if (existing) existing.state = write.state;
|
|
2282
|
+
else byIndex.set(write.index, {
|
|
2283
|
+
index: write.index,
|
|
2284
|
+
state: write.state,
|
|
2285
|
+
pins
|
|
2286
|
+
});
|
|
2287
|
+
}
|
|
2288
|
+
const normalized = [...byIndex.values()].toSorted((a, b) => a.index - b.index);
|
|
2289
|
+
if (normalized.length === 0) return;
|
|
2290
|
+
await wrap(async () => {
|
|
2291
|
+
const args = [normalized.length];
|
|
2292
|
+
for (const write of normalized) args.push(write.index, json({
|
|
2293
|
+
index: write.index,
|
|
2294
|
+
state: write.state,
|
|
2295
|
+
updatedAt: clock.now().getTime(),
|
|
2296
|
+
r: 1
|
|
2297
|
+
}), write.pins.size, ...[...write.pins.values()].flatMap((pin) => [
|
|
2298
|
+
pin.consumer,
|
|
2299
|
+
tagOf(pin.lane),
|
|
2300
|
+
pin.index
|
|
2301
|
+
]));
|
|
2302
|
+
await evalScript(PUT_SNAPSHOTS_LUA, sessionId, [snapKey(sessionId, reducerName), snapHistoryKey(sessionId, reducerName)], [...args, reducerName]);
|
|
2303
|
+
});
|
|
2304
|
+
},
|
|
2305
|
+
presence: {
|
|
2306
|
+
async set(ns, participant, values, meta) {
|
|
2307
|
+
return wrap(async () => {
|
|
2308
|
+
const entries = Object.entries(values);
|
|
2309
|
+
if (entries.length === 0) return null;
|
|
2310
|
+
const atMs = meta.at.getTime();
|
|
2311
|
+
const expiresAtMs = clock.now().getTime() + meta.ttlMs;
|
|
2312
|
+
const applied = await call("EVAL", PRESENCE_SET_LUA, 1, presenceKey(ns), atMs, meta.ttlMs, entries.length, ...entries.flatMap(([field, value]) => [
|
|
2313
|
+
JSON.stringify([participant, field]),
|
|
2314
|
+
value === null ? "" : json({
|
|
2315
|
+
v: value,
|
|
2316
|
+
s: meta.seen,
|
|
2317
|
+
a: atMs,
|
|
2318
|
+
e: expiresAtMs
|
|
2319
|
+
}),
|
|
2320
|
+
`${json(field)}:${json(value)}`
|
|
2321
|
+
]), presenceKey(ns), `${json({
|
|
2322
|
+
src: options.presenceSource,
|
|
2323
|
+
participant,
|
|
2324
|
+
seen: meta.seen,
|
|
2325
|
+
at: atMs
|
|
2326
|
+
}).slice(0, -1)},"values":{`);
|
|
2327
|
+
if (applied.length === 0) return null;
|
|
2328
|
+
const appliedValues = nullProtoRecord();
|
|
2329
|
+
for (const position of applied) {
|
|
2330
|
+
const [field, value] = entries[Number(position) - 1];
|
|
2331
|
+
appliedValues[field] = structuredClone(value);
|
|
2332
|
+
}
|
|
2333
|
+
return {
|
|
2334
|
+
participant,
|
|
2335
|
+
values: appliedValues,
|
|
2336
|
+
seen: meta.seen,
|
|
2337
|
+
at: new Date(atMs)
|
|
2338
|
+
};
|
|
2339
|
+
});
|
|
2340
|
+
},
|
|
2341
|
+
async read(ns) {
|
|
2342
|
+
return wrap(async () => {
|
|
2343
|
+
const reply = await call("EVAL", PRESENCE_READ_LUA, 1, presenceKey(ns), clock.now().getTime());
|
|
2344
|
+
const rows = [];
|
|
2345
|
+
for (let i = 0; i + 1 < reply.length; i += 2) {
|
|
2346
|
+
const [participant, field] = JSON.parse(reply[i]);
|
|
2347
|
+
const entry = JSON.parse(reply[i + 1]);
|
|
2348
|
+
rows.push({
|
|
2349
|
+
participant,
|
|
2350
|
+
field,
|
|
2351
|
+
value: entry.v,
|
|
2352
|
+
seen: entry.s,
|
|
2353
|
+
at: new Date(entry.a),
|
|
2354
|
+
expiresAt: new Date(entry.e)
|
|
2355
|
+
});
|
|
2356
|
+
}
|
|
2357
|
+
return rows;
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
},
|
|
2361
|
+
async readRange(sessionId, afterIndex, throughIndex) {
|
|
2362
|
+
return wrap(() => readRange(sessionId, afterIndex, throughIndex));
|
|
2363
|
+
},
|
|
2364
|
+
async readTail(sessionId, fromIndex, throughIndex) {
|
|
2365
|
+
return wrap(async () => {
|
|
2366
|
+
const page = (await call("XREVRANGE", storeKey(sessionId), throughIndex === void 0 ? "+" : `${throughIndex}-0`, `${fromIndex}-0`, "COUNT", 256)).toReversed().map((entry) => toEvent(sessionId, entry));
|
|
2367
|
+
for (let position = 1; position < page.length; position += 1) if (page[position].index !== page[position - 1].index + 1) throw new A2Error("STORE_UNAVAILABLE", `redis event range for session '${sessionId}' is not contiguous`);
|
|
2368
|
+
const oldest = page[0]?.index;
|
|
2369
|
+
return oldest === void 0 || oldest <= fromIndex ? page : null;
|
|
2370
|
+
});
|
|
2371
|
+
},
|
|
2372
|
+
async clear({ sessionId, ifLastIndex, cause }) {
|
|
2373
|
+
return wrap(async () => {
|
|
2374
|
+
const markerId = generateId();
|
|
2375
|
+
const reply = await evalScript(CLEAR_LUA, sessionId, [idKey(markerId)], [
|
|
2376
|
+
sessionId,
|
|
2377
|
+
clock.now().getTime(),
|
|
2378
|
+
ifLastIndex ?? "",
|
|
2379
|
+
cause?.consumer ?? "",
|
|
2380
|
+
cause ? tagOf(cause.lane) : "",
|
|
2381
|
+
cause?.index ?? "",
|
|
2382
|
+
cause?.attempt ?? "",
|
|
2383
|
+
markerId,
|
|
2384
|
+
notifyChannel(sessionId)
|
|
2385
|
+
]);
|
|
2386
|
+
if (reply[0] === "superseded") throw supersededAttemptError(sessionId, cause);
|
|
2387
|
+
if (reply[0] === "foreign") throw new A2Error("PARTIAL_DUPLICATE_BATCH", `the clear marker id '${markerId}' already exists`);
|
|
2388
|
+
return {
|
|
2389
|
+
cleared: reply[1] === "1",
|
|
2390
|
+
index: Number(reply[2]),
|
|
2391
|
+
clearIndex: Number(reply[3])
|
|
2392
|
+
};
|
|
2393
|
+
});
|
|
2394
|
+
},
|
|
2395
|
+
async reclaim(sessionId) {
|
|
2396
|
+
return wrap(async () => {
|
|
2397
|
+
const [log, ...pairs] = await evalScript(RECLAIM_BATCH_LUA, sessionId, [], [256]);
|
|
2398
|
+
if (log === void 0) return {
|
|
2399
|
+
deleted: 0,
|
|
2400
|
+
done: true
|
|
2401
|
+
};
|
|
2402
|
+
const ids = [];
|
|
2403
|
+
for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]);
|
|
2404
|
+
const [deleted, done] = await evalScript(RECLAIM_LUA, sessionId, [log, ...ids.map((id) => idKey(id))], [
|
|
2405
|
+
sessionId,
|
|
2406
|
+
ids.length,
|
|
2407
|
+
...pairs
|
|
2408
|
+
]);
|
|
2409
|
+
return {
|
|
2410
|
+
deleted: Number(deleted),
|
|
2411
|
+
done: Number(done) === 1
|
|
2412
|
+
};
|
|
2413
|
+
});
|
|
2414
|
+
},
|
|
2415
|
+
async readEvents(sessionId, afterIndex) {
|
|
2416
|
+
return wrap(async () => {
|
|
2417
|
+
return (await call("XRANGE", storeKey(sessionId), `${afterIndex + 1}-0`, "+", "COUNT", 256)).map((entry) => toEvent(sessionId, entry));
|
|
2418
|
+
});
|
|
2419
|
+
},
|
|
2420
|
+
async readFeedPage(sessionId, afterIndex) {
|
|
2421
|
+
return wrap(async () => {
|
|
2422
|
+
const range = [
|
|
2423
|
+
storeKey(sessionId),
|
|
2424
|
+
`${afterIndex + 1}-0`,
|
|
2425
|
+
"+",
|
|
2426
|
+
"COUNT",
|
|
2427
|
+
FEED_PAGE_SIZE
|
|
2428
|
+
];
|
|
2429
|
+
if (callBytes) return (await callBytes("XRANGE", ...range)).map((entry) => bytesToSerializedEvent(sessionId, entry));
|
|
2430
|
+
return (await call("XRANGE", ...range)).map((entry) => toSerializedEvent(sessionId, entry));
|
|
2431
|
+
});
|
|
2432
|
+
}
|
|
2433
|
+
};
|
|
2434
|
+
}
|
|
2435
|
+
//#endregion
|
|
2436
|
+
//#region src/store-redis-notify.ts
|
|
2437
|
+
/** A feed that falls this far behind drops its inbox and catches up. */
|
|
2438
|
+
const INBOX_BYTES = 524288;
|
|
2439
|
+
/**
|
|
2440
|
+
* Parses a wake-up message: `{"f":frontier}` or a bare frontier number
|
|
2441
|
+
* announces events without carrying them; `{"f","t","e"}` carries them.
|
|
2442
|
+
* Returns null for anything else, which callers treat as a hint to catch up.
|
|
2443
|
+
*/
|
|
2444
|
+
const parseWake = (message) => {
|
|
2445
|
+
let parsed;
|
|
2446
|
+
try {
|
|
2447
|
+
parsed = JSON.parse(message);
|
|
2448
|
+
} catch {
|
|
2449
|
+
return null;
|
|
2450
|
+
}
|
|
2451
|
+
if (typeof parsed === "number") return Number.isSafeInteger(parsed) ? {
|
|
2452
|
+
frontier: parsed,
|
|
2453
|
+
createdAt: 0,
|
|
2454
|
+
entries: []
|
|
2455
|
+
} : null;
|
|
2456
|
+
if (typeof parsed !== "object" || parsed === null) return null;
|
|
2457
|
+
const { f, t, e } = parsed;
|
|
2458
|
+
if (typeof f !== "number" || !Number.isSafeInteger(f)) return null;
|
|
2459
|
+
if (e === void 0) return {
|
|
2460
|
+
frontier: f,
|
|
2461
|
+
createdAt: 0,
|
|
2462
|
+
entries: []
|
|
2463
|
+
};
|
|
2464
|
+
if (!Array.isArray(e) || typeof t !== "number" || !Number.isFinite(t)) return null;
|
|
2465
|
+
for (const entry of e) if (!Array.isArray(entry) || entry.length !== 4 || !Number.isSafeInteger(entry[0]) || typeof entry[1] !== "string" || typeof entry[2] !== "string") return null;
|
|
2466
|
+
return {
|
|
2467
|
+
frontier: f,
|
|
2468
|
+
createdAt: t,
|
|
2469
|
+
entries: e
|
|
2470
|
+
};
|
|
2471
|
+
};
|
|
2472
|
+
function createRedisNotifyStore(options) {
|
|
2473
|
+
const { client, clock, ids, keyPrefix: prefix } = options;
|
|
2474
|
+
const notifyChannel = (sessionId) => `${prefix}:${sessionId}:notify`;
|
|
2475
|
+
const presenceChannel = (ns) => `${prefix}:${ns}:presence`;
|
|
2476
|
+
const instanceId = crypto.randomUUID();
|
|
2477
|
+
const core = createRedisStoreCore({
|
|
2478
|
+
call: async (command, ...args) => {
|
|
2479
|
+
return (await client()).call(command, ...args);
|
|
2480
|
+
},
|
|
2481
|
+
clock,
|
|
2482
|
+
ids,
|
|
2483
|
+
keyPrefix: prefix,
|
|
2484
|
+
presenceSource: instanceId,
|
|
2485
|
+
...options.callBytes ? { callBytes: options.callBytes } : {},
|
|
2486
|
+
...options.readStream ? { readStream: options.readStream } : {}
|
|
2487
|
+
});
|
|
2488
|
+
let severed = false;
|
|
2489
|
+
let subscriber = null;
|
|
2490
|
+
/** channel → live event feeds, registered before they subscribe. */
|
|
2491
|
+
const feeds = /* @__PURE__ */ new Map();
|
|
2492
|
+
/** channel → refcounted subscription held for each live iterator. */
|
|
2493
|
+
const subscriptions = /* @__PURE__ */ new Map();
|
|
2494
|
+
const presenceSubscriptions = /* @__PURE__ */ new Map();
|
|
2495
|
+
const pollingStreams = /* @__PURE__ */ new Set();
|
|
2496
|
+
options.onReconnect?.((channel) => {
|
|
2497
|
+
for (const feed of feeds.get(channel) ?? []) {
|
|
2498
|
+
feed.dirty = true;
|
|
2499
|
+
feed.wake?.();
|
|
2500
|
+
}
|
|
2501
|
+
presenceSubscriptions.get(channel)?.recovery?.reconnect().catch(() => {});
|
|
2502
|
+
});
|
|
2503
|
+
const deliverRemotePresence = ({ subscription, message }) => {
|
|
2504
|
+
let parsed;
|
|
2505
|
+
try {
|
|
2506
|
+
parsed = JSON.parse(message);
|
|
2507
|
+
} catch {
|
|
2508
|
+
return;
|
|
2509
|
+
}
|
|
2510
|
+
if (parsed.src === instanceId) return;
|
|
2511
|
+
const patch = {
|
|
2512
|
+
participant: parsed.participant,
|
|
2513
|
+
values: parsed.values,
|
|
2514
|
+
seen: parsed.seen,
|
|
2515
|
+
at: new Date(parsed.at)
|
|
2516
|
+
};
|
|
2517
|
+
subscription.recovery?.update(patch);
|
|
2518
|
+
for (const listener of subscription.listeners) listener(patch);
|
|
2519
|
+
};
|
|
2520
|
+
const getSubscriber = () => {
|
|
2521
|
+
subscriber ??= (async () => {
|
|
2522
|
+
const conn = (await client()).duplicate();
|
|
2523
|
+
conn.on("message", (channel, message) => {
|
|
2524
|
+
for (const feed of feeds.get(channel) ?? []) {
|
|
2525
|
+
if (feed.bytes + message.length <= INBOX_BYTES) {
|
|
2526
|
+
feed.inbox.push(message);
|
|
2527
|
+
feed.bytes += message.length;
|
|
2528
|
+
} else {
|
|
2529
|
+
feed.inbox.length = 0;
|
|
2530
|
+
feed.bytes = 0;
|
|
2531
|
+
feed.dirty = true;
|
|
2532
|
+
}
|
|
2533
|
+
feed.wake?.();
|
|
2534
|
+
}
|
|
2535
|
+
const subscription = presenceSubscriptions.get(channel);
|
|
2536
|
+
if (subscription) deliverRemotePresence({
|
|
2537
|
+
subscription,
|
|
2538
|
+
message
|
|
2539
|
+
});
|
|
2540
|
+
});
|
|
2541
|
+
return conn;
|
|
2542
|
+
})();
|
|
2543
|
+
return subscriber;
|
|
2544
|
+
};
|
|
2545
|
+
/**
|
|
2546
|
+
* The subscriber, gated on connection readiness — for channel
|
|
2547
|
+
* commands only (`close` uses the ungated promise, so it can never
|
|
2548
|
+
* hang on an unreachable server). SUBSCRIBE carries redis's
|
|
2549
|
+
* ok-loading flag, so ioredis writes it mid-handshake, ahead of the
|
|
2550
|
+
* ready check's INFO — which then finds the connection already in
|
|
2551
|
+
* subscriber mode and fails the check (a spurious error event plus
|
|
2552
|
+
* a reconnect). ECHO has no such flag: it resolves only once the
|
|
2553
|
+
* connection is ready, so every channel command chained behind it
|
|
2554
|
+
* lands after the check.
|
|
2555
|
+
*/
|
|
2556
|
+
let subscriberGate = null;
|
|
2557
|
+
const gatedSubscriber = () => {
|
|
2558
|
+
subscriberGate ??= getSubscriber().then(async (conn) => {
|
|
2559
|
+
await conn.call("echo", "ready");
|
|
2560
|
+
return conn;
|
|
2561
|
+
});
|
|
2562
|
+
return subscriberGate;
|
|
2563
|
+
};
|
|
2564
|
+
const acquireChannel = async (channel) => {
|
|
2565
|
+
const existing = subscriptions.get(channel);
|
|
2566
|
+
if (existing) {
|
|
2567
|
+
existing.refs += 1;
|
|
2568
|
+
try {
|
|
2569
|
+
await existing.ready;
|
|
2570
|
+
} catch (err) {
|
|
2571
|
+
existing.refs -= 1;
|
|
2572
|
+
throw err;
|
|
2573
|
+
}
|
|
2574
|
+
return;
|
|
2575
|
+
}
|
|
2576
|
+
const lease = {
|
|
2577
|
+
refs: 1,
|
|
2578
|
+
ready: gatedSubscriber().then(async (conn) => {
|
|
2579
|
+
await conn.call("subscribe", channel);
|
|
2580
|
+
if (subscriptions.get(channel) !== lease || severed) return;
|
|
2581
|
+
const presence = presenceSubscriptions.get(channel);
|
|
2582
|
+
if (options.polling?.() && presence) {
|
|
2583
|
+
presence.recovery = void 0;
|
|
2584
|
+
const ns = channel.slice(prefix.length + 1, -9);
|
|
2585
|
+
presence.poll = pollPresence({
|
|
2586
|
+
read: () => core.presence.read(ns),
|
|
2587
|
+
deliver: (patch) => {
|
|
2588
|
+
for (const listener of presence.listeners) listener(patch);
|
|
2589
|
+
}
|
|
2590
|
+
});
|
|
2591
|
+
await presence.poll.ready;
|
|
2592
|
+
}
|
|
2593
|
+
})
|
|
2594
|
+
};
|
|
2595
|
+
subscriptions.set(channel, lease);
|
|
2596
|
+
try {
|
|
2597
|
+
await lease.ready;
|
|
2598
|
+
} catch (err) {
|
|
2599
|
+
if (subscriptions.get(channel) === lease) {
|
|
2600
|
+
subscriptions.delete(channel);
|
|
2601
|
+
const presence = presenceSubscriptions.get(channel);
|
|
2602
|
+
presence?.poll?.stop();
|
|
2603
|
+
if (presence) presence.poll = void 0;
|
|
2604
|
+
}
|
|
2605
|
+
throw err;
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
const releaseChannel = (channel) => {
|
|
2609
|
+
const lease = subscriptions.get(channel);
|
|
2610
|
+
if (!lease) return;
|
|
2611
|
+
lease.refs -= 1;
|
|
2612
|
+
if (lease.refs > 0) return;
|
|
2613
|
+
subscriptions.delete(channel);
|
|
2614
|
+
gatedSubscriber().then((conn) => conn.call("unsubscribe", channel)).catch(() => {});
|
|
2615
|
+
};
|
|
2616
|
+
const store = {
|
|
2617
|
+
append: core.append,
|
|
2618
|
+
cursors: core.cursors,
|
|
2619
|
+
read: core.read,
|
|
2620
|
+
readHistory: core.readHistory,
|
|
2621
|
+
claimAvailable: core.claimAvailable,
|
|
2622
|
+
renewClaims: core.renewClaims,
|
|
2623
|
+
completeAttempt: core.completeAttempt,
|
|
2624
|
+
failAttempt: core.failAttempt,
|
|
2625
|
+
readState: core.readState,
|
|
2626
|
+
putSnapshots: core.putSnapshots,
|
|
2627
|
+
clear: core.clear,
|
|
2628
|
+
reclaim: core.reclaim,
|
|
2629
|
+
deadLetters: core.deadLetters,
|
|
2630
|
+
redrive: core.redrive,
|
|
2631
|
+
wakeLog: core.wakeLog,
|
|
2632
|
+
presence: {
|
|
2633
|
+
async set(ns, participant, values, meta) {
|
|
2634
|
+
const patch = await core.presence.set(ns, participant, values, meta);
|
|
2635
|
+
if (!patch) return;
|
|
2636
|
+
const channel = presenceChannel(ns);
|
|
2637
|
+
const subscription = presenceSubscriptions.get(channel);
|
|
2638
|
+
if (subscription) {
|
|
2639
|
+
subscription.poll?.update(patch);
|
|
2640
|
+
subscription.recovery?.update(patch);
|
|
2641
|
+
for (const listener of subscription.listeners) listener(patch);
|
|
2642
|
+
}
|
|
2643
|
+
},
|
|
2644
|
+
async read(ns) {
|
|
2645
|
+
const channel = presenceChannel(ns);
|
|
2646
|
+
await subscriptions.get(channel)?.ready;
|
|
2647
|
+
return presenceSubscriptions.get(channel)?.recovery?.read() ?? core.presence.read(ns);
|
|
2648
|
+
},
|
|
2649
|
+
subscribe(ns, onPatch) {
|
|
2650
|
+
const channel = presenceChannel(ns);
|
|
2651
|
+
let subscription = presenceSubscriptions.get(channel);
|
|
2652
|
+
if (!subscription) {
|
|
2653
|
+
subscription = { listeners: /* @__PURE__ */ new Set() };
|
|
2654
|
+
presenceSubscriptions.set(channel, subscription);
|
|
2655
|
+
if (options.onReconnect && !options.polling?.()) {
|
|
2656
|
+
const current = subscription;
|
|
2657
|
+
subscription.recovery = presenceRecovery({
|
|
2658
|
+
read: () => core.presence.read(ns),
|
|
2659
|
+
deliver: (patch) => {
|
|
2660
|
+
for (const listener of current.listeners) listener(patch);
|
|
2661
|
+
},
|
|
2662
|
+
isActive: () => !severed && presenceSubscriptions.get(channel) === current
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
subscription.listeners.add(onPatch);
|
|
2667
|
+
acquireChannel(channel).catch(() => {});
|
|
2668
|
+
let stopped = false;
|
|
2669
|
+
return () => {
|
|
2670
|
+
if (stopped) return;
|
|
2671
|
+
stopped = true;
|
|
2672
|
+
subscription.listeners.delete(onPatch);
|
|
2673
|
+
if (subscription.listeners.size === 0) {
|
|
2674
|
+
subscription.poll?.stop();
|
|
2675
|
+
presenceSubscriptions.delete(channel);
|
|
2676
|
+
}
|
|
2677
|
+
releaseChannel(channel);
|
|
2678
|
+
};
|
|
2679
|
+
}
|
|
2680
|
+
},
|
|
2681
|
+
stream(sessionId, opts) {
|
|
2682
|
+
const startAfter = opts?.startAfter ?? 0;
|
|
2683
|
+
const channel = notifyChannel(sessionId);
|
|
2684
|
+
return { [Symbol.asyncIterator]() {
|
|
2685
|
+
let last = startAfter;
|
|
2686
|
+
let buffer = [];
|
|
2687
|
+
let closed = false;
|
|
2688
|
+
let subscribed = false;
|
|
2689
|
+
let pending = null;
|
|
2690
|
+
let poller;
|
|
2691
|
+
let stopActivity;
|
|
2692
|
+
let catchup;
|
|
2693
|
+
let stoppingRead;
|
|
2694
|
+
const stopRead = () => {
|
|
2695
|
+
const reading = catchup;
|
|
2696
|
+
catchup = void 0;
|
|
2697
|
+
if (reading) {
|
|
2698
|
+
reading.controller.abort();
|
|
2699
|
+
stoppingRead = reading.iterator.return(void 0).then(() => void 0).catch(() => {});
|
|
2700
|
+
}
|
|
2701
|
+
return stoppingRead ?? Promise.resolve();
|
|
2702
|
+
};
|
|
2703
|
+
const nextRead = async (after) => {
|
|
2704
|
+
if (!catchup) {
|
|
2705
|
+
const controller = new AbortController();
|
|
2706
|
+
catchup = {
|
|
2707
|
+
controller,
|
|
2708
|
+
delivered: false,
|
|
2709
|
+
iterator: core.streamEvents({
|
|
2710
|
+
sessionId,
|
|
2711
|
+
afterIndex: after,
|
|
2712
|
+
signal: controller.signal
|
|
2713
|
+
})[Symbol.asyncIterator]()
|
|
2714
|
+
};
|
|
2715
|
+
}
|
|
2716
|
+
const reading = catchup;
|
|
2717
|
+
const result = await reading.iterator.next();
|
|
2718
|
+
if (result.done && catchup === reading) {
|
|
2719
|
+
catchup = void 0;
|
|
2720
|
+
if (reading.delivered) feed.dirty = true;
|
|
2721
|
+
} else if (!result.done) reading.delivered = true;
|
|
2722
|
+
return result;
|
|
2723
|
+
};
|
|
2724
|
+
const feed = {
|
|
2725
|
+
inbox: [],
|
|
2726
|
+
bytes: 0,
|
|
2727
|
+
dirty: true,
|
|
2728
|
+
wake: null,
|
|
2729
|
+
stopRead
|
|
2730
|
+
};
|
|
2731
|
+
const activity = createStreamActivity(() => {
|
|
2732
|
+
if (poller) streamActivities.get(poller)?.activate();
|
|
2733
|
+
});
|
|
2734
|
+
const release = () => {
|
|
2735
|
+
stopRead();
|
|
2736
|
+
stopActivity?.();
|
|
2737
|
+
if (poller) {
|
|
2738
|
+
poller.return?.();
|
|
2739
|
+
pollingStreams.delete(poller);
|
|
2740
|
+
}
|
|
2741
|
+
if (!subscribed) return;
|
|
2742
|
+
subscribed = false;
|
|
2743
|
+
const set = feeds.get(channel);
|
|
2744
|
+
set?.delete(feed);
|
|
2745
|
+
if (set?.size === 0) feeds.delete(channel);
|
|
2746
|
+
releaseChannel(channel);
|
|
2747
|
+
};
|
|
2748
|
+
/**
|
|
2749
|
+
* Moves carried events that continue this feed's sequence into
|
|
2750
|
+
* the buffer. A gap, a message without events, or an unreadable
|
|
2751
|
+
* message marks the feed dirty so it catches up with XRANGE.
|
|
2752
|
+
*/
|
|
2753
|
+
const applyInbox = () => {
|
|
2754
|
+
feed.bytes = 0;
|
|
2755
|
+
for (const message of feed.inbox.splice(0)) {
|
|
2756
|
+
const wake = parseWake(message);
|
|
2757
|
+
if (!wake) {
|
|
2758
|
+
feed.dirty = true;
|
|
2759
|
+
continue;
|
|
2760
|
+
}
|
|
2761
|
+
for (const [index, id, type, payload] of wake.entries) {
|
|
2762
|
+
const expected = (buffer.at(-1)?.index ?? last) + 1;
|
|
2763
|
+
if (index < expected) continue;
|
|
2764
|
+
if (index > expected) {
|
|
2765
|
+
feed.dirty = true;
|
|
2766
|
+
break;
|
|
2767
|
+
}
|
|
2768
|
+
buffer.push({
|
|
2769
|
+
id,
|
|
2770
|
+
type,
|
|
2771
|
+
payload,
|
|
2772
|
+
index,
|
|
2773
|
+
sessionId,
|
|
2774
|
+
createdAt: new Date(wake.createdAt)
|
|
2775
|
+
});
|
|
2776
|
+
}
|
|
2777
|
+
if (wake.frontier > (buffer.at(-1)?.index ?? last)) feed.dirty = true;
|
|
2778
|
+
}
|
|
2779
|
+
};
|
|
2780
|
+
const iterator = {
|
|
2781
|
+
async next() {
|
|
2782
|
+
for (;;) {
|
|
2783
|
+
if (closed) {
|
|
2784
|
+
release();
|
|
2785
|
+
return {
|
|
2786
|
+
value: void 0,
|
|
2787
|
+
done: true
|
|
2788
|
+
};
|
|
2789
|
+
}
|
|
2790
|
+
const row = buffer.shift();
|
|
2791
|
+
if (row) {
|
|
2792
|
+
let event;
|
|
2793
|
+
try {
|
|
2794
|
+
event = isSerializedEvent(row) ? parseSerializedEvent(row) : row;
|
|
2795
|
+
} catch (cause) {
|
|
2796
|
+
buffer = [];
|
|
2797
|
+
feed.dirty = true;
|
|
2798
|
+
release();
|
|
2799
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis stream read failed", { cause });
|
|
2800
|
+
}
|
|
2801
|
+
last = row.index;
|
|
2802
|
+
return {
|
|
2803
|
+
value: event,
|
|
2804
|
+
done: false
|
|
2805
|
+
};
|
|
2806
|
+
}
|
|
2807
|
+
if (severed) {
|
|
2808
|
+
release();
|
|
2809
|
+
throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
2810
|
+
}
|
|
2811
|
+
try {
|
|
2812
|
+
if (!subscribed) {
|
|
2813
|
+
subscribed = true;
|
|
2814
|
+
let set = feeds.get(channel);
|
|
2815
|
+
if (!set) {
|
|
2816
|
+
set = /* @__PURE__ */ new Set();
|
|
2817
|
+
feeds.set(channel, set);
|
|
2818
|
+
}
|
|
2819
|
+
set.add(feed);
|
|
2820
|
+
await acquireChannel(channel);
|
|
2821
|
+
if (closed || severed) continue;
|
|
2822
|
+
}
|
|
2823
|
+
if (options.polling?.()) {
|
|
2824
|
+
if (!poller) {
|
|
2825
|
+
poller = pollingStream((after) => core.readEvents(sessionId, after), { startAfter: last })[Symbol.asyncIterator]();
|
|
2826
|
+
pollingStreams.add(poller);
|
|
2827
|
+
stopActivity = streamActivities.get(poller)?.subscribe(activity.setIdle);
|
|
2828
|
+
}
|
|
2829
|
+
const result = await poller.next();
|
|
2830
|
+
if (!result.done) presenceSubscriptions.get(presenceChannel(sessionId))?.poll?.wake();
|
|
2831
|
+
if (severed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
2832
|
+
return result;
|
|
2833
|
+
}
|
|
2834
|
+
if (catchup) {
|
|
2835
|
+
const result = await nextRead(last);
|
|
2836
|
+
if (closed || severed) continue;
|
|
2837
|
+
if (!result.done) buffer = result.value;
|
|
2838
|
+
continue;
|
|
2839
|
+
}
|
|
2840
|
+
applyInbox();
|
|
2841
|
+
if (buffer.length > 0) continue;
|
|
2842
|
+
if (feed.dirty) {
|
|
2843
|
+
feed.dirty = false;
|
|
2844
|
+
if (core.streamEvents) {
|
|
2845
|
+
const result = await nextRead(last);
|
|
2846
|
+
if (closed || severed) continue;
|
|
2847
|
+
if (!result.done) buffer = result.value;
|
|
2848
|
+
continue;
|
|
2849
|
+
}
|
|
2850
|
+
buffer = await core.readFeedPage(sessionId, last);
|
|
2851
|
+
if (buffer.length >= 1e3) feed.dirty = true;
|
|
2852
|
+
continue;
|
|
2853
|
+
}
|
|
2854
|
+
const woken = new Promise((resolve) => {
|
|
2855
|
+
feed.wake = () => resolve("wake");
|
|
2856
|
+
});
|
|
2857
|
+
pending = defaultSleep(NOTIFY_TIMINGS.safetyReadMs);
|
|
2858
|
+
const outcome = await Promise.race([woken, pending.promise.then(() => "timer")]);
|
|
2859
|
+
pending.cancel();
|
|
2860
|
+
pending = null;
|
|
2861
|
+
if (outcome === "timer") feed.dirty = true;
|
|
2862
|
+
} catch (err) {
|
|
2863
|
+
release();
|
|
2864
|
+
if (closed) return {
|
|
2865
|
+
value: void 0,
|
|
2866
|
+
done: true
|
|
2867
|
+
};
|
|
2868
|
+
if (err instanceof A2Error) throw err;
|
|
2869
|
+
throw new A2Error("STORE_UNAVAILABLE", "redis store operation failed", { cause: err });
|
|
2870
|
+
} finally {
|
|
2871
|
+
feed.wake = null;
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
},
|
|
2875
|
+
async return() {
|
|
2876
|
+
closed = true;
|
|
2877
|
+
pending?.cancel();
|
|
2878
|
+
feed.wake?.();
|
|
2879
|
+
release();
|
|
2880
|
+
await stopRead();
|
|
2881
|
+
return {
|
|
2882
|
+
value: void 0,
|
|
2883
|
+
done: true
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
};
|
|
2887
|
+
streamActivities.set(iterator, activity);
|
|
2888
|
+
bufferedEvents.set(iterator, () => {
|
|
2889
|
+
if (closed) return void 0;
|
|
2890
|
+
const row = buffer.shift();
|
|
2891
|
+
if (row) last = row.index;
|
|
2892
|
+
return row;
|
|
2893
|
+
});
|
|
2894
|
+
return iterator;
|
|
2895
|
+
} };
|
|
2896
|
+
},
|
|
2897
|
+
async close() {
|
|
2898
|
+
severed = true;
|
|
2899
|
+
for (const subscription of presenceSubscriptions.values()) subscription.poll?.stop();
|
|
2900
|
+
presenceSubscriptions.clear();
|
|
2901
|
+
for (const poller of pollingStreams) poller.return?.();
|
|
2902
|
+
pollingStreams.clear();
|
|
2903
|
+
const reads = [];
|
|
2904
|
+
for (const set of feeds.values()) for (const feed of set) {
|
|
2905
|
+
feed.wake?.();
|
|
2906
|
+
reads.push(feed.stopRead());
|
|
2907
|
+
}
|
|
2908
|
+
await Promise.allSettled(reads);
|
|
2909
|
+
if (subscriber) (await subscriber.catch(() => null))?.disconnect();
|
|
2910
|
+
await options.closeClient();
|
|
2911
|
+
}
|
|
2912
|
+
};
|
|
2913
|
+
stateTailReaders.set(store, core.readTail);
|
|
2914
|
+
return store;
|
|
2915
|
+
}
|
|
2916
|
+
//#endregion
|
|
2917
|
+
export { createRedisNotifyStore as t };
|
|
2918
|
+
|
|
2919
|
+
//# sourceMappingURL=store-redis-notify-IiVdlYHc.js.map
|