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
package/src/server.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { decodeState, encodeState } from './reducer-runtime.ts'
|
|
|
2
2
|
// oxlint-disable no-await-in-loop -- scheduler advances after durable outcomes
|
|
3
3
|
/**
|
|
4
4
|
* experimental-a2/server — where a contract is implemented. `createServer({
|
|
5
|
-
* contract, store,
|
|
5
|
+
* contract, store, consumers, ... })` binds the vocabulary to storage and
|
|
6
6
|
* reactions; sessions, drains, claims, and scheduler all live here.
|
|
7
7
|
* Server-only by construction: this entry point is the only one that
|
|
8
8
|
* can reach a store backend, and its exports map fails loudly in browser
|
|
@@ -31,6 +31,8 @@ import {
|
|
|
31
31
|
markSchedulerSendFailure,
|
|
32
32
|
stateTailReaders,
|
|
33
33
|
nullProtoRecord,
|
|
34
|
+
runtimeConsumerFactory,
|
|
35
|
+
runtimeConsumers,
|
|
34
36
|
serverInternals,
|
|
35
37
|
type DrainOutcome,
|
|
36
38
|
type DrainResult,
|
|
@@ -50,9 +52,14 @@ import {
|
|
|
50
52
|
} from './stream-buffer.ts'
|
|
51
53
|
import type {
|
|
52
54
|
A2Store,
|
|
55
|
+
CompleteAttemptResult,
|
|
53
56
|
AppendEvent,
|
|
54
57
|
Event,
|
|
55
58
|
EventCause,
|
|
59
|
+
EventTarget,
|
|
60
|
+
HeadEvent,
|
|
61
|
+
HeadRef,
|
|
62
|
+
StoreConsumer,
|
|
56
63
|
StoreClaimAvailableResult,
|
|
57
64
|
StoreClearResult,
|
|
58
65
|
StoreReclaimResult,
|
|
@@ -104,8 +111,8 @@ import {
|
|
|
104
111
|
type A2Telemetry,
|
|
105
112
|
} from './telemetry.ts'
|
|
106
113
|
|
|
107
|
-
/** What every
|
|
108
|
-
export type
|
|
114
|
+
/** What every consumer's `run` receives. */
|
|
115
|
+
export type ConsumerContext<
|
|
109
116
|
D extends EventDefs,
|
|
110
117
|
K extends keyof D & string = keyof D & string,
|
|
111
118
|
P extends PresenceDefs = Record<never, never>,
|
|
@@ -115,21 +122,12 @@ export type HandlerContext<
|
|
|
115
122
|
event: ContractEvent<D, K>
|
|
116
123
|
/** Durable, 1-based dispatch ordinal for this event. */
|
|
117
124
|
attempt: number
|
|
118
|
-
/** This session, with
|
|
119
|
-
session: Session<D,
|
|
125
|
+
/** This session, with consumer-scoped idempotent append. */
|
|
126
|
+
session: Session<D, ConsumerAppend<D>, P, S>
|
|
120
127
|
/** Fires on `abortOn` events (cancellation slice); dormant otherwise. */
|
|
121
128
|
signal: AbortSignal
|
|
122
129
|
}
|
|
123
130
|
|
|
124
|
-
export type Handler<
|
|
125
|
-
D extends EventDefs,
|
|
126
|
-
K extends keyof D & string = keyof D & string,
|
|
127
|
-
P extends PresenceDefs = Record<never, never>,
|
|
128
|
-
S = never,
|
|
129
|
-
> = (
|
|
130
|
-
ctx: HandlerContext<D, K, P, S>,
|
|
131
|
-
) => Promise<void | AppendInput<D> | readonly AppendInput<D>[]>
|
|
132
|
-
|
|
133
131
|
export type LaneContext<
|
|
134
132
|
D extends EventDefs,
|
|
135
133
|
K extends keyof D & string = keyof D & string,
|
|
@@ -138,10 +136,15 @@ export type LaneContext<
|
|
|
138
136
|
event: Pick<ContractEvent<D, K>, 'type' | 'payload'> & { id?: string }
|
|
139
137
|
}
|
|
140
138
|
|
|
141
|
-
|
|
139
|
+
/**
|
|
140
|
+
* A consumer's lane: a string runs every event it takes in log order on one
|
|
141
|
+
* cursor; a function returns each event's lane, or null to run it
|
|
142
|
+
* concurrently.
|
|
143
|
+
*/
|
|
144
|
+
export type ConsumerLane<
|
|
142
145
|
D extends EventDefs,
|
|
143
146
|
K extends keyof D & string = keyof D & string,
|
|
144
|
-
> = string | ((context: LaneContext<D, K>) => string)
|
|
147
|
+
> = string | ((context: LaneContext<D, K>) => string | null)
|
|
145
148
|
|
|
146
149
|
export type SessionDispatch<D extends EventDefs> = (
|
|
147
150
|
...events: AppendInput<D>[]
|
|
@@ -152,7 +155,7 @@ export type SessionAppend<D extends EventDefs> = SessionDispatch<D> & {
|
|
|
152
155
|
dispatch: SessionDispatch<D>
|
|
153
156
|
}
|
|
154
157
|
|
|
155
|
-
export type
|
|
158
|
+
export type ConsumerAppend<D extends EventDefs> = (
|
|
156
159
|
name: string,
|
|
157
160
|
...events: AppendInput<D>[]
|
|
158
161
|
) => Promise<ContractEvent<D>[]>
|
|
@@ -191,7 +194,7 @@ export type SessionClearOptions = {
|
|
|
191
194
|
export type SessionClearResult = { cleared: boolean; index: number }
|
|
192
195
|
|
|
193
196
|
export type SessionRedriveOptions = {
|
|
194
|
-
/** `retry` runs the
|
|
197
|
+
/** `retry` runs the consumer again with a fresh failure budget; `skip` settles the event without running it. */
|
|
195
198
|
action: 'retry' | 'skip'
|
|
196
199
|
}
|
|
197
200
|
|
|
@@ -200,18 +203,36 @@ export type SessionRedriveResult = {
|
|
|
200
203
|
redriven: boolean
|
|
201
204
|
}
|
|
202
205
|
|
|
206
|
+
export type SessionConsumeOptions = {
|
|
207
|
+
/**
|
|
208
|
+
* Run until the consumer has handled every event it takes through this
|
|
209
|
+
* index. Default: the session's head when the call starts. An index past
|
|
210
|
+
* the head counts as the head.
|
|
211
|
+
*/
|
|
212
|
+
through?: number
|
|
213
|
+
/** Stops waiting: the call throws the signal's reason. */
|
|
214
|
+
signal?: AbortSignal
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export type SessionConsumeResult = {
|
|
218
|
+
/** The consumer has handled every event it takes through this index. */
|
|
219
|
+
index: number
|
|
220
|
+
}
|
|
221
|
+
|
|
203
222
|
/** A dead-lettered event, with what an operator needs to resolve it. */
|
|
204
223
|
export type DeadLetter<D extends EventDefs> = ContractEvent<D> & {
|
|
224
|
+
/** The consumer this event is dead for; it may be done for others. */
|
|
225
|
+
consumer: string
|
|
205
226
|
lane: string | null
|
|
206
227
|
/** Durable dispatch claims, including claims abandoned by hard kills. */
|
|
207
228
|
attemptCount: number
|
|
208
229
|
/**
|
|
209
230
|
* Failed attempts since the event was appended or last retried: caught
|
|
210
|
-
*
|
|
231
|
+
* consumer failures, and attempts that ended before they finished.
|
|
211
232
|
*/
|
|
212
233
|
failureCount: number
|
|
213
234
|
/**
|
|
214
|
-
* The last error
|
|
235
|
+
* The last error the consumer threw, stringified; null when every failed
|
|
215
236
|
* attempt ended before it finished.
|
|
216
237
|
*/
|
|
217
238
|
lastError: string | null
|
|
@@ -227,7 +248,7 @@ export type SessionDeadLetters<D extends EventDefs> = {
|
|
|
227
248
|
* an append. Any other event is left alone and reports `redriven: false`.
|
|
228
249
|
*/
|
|
229
250
|
redrive(
|
|
230
|
-
index: number,
|
|
251
|
+
target: { consumer: string; index: number },
|
|
231
252
|
options: SessionRedriveOptions,
|
|
232
253
|
): Promise<SessionRedriveResult>
|
|
233
254
|
}
|
|
@@ -283,6 +304,17 @@ export type Session<
|
|
|
283
304
|
*/
|
|
284
305
|
clear(options?: SessionClearOptions): Promise<SessionClearResult>
|
|
285
306
|
history(options?: { gte?: number; lte?: number }): Promise<ContractEvent<D>[]>
|
|
307
|
+
/**
|
|
308
|
+
* Run a consumer with a string lane on this session, here, until it has
|
|
309
|
+
* handled every event it takes through `through`. The server's own
|
|
310
|
+
* consumer shares its cursor with background work, so this waits for
|
|
311
|
+
* whichever invocation holds it. A consumer the server doesn't run is
|
|
312
|
+
* pulled only by `consume`, and keeps its place between calls.
|
|
313
|
+
*/
|
|
314
|
+
consume(
|
|
315
|
+
consumer: Consumer<D>,
|
|
316
|
+
options?: SessionConsumeOptions,
|
|
317
|
+
): Promise<SessionConsumeResult>
|
|
286
318
|
state: {
|
|
287
319
|
<T>(
|
|
288
320
|
reducer: Reducer<D, T, Record<never, never>>,
|
|
@@ -302,7 +334,7 @@ export type Session<
|
|
|
302
334
|
}
|
|
303
335
|
|
|
304
336
|
/**
|
|
305
|
-
* The public server shape scheduler
|
|
337
|
+
* The public server shape scheduler consumers accept. It stays structural so
|
|
306
338
|
* servers of any contract mix in one route; A2's private server internals add
|
|
307
339
|
* delayed-append delivery without exposing an untyped append method here.
|
|
308
340
|
*/
|
|
@@ -425,11 +457,11 @@ export async function deliverSchedulerClear(
|
|
|
425
457
|
}
|
|
426
458
|
|
|
427
459
|
/**
|
|
428
|
-
* Which events fire `ctx.signal` while a
|
|
460
|
+
* Which events fire `ctx.signal` while a consumer runs — the preemption
|
|
429
461
|
* channel for user cancellation. The array form matches by type; the
|
|
430
462
|
* object form takes per-type predicates for targeted cancellation
|
|
431
|
-
* (`(event, trigger) => event.payload.of === trigger.id`).
|
|
432
|
-
* without `abortOn` pay nothing. An aborted
|
|
463
|
+
* (`(event, trigger) => event.payload.of === trigger.id`). Consumers
|
|
464
|
+
* without `abortOn` pay nothing. An aborted consumer should catch and
|
|
433
465
|
* return normally; throwing means "retry me".
|
|
434
466
|
*/
|
|
435
467
|
export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
|
|
@@ -444,36 +476,209 @@ export type AbortSpec<D extends EventDefs, K extends keyof D & string> =
|
|
|
444
476
|
) => boolean)
|
|
445
477
|
}
|
|
446
478
|
|
|
447
|
-
|
|
479
|
+
/** What a consumer's `run` returns: nothing, one event, or a batch. */
|
|
480
|
+
export type ConsumerResult<D extends EventDefs> = Promise<
|
|
481
|
+
void | AppendInput<D> | readonly AppendInput<D>[]
|
|
482
|
+
>
|
|
483
|
+
|
|
484
|
+
/** How `createServer` runs a consumer. Built by `consumer().run()`. */
|
|
485
|
+
export type ConsumerSpec<D extends EventDefs> = {
|
|
486
|
+
readonly types: readonly (keyof D & string)[] | null
|
|
487
|
+
readonly lane: ConsumerLane<D> | null
|
|
488
|
+
readonly backfill: boolean
|
|
489
|
+
readonly abortOn: AbortSpec<D, keyof D & string> | null
|
|
490
|
+
readonly run: (ctx: ConsumerContext<D>) => ConsumerResult<D>
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** A named reader of a session's log with its own durable cursors. */
|
|
494
|
+
export type Consumer<D extends EventDefs> = {
|
|
495
|
+
readonly name: string
|
|
496
|
+
readonly contract: { readonly name: string }
|
|
497
|
+
readonly spec: ConsumerSpec<D>
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
export type ConsumerOptions<
|
|
448
501
|
D extends EventDefs,
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
> =
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
502
|
+
P extends PresenceDefs,
|
|
503
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
504
|
+
K extends keyof D & string,
|
|
505
|
+
> = {
|
|
506
|
+
/** The contract whose events it reads. */
|
|
507
|
+
contract: Contract<D, P, R>
|
|
508
|
+
/** Its identity, unique per server. A new name is a new consumer. */
|
|
509
|
+
name: string
|
|
510
|
+
/** The event types it runs on; default every type. */
|
|
511
|
+
types?: readonly K[]
|
|
512
|
+
/** Omitted: concurrent. A string: one ordered lane. A function: per event. */
|
|
513
|
+
lane?: ConsumerLane<D, K>
|
|
514
|
+
/** A new consumer starts at the beginning of the log. Needs a string lane. */
|
|
515
|
+
backfill?: boolean
|
|
516
|
+
abortOn?: AbortSpec<D, K>
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* A consumer written in `createServer({ consumers })`: `consumer()`'s
|
|
521
|
+
* options without the contract, which is the server's, and its `run`.
|
|
522
|
+
*/
|
|
523
|
+
export type InlineConsumer<
|
|
524
|
+
D extends EventDefs,
|
|
525
|
+
P extends PresenceDefs,
|
|
526
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
527
|
+
K extends keyof D & string,
|
|
528
|
+
> = Omit<ConsumerOptions<D, P, R, K>, 'contract'> & {
|
|
529
|
+
run: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/** The types an inline consumer takes: the ones it lists, or every type. */
|
|
533
|
+
type InlineTypes<D extends EventDefs, X> = unknown extends X
|
|
534
|
+
? keyof D & string
|
|
535
|
+
: Extract<X, keyof D & string>
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* `createServer`'s consumers: each a `consumer()` or written inline. `C` is
|
|
539
|
+
* inferred per entry, so an inline `run` sees only the types it lists.
|
|
540
|
+
*/
|
|
541
|
+
export type ServerConsumers<
|
|
542
|
+
D extends EventDefs,
|
|
543
|
+
P extends PresenceDefs,
|
|
544
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
545
|
+
C extends readonly unknown[],
|
|
546
|
+
> = {
|
|
547
|
+
readonly [I in keyof C]:
|
|
548
|
+
InlineConsumer<D, P, R, InlineTypes<D, C[I]>> | Extract<C[I], Consumer<D>>
|
|
549
|
+
}
|
|
460
550
|
|
|
461
|
-
|
|
551
|
+
const CONSUMER_NAME = /^[a-z0-9][a-z0-9._:-]{0,127}$/i
|
|
462
552
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
553
|
+
/** Define a consumer: a named reader of each session's log. */
|
|
554
|
+
export function consumer<
|
|
555
|
+
D extends EventDefs,
|
|
556
|
+
P extends PresenceDefs,
|
|
557
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
558
|
+
const K extends keyof D & string = keyof D & string,
|
|
559
|
+
>(
|
|
560
|
+
options: ConsumerOptions<D, P, R, K>,
|
|
561
|
+
): {
|
|
562
|
+
run(
|
|
563
|
+
fn: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>,
|
|
564
|
+
): Consumer<D>
|
|
565
|
+
} {
|
|
566
|
+
return defineConsumer(options, false)
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* `consumer()`, and for A2's own runtimes (`runtime`) a name under `a2.` and
|
|
571
|
+
* child ids without the name: their types are disjoint, so the event alone
|
|
572
|
+
* keys them, and regrouping A2's consumers never changes an id.
|
|
573
|
+
*/
|
|
574
|
+
function defineConsumer<
|
|
575
|
+
D extends EventDefs,
|
|
576
|
+
P extends PresenceDefs,
|
|
577
|
+
R extends RegisteredReducer<D, P> | undefined,
|
|
578
|
+
const K extends keyof D & string = keyof D & string,
|
|
579
|
+
>(
|
|
580
|
+
options: ConsumerOptions<D, P, R, K>,
|
|
581
|
+
runtime: boolean,
|
|
582
|
+
): {
|
|
583
|
+
run(
|
|
584
|
+
fn: (ctx: ConsumerContext<D, K, P, PublicState<R>>) => ConsumerResult<D>,
|
|
585
|
+
): Consumer<D>
|
|
586
|
+
} {
|
|
587
|
+
const contract = options?.contract
|
|
588
|
+
if (!contract || typeof contract.name !== 'string') {
|
|
589
|
+
throw new TypeError('consumer expects options with a contract')
|
|
590
|
+
}
|
|
591
|
+
const consumerName = options.name
|
|
592
|
+
if (typeof consumerName !== 'string' || !CONSUMER_NAME.test(consumerName)) {
|
|
593
|
+
throw new TypeError(
|
|
594
|
+
'consumer name must be 1-128 letters, digits, and . _ : - characters',
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
if (consumerName.startsWith('a2.') !== runtime) {
|
|
598
|
+
throw new TypeError(
|
|
599
|
+
runtime
|
|
600
|
+
? `A2's own consumers are named under 'a2.'`
|
|
601
|
+
: `consumer names starting with 'a2.' are reserved`,
|
|
602
|
+
)
|
|
603
|
+
}
|
|
604
|
+
const types = options.types === undefined ? null : [...options.types]
|
|
605
|
+
if (types?.length === 0) {
|
|
606
|
+
throw new TypeError(
|
|
607
|
+
`consumer '${consumerName}' lists no types; leave types out to take every type`,
|
|
608
|
+
)
|
|
609
|
+
}
|
|
610
|
+
for (const type of types ?? []) {
|
|
611
|
+
if (!Object.hasOwn(contract.events, type)) {
|
|
612
|
+
throw new TypeError(
|
|
613
|
+
`contract '${contract.name}' has no event type '${String(type)}'`,
|
|
614
|
+
)
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const lane = options.lane ?? null
|
|
618
|
+
if (
|
|
619
|
+
lane !== null &&
|
|
620
|
+
typeof lane !== 'function' &&
|
|
621
|
+
(typeof lane !== 'string' || lane.length === 0)
|
|
622
|
+
) {
|
|
623
|
+
throw new TypeError(
|
|
624
|
+
`lane for consumer '${consumerName}' must be a non-empty string or a function`,
|
|
625
|
+
)
|
|
626
|
+
}
|
|
627
|
+
const backfill = options.backfill ?? false
|
|
628
|
+
if (backfill && typeof lane !== 'string') {
|
|
629
|
+
throw new TypeError(
|
|
630
|
+
`consumer '${consumerName}' backfills, so its lane must be a string`,
|
|
631
|
+
)
|
|
632
|
+
}
|
|
633
|
+
// Cancellation is for work in progress. Checking it while replaying
|
|
634
|
+
// history would read the rest of the log for every event replayed.
|
|
635
|
+
if (backfill && options.abortOn !== undefined) {
|
|
636
|
+
throw new TypeError(
|
|
637
|
+
`consumer '${consumerName}' backfills, so it can't use abortOn`,
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
return {
|
|
641
|
+
run(fn) {
|
|
642
|
+
if (typeof fn !== 'function') {
|
|
643
|
+
throw new TypeError(`consumer '${consumerName}' needs a run function`)
|
|
644
|
+
}
|
|
645
|
+
const defined: Consumer<D> = {
|
|
646
|
+
name: consumerName,
|
|
647
|
+
contract: { name: contract.name },
|
|
648
|
+
spec: {
|
|
649
|
+
types,
|
|
650
|
+
lane: lane as ConsumerLane<D> | null,
|
|
651
|
+
backfill,
|
|
652
|
+
abortOn: (options.abortOn ?? null) as AbortSpec<
|
|
653
|
+
D,
|
|
654
|
+
keyof D & string
|
|
655
|
+
> | null,
|
|
656
|
+
run: fn as unknown as (ctx: ConsumerContext<D>) => ConsumerResult<D>,
|
|
657
|
+
},
|
|
658
|
+
}
|
|
659
|
+
if (runtime) runtimeConsumers.add(defined)
|
|
660
|
+
return defined
|
|
661
|
+
},
|
|
662
|
+
}
|
|
470
663
|
}
|
|
664
|
+
runtimeConsumerFactory.define = (options) =>
|
|
665
|
+
defineConsumer(options as Parameters<typeof consumer>[0], true)
|
|
666
|
+
|
|
667
|
+
/** A registered consumer as the store needs to know it. */
|
|
668
|
+
const storeConsumerOf = (registered: {
|
|
669
|
+
name: string
|
|
670
|
+
log: StoreConsumer['log']
|
|
671
|
+
}): StoreConsumer =>
|
|
672
|
+
registered.log
|
|
673
|
+
? { name: registered.name, log: registered.log }
|
|
674
|
+
: { name: registered.name }
|
|
471
675
|
|
|
472
676
|
export type ServerOptions<
|
|
473
677
|
D extends EventDefs,
|
|
474
678
|
P extends PresenceDefs = Record<never, never>,
|
|
475
679
|
R extends RegisteredReducer<D, P> | undefined =
|
|
476
680
|
RegisteredReducer<D, P> | undefined,
|
|
681
|
+
C extends readonly unknown[] = readonly Consumer<D>[],
|
|
477
682
|
> = {
|
|
478
683
|
/** The contract this server implements (see `a2.contract`). */
|
|
479
684
|
contract: Contract<D, P, R>
|
|
@@ -495,16 +700,39 @@ export type ServerOptions<
|
|
|
495
700
|
*/
|
|
496
701
|
presence?: { ttlMs?: number }
|
|
497
702
|
/**
|
|
498
|
-
* The
|
|
499
|
-
*
|
|
500
|
-
*
|
|
501
|
-
* a duplicate key under spread silently last-wins).
|
|
703
|
+
* The consumers, each a named reader of every session's log with its own
|
|
704
|
+
* cursors: a `consumer()`, or its options and `run` written inline, on this
|
|
705
|
+
* contract. Names are unique; a duplicate throws.
|
|
502
706
|
*/
|
|
503
|
-
|
|
707
|
+
consumers?: ServerConsumers<D, P, R, C> | readonly Consumer<D>[]
|
|
504
708
|
}
|
|
505
709
|
|
|
506
710
|
const MAX_FAILURES = 10
|
|
507
711
|
|
|
712
|
+
/** How long `consume` waits before looking again at a head it can't run. */
|
|
713
|
+
const CONSUME_WAIT_MIN_MS = 25
|
|
714
|
+
const CONSUME_WAIT_MAX_MS = 1_000
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* `task`, or a rejection with `signal`'s reason once it aborts. The task runs
|
|
718
|
+
* on either way, and its own failure is caught, so nothing goes unhandled.
|
|
719
|
+
*/
|
|
720
|
+
const untilAborted = <T>(
|
|
721
|
+
task: Promise<T>,
|
|
722
|
+
signal: AbortSignal | undefined,
|
|
723
|
+
): Promise<T> => {
|
|
724
|
+
if (!signal) return task
|
|
725
|
+
task.catch(() => {})
|
|
726
|
+
if (signal.aborted) return Promise.reject(signal.reason)
|
|
727
|
+
return new Promise<T>((resolve, reject) => {
|
|
728
|
+
const abort = (): void => reject(signal.reason)
|
|
729
|
+
signal.addEventListener('abort', abort, { once: true })
|
|
730
|
+
task.then(resolve, reject).finally(() => {
|
|
731
|
+
signal.removeEventListener('abort', abort)
|
|
732
|
+
})
|
|
733
|
+
})
|
|
734
|
+
}
|
|
735
|
+
|
|
508
736
|
/** How long an event waits after its `failureCount`th failed attempt. */
|
|
509
737
|
const failureRetryDelayMs = (failureCount: number): number =>
|
|
510
738
|
Math.min(
|
|
@@ -512,7 +740,7 @@ const failureRetryDelayMs = (failureCount: number): number =>
|
|
|
512
740
|
DRAIN_TIMINGS.failureRetryMaxMs,
|
|
513
741
|
)
|
|
514
742
|
|
|
515
|
-
/** Abort a lost attempt's
|
|
743
|
+
/** Abort a lost attempt's consumer and release its drain slot. */
|
|
516
744
|
function endSuperseded(
|
|
517
745
|
entry: {
|
|
518
746
|
supersede: AbortController
|
|
@@ -724,30 +952,47 @@ function foldPresenceRows(
|
|
|
724
952
|
|
|
725
953
|
type SnapshotWrites = Map<
|
|
726
954
|
number,
|
|
727
|
-
{ state: unknown;
|
|
955
|
+
{ state: unknown; pins: Map<string, HeadRef> }
|
|
728
956
|
>
|
|
729
957
|
|
|
958
|
+
/** One cursor head, as a map key. */
|
|
959
|
+
const headKey = (ref: HeadRef): string =>
|
|
960
|
+
JSON.stringify([ref.consumer, ref.lane, ref.index])
|
|
961
|
+
|
|
962
|
+
const headRefOf = (head: HeadEvent): HeadRef => ({
|
|
963
|
+
consumer: head.consumer,
|
|
964
|
+
lane: head.lane,
|
|
965
|
+
index: head.index,
|
|
966
|
+
})
|
|
967
|
+
|
|
730
968
|
const addSnapshotWrite = (
|
|
731
969
|
writes: SnapshotWrites,
|
|
732
970
|
index: number,
|
|
733
971
|
state: unknown,
|
|
734
|
-
|
|
972
|
+
pin?: HeadRef,
|
|
735
973
|
): void => {
|
|
736
974
|
let write = writes.get(index)
|
|
737
975
|
if (!write) {
|
|
738
|
-
write = { state: cloneInitial(state),
|
|
976
|
+
write = { state: cloneInitial(state), pins: new Map() }
|
|
739
977
|
writes.set(index, write)
|
|
740
978
|
}
|
|
741
|
-
if (
|
|
979
|
+
if (pin !== undefined) write.pins.set(headKey(pin), pin)
|
|
742
980
|
}
|
|
743
981
|
|
|
982
|
+
/** Whether a registered consumer runs events of this type. */
|
|
983
|
+
const takes = (
|
|
984
|
+
registered: { readonly types: ReadonlySet<string> | null },
|
|
985
|
+
type: string,
|
|
986
|
+
): boolean => registered.types === null || registered.types.has(type)
|
|
987
|
+
|
|
744
988
|
/** Implement a contract: bind its vocabulary to storage and reactions. */
|
|
745
989
|
export function createServer<
|
|
746
990
|
D extends EventDefs,
|
|
747
991
|
P extends PresenceDefs = Record<never, never>,
|
|
748
992
|
R extends RegisteredReducer<D, P> | undefined =
|
|
749
993
|
RegisteredReducer<D, P> | undefined,
|
|
750
|
-
|
|
994
|
+
const C extends readonly unknown[] = readonly Consumer<D>[],
|
|
995
|
+
>(options: ServerOptions<D, P, R, C>): A2Server<D, P, R> {
|
|
751
996
|
const serverContract = options?.contract
|
|
752
997
|
if (
|
|
753
998
|
serverContract === null ||
|
|
@@ -759,6 +1004,12 @@ export function createServer<
|
|
|
759
1004
|
'createServer expects options with a contract (see a2.contract)',
|
|
760
1005
|
)
|
|
761
1006
|
}
|
|
1007
|
+
// Ignored, a handler table would run nothing and say nothing.
|
|
1008
|
+
if (Object.hasOwn(options, 'handlers')) {
|
|
1009
|
+
throw new TypeError(
|
|
1010
|
+
'createServer no longer takes handlers: write each as a consumer in `consumers` (see https://www.a2.dev/upgrades/0-21)',
|
|
1011
|
+
)
|
|
1012
|
+
}
|
|
762
1013
|
const name = serverContract.name
|
|
763
1014
|
const defs = serverContract.events
|
|
764
1015
|
const presenceDefs: Readonly<PresenceDefs> = serverContract.presence
|
|
@@ -847,24 +1098,11 @@ export function createServer<
|
|
|
847
1098
|
context: { attempt: number },
|
|
848
1099
|
) => boolean)
|
|
849
1100
|
>
|
|
850
|
-
const
|
|
851
|
-
string,
|
|
852
|
-
|
|
853
|
-
>()
|
|
854
|
-
for (const [type, entry] of Object.entries(options.handlers ?? {})) {
|
|
855
|
-
if (entry === undefined) continue
|
|
856
|
-
if (!Object.hasOwn(defs, type)) {
|
|
857
|
-
throw new TypeError(
|
|
858
|
-
`contract '${name}' has no event type '${type}' — cannot register a handler for it`,
|
|
859
|
-
)
|
|
860
|
-
}
|
|
861
|
-
const callback = typeof entry === 'function' ? entry : entry?.handler
|
|
862
|
-
if (typeof callback !== 'function') {
|
|
863
|
-
throw new TypeError(`handler for '${type}' must be a function`)
|
|
864
|
-
}
|
|
1101
|
+
const abortMatcherOf = (
|
|
1102
|
+
spec: AbortSpec<D, keyof D & string> | null | undefined,
|
|
1103
|
+
): AbortMatcher | null => {
|
|
865
1104
|
let abortOn: AbortMatcher | null = null
|
|
866
|
-
|
|
867
|
-
if (spec !== undefined) {
|
|
1105
|
+
if (spec !== undefined && spec !== null) {
|
|
868
1106
|
abortOn = new Map()
|
|
869
1107
|
const pairs = Array.isArray(spec)
|
|
870
1108
|
? spec.map((abortType) => [abortType, true as const] as const)
|
|
@@ -892,22 +1130,117 @@ export function createServer<
|
|
|
892
1130
|
}
|
|
893
1131
|
if (abortOn.size === 0) abortOn = null
|
|
894
1132
|
}
|
|
895
|
-
|
|
1133
|
+
return abortOn
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
/** A consumer as this server runs it. */
|
|
1137
|
+
type RegisteredConsumer = {
|
|
1138
|
+
name: string
|
|
1139
|
+
types: ReadonlySet<string> | null
|
|
1140
|
+
/** Lane-function and concurrent consumers; log consumers resolve none. */
|
|
1141
|
+
laneOf: ((sessionId: string, event: AppendEvent) => string | null) | null
|
|
1142
|
+
log: StoreConsumer['log']
|
|
1143
|
+
abortOn: (type: string) => AbortMatcher | null
|
|
1144
|
+
run: (ctx: ConsumerContext<D>) => Promise<unknown>
|
|
1145
|
+
/** A2's own consumers: child and timer ids leave out the name. */
|
|
1146
|
+
anonymousIds: boolean
|
|
1147
|
+
}
|
|
1148
|
+
const laneContext = (sessionId: string, event: AppendEvent): LaneContext<D> =>
|
|
1149
|
+
({
|
|
1150
|
+
sessionId,
|
|
1151
|
+
event: {
|
|
1152
|
+
type: event.type,
|
|
1153
|
+
payload: structuredClone(event.payload),
|
|
1154
|
+
...(event.id === undefined ? {} : { id: event.id }),
|
|
1155
|
+
},
|
|
1156
|
+
}) as LaneContext<D>
|
|
1157
|
+
const registeredConsumers = new Map<string, RegisteredConsumer>()
|
|
1158
|
+
/** A consumer definition as the server runs it. */
|
|
1159
|
+
const registrationOf = (defined: Consumer<D>): RegisteredConsumer => {
|
|
1160
|
+
const { types, lane, backfill, abortOn, run } = defined.spec
|
|
1161
|
+
const matcher = abortMatcherOf(abortOn)
|
|
1162
|
+
return {
|
|
1163
|
+
name: defined.name,
|
|
1164
|
+
types: types === null ? null : new Set(types),
|
|
1165
|
+
laneOf:
|
|
1166
|
+
typeof lane === 'string'
|
|
1167
|
+
? null
|
|
1168
|
+
: lane === null
|
|
1169
|
+
? () => null
|
|
1170
|
+
: (sessionId, event) => {
|
|
1171
|
+
const resolved = lane(laneContext(sessionId, event))
|
|
1172
|
+
if (
|
|
1173
|
+
resolved !== null &&
|
|
1174
|
+
(typeof resolved !== 'string' || resolved.length === 0)
|
|
1175
|
+
) {
|
|
1176
|
+
throw new TypeError(
|
|
1177
|
+
`lane for consumer '${defined.name}' must resolve to a non-empty string or null`,
|
|
1178
|
+
)
|
|
1179
|
+
}
|
|
1180
|
+
return resolved
|
|
1181
|
+
},
|
|
1182
|
+
log:
|
|
1183
|
+
typeof lane === 'string'
|
|
1184
|
+
? { lane, types: types === null ? null : [...types], backfill }
|
|
1185
|
+
: undefined,
|
|
1186
|
+
abortOn: () => matcher,
|
|
1187
|
+
run: run as (ctx: ConsumerContext<D>) => Promise<unknown>,
|
|
1188
|
+
anonymousIds: runtimeConsumers.has(defined),
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
/** The definitions the server runs, so `consume` can tell its own from another. */
|
|
1192
|
+
const definitions = new Map<string, Consumer<D>>()
|
|
1193
|
+
/** A consumer written inline is `consumer()`'s options and `run`, on this contract. */
|
|
1194
|
+
const definitionOf = (entry: unknown): Consumer<D> => {
|
|
896
1195
|
if (
|
|
897
|
-
|
|
898
|
-
typeof
|
|
899
|
-
|
|
1196
|
+
entry === null ||
|
|
1197
|
+
typeof entry !== 'object' ||
|
|
1198
|
+
'spec' in entry ||
|
|
1199
|
+
!('run' in entry)
|
|
900
1200
|
) {
|
|
1201
|
+
return entry as Consumer<D>
|
|
1202
|
+
}
|
|
1203
|
+
const { run, ...rest } = entry as InlineConsumer<D, P, R, keyof D & string>
|
|
1204
|
+
return consumer({ ...rest, contract: serverContract }).run(run)
|
|
1205
|
+
}
|
|
1206
|
+
for (const entry of (options.consumers ?? []) as readonly unknown[]) {
|
|
1207
|
+
const defined = definitionOf(entry)
|
|
1208
|
+
if (defined?.contract?.name !== name || !defined.spec) {
|
|
901
1209
|
throw new TypeError(
|
|
902
|
-
`
|
|
1210
|
+
`createServer for '${name}' got a consumer of another contract`,
|
|
903
1211
|
)
|
|
904
1212
|
}
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1213
|
+
if (registeredConsumers.has(defined.name)) {
|
|
1214
|
+
throw new TypeError(`two consumers are named '${defined.name}'`)
|
|
1215
|
+
}
|
|
1216
|
+
registeredConsumers.set(defined.name, registrationOf(defined))
|
|
1217
|
+
definitions.set(defined.name, defined)
|
|
1218
|
+
}
|
|
1219
|
+
// Runtime consumers leave their name out of child ids, so no event may
|
|
1220
|
+
// start two of them: their first returned events would share an id.
|
|
1221
|
+
const anonymousTypes = new Map<string, string>()
|
|
1222
|
+
for (const registered of registeredConsumers.values()) {
|
|
1223
|
+
if (!registered.anonymousIds) continue
|
|
1224
|
+
for (const type of registered.types ?? Object.keys(defs)) {
|
|
1225
|
+
const other = anonymousTypes.get(type)
|
|
1226
|
+
if (other !== undefined) {
|
|
1227
|
+
throw new TypeError(
|
|
1228
|
+
`A2's consumers '${other}' and '${registered.name}' both take '${type}'`,
|
|
1229
|
+
)
|
|
1230
|
+
}
|
|
1231
|
+
anonymousTypes.set(type, registered.name)
|
|
1232
|
+
}
|
|
910
1233
|
}
|
|
1234
|
+
/** The consumers this server serves, as the store needs to know them. */
|
|
1235
|
+
const storeConsumers: StoreConsumer[] = [...registeredConsumers.values()].map(
|
|
1236
|
+
storeConsumerOf,
|
|
1237
|
+
)
|
|
1238
|
+
/** Whether an event starts cursor work for any consumer. */
|
|
1239
|
+
const startsWork = (event: AppendEvent): boolean =>
|
|
1240
|
+
(event.targets?.length ?? 0) > 0 ||
|
|
1241
|
+
[...registeredConsumers.values()].some(
|
|
1242
|
+
(registered) => registered.log && takes(registered, event.type),
|
|
1243
|
+
)
|
|
911
1244
|
|
|
912
1245
|
const prefix = `${name}${NS}`
|
|
913
1246
|
const nsId = (sessionId: string): string => `${prefix}${sessionId}`
|
|
@@ -1004,7 +1337,7 @@ export function createServer<
|
|
|
1004
1337
|
const signal = createDrainSignal()
|
|
1005
1338
|
const p = new Promise<void>((resolve) => {
|
|
1006
1339
|
queueMicrotask(() => {
|
|
1007
|
-
void drainSession(sessionId, watchdogDueAt, signal)
|
|
1340
|
+
void drainSession(sessionId, { watchdogDueAt, signal })
|
|
1008
1341
|
.then(() => resolve())
|
|
1009
1342
|
.catch(() => resolve())
|
|
1010
1343
|
})
|
|
@@ -1047,28 +1380,15 @@ export function createServer<
|
|
|
1047
1380
|
const validated: AppendEvent = { type: e.type, payload: result.value }
|
|
1048
1381
|
if (e.id !== undefined) validated.id = e.id
|
|
1049
1382
|
if (!resolveDispatchMetadata) return validated
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
sessionId,
|
|
1058
|
-
event: {
|
|
1059
|
-
type: e.type,
|
|
1060
|
-
payload: structuredClone(result.value),
|
|
1061
|
-
...(e.id === undefined ? {} : { id: e.id }),
|
|
1062
|
-
} as never,
|
|
1063
|
-
})
|
|
1064
|
-
: registration.lane
|
|
1065
|
-
if (typeof lane !== 'string' || lane.length === 0) {
|
|
1066
|
-
throw new TypeError(
|
|
1067
|
-
`lane for '${e.type}' must resolve to a non-empty string`,
|
|
1068
|
-
)
|
|
1069
|
-
}
|
|
1070
|
-
validated.lane = lane
|
|
1383
|
+
const targets: EventTarget[] = []
|
|
1384
|
+
for (const registered of registeredConsumers.values()) {
|
|
1385
|
+
if (!registered.laneOf || !takes(registered, e.type)) continue
|
|
1386
|
+
targets.push({
|
|
1387
|
+
consumer: registered.name,
|
|
1388
|
+
lane: registered.laneOf(sessionId, validated),
|
|
1389
|
+
})
|
|
1071
1390
|
}
|
|
1391
|
+
if (targets.length > 0) validated.targets = targets
|
|
1072
1392
|
return validated
|
|
1073
1393
|
})
|
|
1074
1394
|
}
|
|
@@ -1081,7 +1401,7 @@ export function createServer<
|
|
|
1081
1401
|
const appendCore = (
|
|
1082
1402
|
sessionId: string,
|
|
1083
1403
|
events: AppendInput<D>[],
|
|
1084
|
-
source: 'external' | '
|
|
1404
|
+
source: 'external' | 'consumer',
|
|
1085
1405
|
mode: 'inline' | 'dispatch',
|
|
1086
1406
|
onAppended: (rows: ContractEvent<D>[], watchdogDueAt?: number) => void,
|
|
1087
1407
|
cause?: EventCause,
|
|
@@ -1118,20 +1438,16 @@ export function createServer<
|
|
|
1118
1438
|
let rows: StoredEvent[]
|
|
1119
1439
|
let shouldHealSession: boolean
|
|
1120
1440
|
try {
|
|
1121
|
-
const result = await store.append(
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
)
|
|
1441
|
+
const result = await store.append(nsId(sessionId), validated, {
|
|
1442
|
+
consumers: storeConsumers,
|
|
1443
|
+
...(originIndex === undefined ? {} : { originIndex }),
|
|
1444
|
+
})
|
|
1126
1445
|
rows = result.events
|
|
1127
|
-
// A
|
|
1128
|
-
// drain holding its trigger is already running.
|
|
1446
|
+
// A consumer appending events no consumer takes adds no work, and
|
|
1447
|
+
// the drain holding its trigger is already running.
|
|
1129
1448
|
shouldHealSession =
|
|
1130
1449
|
result.hasPending &&
|
|
1131
|
-
!(
|
|
1132
|
-
source === 'handler' &&
|
|
1133
|
-
validated.every((event) => event.settled === true)
|
|
1134
|
-
)
|
|
1450
|
+
!(source === 'consumer' && !validated.some(startsWork))
|
|
1135
1451
|
} catch (err) {
|
|
1136
1452
|
throw asStoreUnavailable(err)
|
|
1137
1453
|
}
|
|
@@ -1242,8 +1558,8 @@ export function createServer<
|
|
|
1242
1558
|
throughIndex?: number
|
|
1243
1559
|
/** The id of the event at `throughIndex`, when the reader knows it. */
|
|
1244
1560
|
throughEventId?: string
|
|
1245
|
-
|
|
1246
|
-
/** A
|
|
1561
|
+
pin?: HeadRef
|
|
1562
|
+
/** A consumer read: rejected once this trigger index has been cleared. */
|
|
1247
1563
|
triggerIndex?: number
|
|
1248
1564
|
span: A2SpanHandle
|
|
1249
1565
|
resolve: (result: StateResult) => void
|
|
@@ -1388,9 +1704,7 @@ export function createServer<
|
|
|
1388
1704
|
index,
|
|
1389
1705
|
state: encodeState({ reducer, state: write.state }),
|
|
1390
1706
|
}
|
|
1391
|
-
if (write.
|
|
1392
|
-
snapshot.pinEventIndexes = [...write.pinEventIndexes]
|
|
1393
|
-
}
|
|
1707
|
+
if (write.pins.size > 0) snapshot.pins = [...write.pins.values()]
|
|
1394
1708
|
return snapshot
|
|
1395
1709
|
})
|
|
1396
1710
|
if (snapshots.length > 0) {
|
|
@@ -1503,7 +1817,7 @@ export function createServer<
|
|
|
1503
1817
|
/**
|
|
1504
1818
|
* Serves a plan from the cached state when every read is at or past the
|
|
1505
1819
|
* cached index. A read bounded at the cached index by an event whose id
|
|
1506
|
-
* matches the cached one (a
|
|
1820
|
+
* matches the cached one (a consumer's own trigger) needs no store request;
|
|
1507
1821
|
* any other read fetches the tail from the cached event onward and checks
|
|
1508
1822
|
* that event's id. Returns false when the store must answer instead.
|
|
1509
1823
|
*/
|
|
@@ -1560,8 +1874,8 @@ export function createServer<
|
|
|
1560
1874
|
clearIndex: cached.clearIndex,
|
|
1561
1875
|
snapshot: 'memory',
|
|
1562
1876
|
visit: (entry, state, index) => {
|
|
1563
|
-
if (entry.
|
|
1564
|
-
addSnapshotWrite(writes, index, state, entry.
|
|
1877
|
+
if (entry.pin !== undefined) {
|
|
1878
|
+
addSnapshotWrite(writes, index, state, entry.pin)
|
|
1565
1879
|
}
|
|
1566
1880
|
},
|
|
1567
1881
|
})
|
|
@@ -1571,7 +1885,7 @@ export function createServer<
|
|
|
1571
1885
|
if (rewritesHead && !writes.has(folded.index)) {
|
|
1572
1886
|
writes.set(folded.index, {
|
|
1573
1887
|
state: folded.state,
|
|
1574
|
-
|
|
1888
|
+
pins: new Map(),
|
|
1575
1889
|
})
|
|
1576
1890
|
}
|
|
1577
1891
|
const snapshots = writeSnapshots({
|
|
@@ -1672,14 +1986,13 @@ export function createServer<
|
|
|
1672
1986
|
snapshot: snapshotOutcome,
|
|
1673
1987
|
visit: (entry, entryState, entryIndex, count) => {
|
|
1674
1988
|
if (entryIndex === 0) return
|
|
1675
|
-
if (count === 0 && entry.
|
|
1989
|
+
if (count === 0 && entry.pin === undefined) return
|
|
1676
1990
|
const advancesHead =
|
|
1677
1991
|
stateRead.headIndex === null || entryIndex > stateRead.headIndex
|
|
1678
1992
|
const repairsHead =
|
|
1679
1993
|
snapshotOutcome === 'rejected' && entryIndex === stateRead.headIndex
|
|
1680
|
-
if (!advancesHead && !repairsHead && entry.
|
|
1681
|
-
|
|
1682
|
-
addSnapshotWrite(writes, entryIndex, entryState, entry.pinEventIndex)
|
|
1994
|
+
if (!advancesHead && !repairsHead && entry.pin === undefined) return
|
|
1995
|
+
addSnapshotWrite(writes, entryIndex, entryState, entry.pin)
|
|
1683
1996
|
},
|
|
1684
1997
|
})
|
|
1685
1998
|
const snapshots = writeSnapshots({
|
|
@@ -1775,7 +2088,7 @@ export function createServer<
|
|
|
1775
2088
|
type StateReadBounds = {
|
|
1776
2089
|
throughIndex?: number
|
|
1777
2090
|
throughEventId?: string
|
|
1778
|
-
|
|
2091
|
+
pin?: HeadRef
|
|
1779
2092
|
triggerIndex?: number
|
|
1780
2093
|
}
|
|
1781
2094
|
|
|
@@ -1794,7 +2107,7 @@ export function createServer<
|
|
|
1794
2107
|
pendingStateReads.set(reducerName, opened)
|
|
1795
2108
|
// One microtask is the whole batching window — deliberately NOT
|
|
1796
2109
|
// DataLoader's post-promise-job nextTick, which would defer
|
|
1797
|
-
//
|
|
2110
|
+
// consumer-internal reads past the entire microtask cascade and
|
|
1798
2111
|
// reorder drain interleaving (and has no edge-runtime primitive).
|
|
1799
2112
|
// It still coalesces reliably: siblings of one Promise.all
|
|
1800
2113
|
// traverse identical awaits (store resolution, span wrapper), so
|
|
@@ -1836,12 +2149,14 @@ export function createServer<
|
|
|
1836
2149
|
|
|
1837
2150
|
const makeSchedule = (
|
|
1838
2151
|
sessionId: string,
|
|
1839
|
-
trigger:
|
|
1840
|
-
StoredEvent,
|
|
1841
|
-
'id' | 'createdAt' | 'index' | 'clearIndex'
|
|
1842
|
-
> | null,
|
|
2152
|
+
trigger: HeadEvent | null,
|
|
1843
2153
|
): SessionSchedule<D> => {
|
|
1844
2154
|
const triggerEventId = trigger?.id ?? null
|
|
2155
|
+
/** Timers a consumer sets key on its name, except A2's own. */
|
|
2156
|
+
const triggerConsumer =
|
|
2157
|
+
trigger && !registeredConsumers.get(trigger.consumer)?.anonymousIds
|
|
2158
|
+
? trigger.consumer
|
|
2159
|
+
: undefined
|
|
1845
2160
|
const triggerCreatedAt = trigger?.createdAt.getTime()
|
|
1846
2161
|
const clearIndex = trigger?.clearIndex ?? 0
|
|
1847
2162
|
const schedule = async (
|
|
@@ -1878,6 +2193,7 @@ export function createServer<
|
|
|
1878
2193
|
triggerEventId,
|
|
1879
2194
|
scheduleName,
|
|
1880
2195
|
clearIndex,
|
|
2196
|
+
triggerConsumer,
|
|
1881
2197
|
)
|
|
1882
2198
|
const scheduledEvents: ScheduledEvent[] = await Promise.all(
|
|
1883
2199
|
validated.map(async (event, item) => ({
|
|
@@ -1943,6 +2259,7 @@ export function createServer<
|
|
|
1943
2259
|
clearIndex,
|
|
1944
2260
|
name: scheduleName,
|
|
1945
2261
|
ifLastIndex,
|
|
2262
|
+
...(triggerConsumer === undefined ? {} : { consumer: triggerConsumer }),
|
|
1946
2263
|
})
|
|
1947
2264
|
try {
|
|
1948
2265
|
await scheduler.schedule({
|
|
@@ -2122,7 +2439,7 @@ export function createServer<
|
|
|
2122
2439
|
{
|
|
2123
2440
|
seen: patch.seen ?? fallbackSeen,
|
|
2124
2441
|
// The sender's stamp is the LWW order; receipt time is the
|
|
2125
|
-
// fallback for stampless callers (
|
|
2442
|
+
// fallback for stampless callers (consumers, old wire
|
|
2126
2443
|
// clients), where receipt order is sender order — single
|
|
2127
2444
|
// writer. Storage anchors expiry on its own clock either way.
|
|
2128
2445
|
at: new Date(patch.at ?? Date.now()),
|
|
@@ -2497,7 +2814,7 @@ export function createServer<
|
|
|
2497
2814
|
}
|
|
2498
2815
|
}
|
|
2499
2816
|
|
|
2500
|
-
/** The
|
|
2817
|
+
/** The consumer attempt a `ctx.session.clear()` belongs to. */
|
|
2501
2818
|
type ClearingAttempt = {
|
|
2502
2819
|
cause: EventCause
|
|
2503
2820
|
end: () => void
|
|
@@ -2530,7 +2847,7 @@ export function createServer<
|
|
|
2530
2847
|
}
|
|
2531
2848
|
if (result.cleared) {
|
|
2532
2849
|
// This instance's own reads after the clear, including the clearing
|
|
2533
|
-
//
|
|
2850
|
+
// consumer's, go to the store and see the boundary.
|
|
2534
2851
|
const cachePrefix = stateCacheKey(sessionId, '')
|
|
2535
2852
|
stateCache.deleteWhere((key) => key.startsWith(cachePrefix))
|
|
2536
2853
|
}
|
|
@@ -2565,7 +2882,7 @@ export function createServer<
|
|
|
2565
2882
|
sessionId: string,
|
|
2566
2883
|
): Promise<DeadLetter<D>[]> => {
|
|
2567
2884
|
const store = await redrivingStore('deadLetters')
|
|
2568
|
-
let rows:
|
|
2885
|
+
let rows: HeadEvent[]
|
|
2569
2886
|
try {
|
|
2570
2887
|
rows = await store.deadLetters(nsId(sessionId))
|
|
2571
2888
|
} catch (err) {
|
|
@@ -2577,6 +2894,7 @@ export function createServer<
|
|
|
2577
2894
|
: [
|
|
2578
2895
|
{
|
|
2579
2896
|
...toPublic(row),
|
|
2897
|
+
consumer: row.consumer,
|
|
2580
2898
|
lane: row.lane,
|
|
2581
2899
|
attemptCount: row.attemptCount,
|
|
2582
2900
|
failureCount: row.failureCount,
|
|
@@ -2589,10 +2907,15 @@ export function createServer<
|
|
|
2589
2907
|
|
|
2590
2908
|
const redriveEvent = async (
|
|
2591
2909
|
sessionId: string,
|
|
2592
|
-
index: number,
|
|
2910
|
+
target: { consumer: string; index: number },
|
|
2593
2911
|
redriveOptions: SessionRedriveOptions,
|
|
2594
2912
|
): Promise<SessionRedriveResult> => {
|
|
2913
|
+
const index = target?.index
|
|
2914
|
+
const redriven = target?.consumer
|
|
2595
2915
|
assertRedriveIndex(index)
|
|
2916
|
+
if (typeof redriven !== 'string' || redriven.length === 0) {
|
|
2917
|
+
throw new TypeError('redrive requires a consumer name')
|
|
2918
|
+
}
|
|
2596
2919
|
const action = redriveOptions?.action
|
|
2597
2920
|
if (action !== 'retry' && action !== 'skip') {
|
|
2598
2921
|
throw new TypeError("redrive requires action 'retry' or 'skip'")
|
|
@@ -2603,6 +2926,7 @@ export function createServer<
|
|
|
2603
2926
|
'a2.contract': name,
|
|
2604
2927
|
'a2.session_id': sessionId,
|
|
2605
2928
|
'a2.redrive.index': index,
|
|
2929
|
+
'a2.redrive.consumer': redriven,
|
|
2606
2930
|
'a2.redrive.action': action,
|
|
2607
2931
|
},
|
|
2608
2932
|
async (span) => {
|
|
@@ -2611,8 +2935,10 @@ export function createServer<
|
|
|
2611
2935
|
try {
|
|
2612
2936
|
result = await store.redrive({
|
|
2613
2937
|
sessionId: nsId(sessionId),
|
|
2938
|
+
consumer: redriven,
|
|
2614
2939
|
index,
|
|
2615
2940
|
action,
|
|
2941
|
+
consumers: storeConsumers,
|
|
2616
2942
|
})
|
|
2617
2943
|
} catch (err) {
|
|
2618
2944
|
throw asStoreUnavailable(err)
|
|
@@ -2643,18 +2969,169 @@ export function createServer<
|
|
|
2643
2969
|
)
|
|
2644
2970
|
}
|
|
2645
2971
|
|
|
2972
|
+
/**
|
|
2973
|
+
* `session.consume`: wakes the consumer's cursor, then drains only that
|
|
2974
|
+
* consumer until its head lies past `through`. A head another invocation
|
|
2975
|
+
* holds, or one waiting out a failure, is waited for; a dead-lettered head
|
|
2976
|
+
* throws.
|
|
2977
|
+
*/
|
|
2978
|
+
/** Per session, which consumer's running head is waiting in `consume` on which: a cycle never ends. */
|
|
2979
|
+
const consumeWaits = new Map<string, Map<string, string>>()
|
|
2980
|
+
|
|
2981
|
+
const consumeSession = (
|
|
2982
|
+
sessionId: string,
|
|
2983
|
+
defined: Consumer<D>,
|
|
2984
|
+
consumeOptions: SessionConsumeOptions = {},
|
|
2985
|
+
trigger: HeadEvent | null = null,
|
|
2986
|
+
): Promise<SessionConsumeResult> => {
|
|
2987
|
+
assertSessionId(sessionId)
|
|
2988
|
+
if (defined?.contract?.name !== name || !defined.spec) {
|
|
2989
|
+
throw new TypeError(
|
|
2990
|
+
`session.consume for '${name}' got a consumer of another contract`,
|
|
2991
|
+
)
|
|
2992
|
+
}
|
|
2993
|
+
const own = definitions.get(defined.name)
|
|
2994
|
+
if (own !== undefined && own !== defined) {
|
|
2995
|
+
throw new TypeError(
|
|
2996
|
+
`this server runs a different consumer named '${defined.name}'`,
|
|
2997
|
+
)
|
|
2998
|
+
}
|
|
2999
|
+
if (defined.name === trigger?.consumer) {
|
|
3000
|
+
throw new TypeError(
|
|
3001
|
+
`consumer '${defined.name}' can't consume itself: its own head is running`,
|
|
3002
|
+
)
|
|
3003
|
+
}
|
|
3004
|
+
const registered = own
|
|
3005
|
+
? registeredConsumers.get(defined.name)!
|
|
3006
|
+
: registrationOf(defined)
|
|
3007
|
+
const log = registered.log
|
|
3008
|
+
if (!log) {
|
|
3009
|
+
throw new TypeError(
|
|
3010
|
+
`consumer '${defined.name}' needs a string lane to be consumed: only then does it have one position`,
|
|
3011
|
+
)
|
|
3012
|
+
}
|
|
3013
|
+
const { through: requested, signal } = consumeOptions
|
|
3014
|
+
if (requested !== undefined) assertConsumeIndex(requested)
|
|
3015
|
+
// A run that waits on a consumer already waiting on it, here, would wait
|
|
3016
|
+
// forever. Checked and recorded before any await, so two can't both pass.
|
|
3017
|
+
// One that would close that cycle isn't recorded: it returns only if what
|
|
3018
|
+
// it asks for is done already, since the other's cursor can't move.
|
|
3019
|
+
const waiter = trigger?.consumer
|
|
3020
|
+
const waits =
|
|
3021
|
+
waiter === undefined
|
|
3022
|
+
? undefined
|
|
3023
|
+
: (consumeWaits.get(sessionId) ??
|
|
3024
|
+
consumeWaits.set(sessionId, new Map()).get(sessionId)!)
|
|
3025
|
+
let cycle = false
|
|
3026
|
+
if (waiter !== undefined && waits) {
|
|
3027
|
+
for (
|
|
3028
|
+
let next: string | undefined = defined.name;
|
|
3029
|
+
next !== undefined && !cycle;
|
|
3030
|
+
next = waits.get(next)
|
|
3031
|
+
) {
|
|
3032
|
+
cycle = next === waiter
|
|
3033
|
+
}
|
|
3034
|
+
if (!cycle) waits.set(waiter, defined.name)
|
|
3035
|
+
}
|
|
3036
|
+
const storeConsumer = { name: registered.name, log }
|
|
3037
|
+
const consumed = telemetry.span(
|
|
3038
|
+
'a2.consume',
|
|
3039
|
+
{
|
|
3040
|
+
'a2.contract': name,
|
|
3041
|
+
'a2.session_id': sessionId,
|
|
3042
|
+
'a2.consume.consumer': registered.name,
|
|
3043
|
+
},
|
|
3044
|
+
async (span) => {
|
|
3045
|
+
const store = await resolveStore()
|
|
3046
|
+
const ns = nsId(sessionId)
|
|
3047
|
+
let woken = await store.wakeLog({
|
|
3048
|
+
sessionId: ns,
|
|
3049
|
+
consumer: storeConsumer,
|
|
3050
|
+
})
|
|
3051
|
+
const through = Math.min(requested ?? woken.head, woken.head)
|
|
3052
|
+
span.setAttribute('a2.consume.through', through)
|
|
3053
|
+
const done = (): boolean =>
|
|
3054
|
+
woken.cursor.head === null || woken.cursor.head > through
|
|
3055
|
+
if (cycle && !done()) {
|
|
3056
|
+
throw new TypeError(
|
|
3057
|
+
`consumer '${waiter}' can't consume '${defined.name}' through ${through}: '${defined.name}' is waiting on it in session '${sessionId}'`,
|
|
3058
|
+
)
|
|
3059
|
+
}
|
|
3060
|
+
let drains = 0
|
|
3061
|
+
let wait = CONSUME_WAIT_MIN_MS
|
|
3062
|
+
while (!done()) {
|
|
3063
|
+
signal?.throwIfAborted()
|
|
3064
|
+
const { cursor } = woken
|
|
3065
|
+
if (cursor.failedAt !== null) {
|
|
3066
|
+
throw new A2Error(
|
|
3067
|
+
'CONSUMER_DEAD_LETTERED',
|
|
3068
|
+
`consumer '${registered.name}' is dead-lettered at event ${cursor.head} in session '${sessionId}': ${cursor.lastError ?? 'every attempt ended before it finished'}`,
|
|
3069
|
+
)
|
|
3070
|
+
}
|
|
3071
|
+
drains += 1
|
|
3072
|
+
const drained = drainSession(sessionId, { only: registered, through })
|
|
3073
|
+
try {
|
|
3074
|
+
await untilAborted(drained, signal)
|
|
3075
|
+
} catch (error) {
|
|
3076
|
+
// An abort stops the wait at once; a run in progress carries on,
|
|
3077
|
+
// and what it appends still reaches the server's consumers.
|
|
3078
|
+
if (signal?.aborted)
|
|
3079
|
+
void drained.then(
|
|
3080
|
+
() => scheduleDrain(sessionId),
|
|
3081
|
+
() => {},
|
|
3082
|
+
)
|
|
3083
|
+
throw error
|
|
3084
|
+
}
|
|
3085
|
+
const before = cursor
|
|
3086
|
+
woken = await store.wakeLog({
|
|
3087
|
+
sessionId: ns,
|
|
3088
|
+
consumer: storeConsumer,
|
|
3089
|
+
})
|
|
3090
|
+
const moved =
|
|
3091
|
+
woken.cursor.head !== before.head ||
|
|
3092
|
+
woken.cursor.attemptCount !== before.attemptCount
|
|
3093
|
+
if (done() || moved) {
|
|
3094
|
+
wait = CONSUME_WAIT_MIN_MS
|
|
3095
|
+
continue
|
|
3096
|
+
}
|
|
3097
|
+
// Another invocation holds the head, or it waits out a failure.
|
|
3098
|
+
const sleep = defaultSleep(wait)
|
|
3099
|
+
const abort = (): void => sleep.cancel()
|
|
3100
|
+
signal?.addEventListener('abort', abort, { once: true })
|
|
3101
|
+
await sleep.promise
|
|
3102
|
+
signal?.removeEventListener('abort', abort)
|
|
3103
|
+
wait = Math.min(wait * 2, CONSUME_WAIT_MAX_MS)
|
|
3104
|
+
}
|
|
3105
|
+
// Work past `through` carries on in the background, as after an append,
|
|
3106
|
+
// and so do the server's consumers of what this consumer's runs appended.
|
|
3107
|
+
if (drains > 0 || (own && woken.cursor.head !== null))
|
|
3108
|
+
scheduleDrain(sessionId)
|
|
3109
|
+
const index =
|
|
3110
|
+
woken.cursor.head === null ? woken.head : woken.cursor.head - 1
|
|
3111
|
+
span.setAttribute('a2.consume.index', index)
|
|
3112
|
+
span.setAttribute('a2.consume.drains', drains)
|
|
3113
|
+
return { index }
|
|
3114
|
+
},
|
|
3115
|
+
)
|
|
3116
|
+
return waiter === undefined || cycle
|
|
3117
|
+
? consumed
|
|
3118
|
+
: consumed.finally(() => {
|
|
3119
|
+
waits?.delete(waiter)
|
|
3120
|
+
if (waits?.size === 0) consumeWaits.delete(sessionId)
|
|
3121
|
+
})
|
|
3122
|
+
}
|
|
3123
|
+
|
|
2646
3124
|
const makeSession = <Append>(
|
|
2647
3125
|
id: string,
|
|
2648
3126
|
append: Append,
|
|
2649
|
-
trigger:
|
|
2650
|
-
StoredEvent,
|
|
2651
|
-
'id' | 'createdAt' | 'index' | 'clearIndex'
|
|
2652
|
-
> | null,
|
|
3127
|
+
trigger: HeadEvent | null,
|
|
2653
3128
|
attempt?: ClearingAttempt,
|
|
2654
3129
|
): Session<D, Append, P> =>
|
|
2655
3130
|
({
|
|
2656
3131
|
id,
|
|
2657
3132
|
append,
|
|
3133
|
+
consume: (defined: Consumer<D>, consumeOptions?: SessionConsumeOptions) =>
|
|
3134
|
+
consumeSession(id, defined, consumeOptions, trigger),
|
|
2658
3135
|
schedule: makeSchedule(id, trigger),
|
|
2659
3136
|
clear: (clearOptions?: SessionClearOptions) =>
|
|
2660
3137
|
clearSession(id, clearOptions, attempt),
|
|
@@ -2683,7 +3160,7 @@ export function createServer<
|
|
|
2683
3160
|
const bounds: StateReadBounds = {}
|
|
2684
3161
|
if (through !== undefined && through !== 'latest') {
|
|
2685
3162
|
bounds.throughIndex = through
|
|
2686
|
-
if (trigger) bounds.
|
|
3163
|
+
if (trigger) bounds.pin = headRefOf(trigger)
|
|
2687
3164
|
// Only a live attempt may be answered from memory with no store
|
|
2688
3165
|
// request; a superseded one (including by a clear) reads the store.
|
|
2689
3166
|
if (through === trigger?.index && !attempt?.signal.aborted)
|
|
@@ -2716,42 +3193,60 @@ export function createServer<
|
|
|
2716
3193
|
|
|
2717
3194
|
// ── drain ────────────────────────────────────────────────────────
|
|
2718
3195
|
|
|
3196
|
+
/** The consumer name a trigger's child ids key on, if any. */
|
|
3197
|
+
const idConsumerOf = (trigger: HeadEvent): string | undefined =>
|
|
3198
|
+
registeredConsumers.get(trigger.consumer)?.anonymousIds
|
|
3199
|
+
? undefined
|
|
3200
|
+
: trigger.consumer
|
|
3201
|
+
|
|
2719
3202
|
const makeCtx = (
|
|
2720
|
-
trigger:
|
|
3203
|
+
trigger: HeadEvent,
|
|
2721
3204
|
attempt: number,
|
|
2722
3205
|
signal: AbortSignal,
|
|
2723
3206
|
endAttempt: () => void,
|
|
2724
|
-
):
|
|
3207
|
+
): ConsumerContext<D> => {
|
|
2725
3208
|
const publicSessionId = stripNs(trigger.sessionId)
|
|
2726
|
-
const
|
|
3209
|
+
const idConsumer = idConsumerOf(trigger)
|
|
3210
|
+
const cause: EventCause = {
|
|
3211
|
+
consumer: trigger.consumer,
|
|
3212
|
+
lane: trigger.lane,
|
|
3213
|
+
index: trigger.index,
|
|
3214
|
+
attempt,
|
|
3215
|
+
}
|
|
3216
|
+
const append: ConsumerAppend<D> = async (appendName, ...events) => {
|
|
2727
3217
|
assertAppendName(appendName)
|
|
2728
3218
|
const generatedIds = events.map((event) => event.id === undefined)
|
|
2729
3219
|
const withIds = await Promise.all(
|
|
2730
3220
|
// oxlint-disable-next-line no-map-spread
|
|
2731
3221
|
events.map(async (event, item) => {
|
|
2732
3222
|
if (event.id !== undefined) return event
|
|
2733
|
-
const id = await deterministicEventId(
|
|
3223
|
+
const id = await deterministicEventId(
|
|
3224
|
+
trigger.id,
|
|
3225
|
+
appendName,
|
|
3226
|
+
item,
|
|
3227
|
+
idConsumer,
|
|
3228
|
+
)
|
|
2734
3229
|
return { ...event, id }
|
|
2735
3230
|
}),
|
|
2736
3231
|
)
|
|
2737
3232
|
return appendCore(
|
|
2738
3233
|
publicSessionId,
|
|
2739
3234
|
withIds,
|
|
2740
|
-
'
|
|
3235
|
+
'consumer',
|
|
2741
3236
|
'inline',
|
|
2742
3237
|
(_rows, watchdogDueAt) => scheduleDrain(publicSessionId, watchdogDueAt),
|
|
2743
|
-
|
|
3238
|
+
cause,
|
|
2744
3239
|
generatedIds,
|
|
2745
3240
|
)
|
|
2746
3241
|
}
|
|
2747
|
-
const ctx:
|
|
3242
|
+
const ctx: ConsumerContext<D> = {
|
|
2748
3243
|
event: toPublic(trigger),
|
|
2749
3244
|
attempt,
|
|
2750
|
-
// The
|
|
2751
|
-
// triggering event's index by default — the frontier the
|
|
3245
|
+
// The consumer-scoped `setPresence` stamps `seen` with the
|
|
3246
|
+
// triggering event's index by default — the frontier the consumer
|
|
2752
3247
|
// provably reflects.
|
|
2753
3248
|
session: makeSession(publicSessionId, append, trigger, {
|
|
2754
|
-
cause
|
|
3249
|
+
cause,
|
|
2755
3250
|
end: endAttempt,
|
|
2756
3251
|
signal,
|
|
2757
3252
|
}),
|
|
@@ -2857,7 +3352,7 @@ export function createServer<
|
|
|
2857
3352
|
const outcome = await Promise.race([
|
|
2858
3353
|
operation.then(
|
|
2859
3354
|
(value) => ({ type: 'completed' as const, value }),
|
|
2860
|
-
(error: unknown) => ({ type: '
|
|
3355
|
+
(error: unknown) => ({ type: 'run-failed' as const, error }),
|
|
2861
3356
|
),
|
|
2862
3357
|
hub.watch!.then(() =>
|
|
2863
3358
|
hub.error === null
|
|
@@ -2866,7 +3361,7 @@ export function createServer<
|
|
|
2866
3361
|
),
|
|
2867
3362
|
])
|
|
2868
3363
|
if (outcome.type === 'completed') return outcome.value
|
|
2869
|
-
if (outcome.type === '
|
|
3364
|
+
if (outcome.type === 'run-failed') throw outcome.error
|
|
2870
3365
|
if (outcome.type === 'monitor-failed') {
|
|
2871
3366
|
for (const active of hub.subscriptions) {
|
|
2872
3367
|
active.controller.abort(outcome.error)
|
|
@@ -2875,7 +3370,7 @@ export function createServer<
|
|
|
2875
3370
|
throw outcome.error
|
|
2876
3371
|
}
|
|
2877
3372
|
await operation.catch(() => {})
|
|
2878
|
-
throw new Error('abort monitor closed while
|
|
3373
|
+
throw new Error('abort monitor closed while consumers were active')
|
|
2879
3374
|
},
|
|
2880
3375
|
close: async () => {
|
|
2881
3376
|
hub.subscriptions.delete(subscription)
|
|
@@ -2894,18 +3389,22 @@ export function createServer<
|
|
|
2894
3389
|
|
|
2895
3390
|
const returnedEvents = async (
|
|
2896
3391
|
sessionId: string,
|
|
2897
|
-
trigger:
|
|
2898
|
-
result:
|
|
3392
|
+
trigger: HeadEvent,
|
|
3393
|
+
result: unknown,
|
|
2899
3394
|
): Promise<ReturnedEvent[]> => {
|
|
2900
3395
|
if (result === undefined) return []
|
|
2901
|
-
const events =
|
|
3396
|
+
const events = (
|
|
3397
|
+
Array.isArray(result) ? [...result] : [result]
|
|
3398
|
+
) as AppendInput<D>[]
|
|
2902
3399
|
if (events.length === 0) return []
|
|
3400
|
+
const idConsumer = idConsumerOf(trigger)
|
|
2903
3401
|
const withIds = await Promise.all(
|
|
2904
|
-
// oxlint-disable-next-line oxc/no-map-spread --
|
|
3402
|
+
// oxlint-disable-next-line oxc/no-map-spread -- consumer results remain caller-owned
|
|
2905
3403
|
events.map(async (event, item) => {
|
|
2906
3404
|
const withId = { ...event }
|
|
2907
3405
|
withId.id =
|
|
2908
|
-
event.id ??
|
|
3406
|
+
event.id ??
|
|
3407
|
+
(await deterministicReturnedEventId(trigger.id, item, idConsumer))
|
|
2909
3408
|
return withId
|
|
2910
3409
|
}),
|
|
2911
3410
|
)
|
|
@@ -2913,6 +3412,8 @@ export function createServer<
|
|
|
2913
3412
|
Object.assign(event, {
|
|
2914
3413
|
id: event.id!,
|
|
2915
3414
|
cause: {
|
|
3415
|
+
consumer: trigger.consumer,
|
|
3416
|
+
lane: trigger.lane,
|
|
2916
3417
|
index: trigger.index,
|
|
2917
3418
|
attempt: trigger.attemptCount,
|
|
2918
3419
|
},
|
|
@@ -2931,8 +3432,22 @@ export function createServer<
|
|
|
2931
3432
|
|
|
2932
3433
|
const drainSession = (
|
|
2933
3434
|
sessionId: string,
|
|
2934
|
-
|
|
2935
|
-
|
|
3435
|
+
{
|
|
3436
|
+
watchdogDueAt,
|
|
3437
|
+
signal,
|
|
3438
|
+
handOffFailure = false,
|
|
3439
|
+
only,
|
|
3440
|
+
through,
|
|
3441
|
+
}: {
|
|
3442
|
+
watchdogDueAt?: number | undefined
|
|
3443
|
+
signal?: DrainSignal | undefined
|
|
3444
|
+
/** Return a failure after arming as `failed` instead of throwing it. */
|
|
3445
|
+
handOffFailure?: boolean
|
|
3446
|
+
/** Claim and run only this consumer's heads, for `consume`. */
|
|
3447
|
+
only?: RegisteredConsumer | undefined
|
|
3448
|
+
/** With `only`: claim no head past it; what's past it is the background's. */
|
|
3449
|
+
through?: number | undefined
|
|
3450
|
+
} = {},
|
|
2936
3451
|
): Promise<DrainResult> =>
|
|
2937
3452
|
telemetry.span(
|
|
2938
3453
|
'a2.drain',
|
|
@@ -2942,6 +3457,7 @@ export function createServer<
|
|
|
2942
3457
|
const ns = nsId(sessionId)
|
|
2943
3458
|
const holder = crypto.randomUUID()
|
|
2944
3459
|
type ActiveEvent = {
|
|
3460
|
+
ref: HeadRef
|
|
2945
3461
|
attempt: number
|
|
2946
3462
|
execution: Promise<EventOutcome>
|
|
2947
3463
|
supersede: AbortController
|
|
@@ -2950,9 +3466,15 @@ export function createServer<
|
|
|
2950
3466
|
/** Local lease estimate: request time + ttl — at or before the store's. */
|
|
2951
3467
|
expiresAtMs: number
|
|
2952
3468
|
lapsed: boolean
|
|
3469
|
+
/**
|
|
3470
|
+
* Its completion is committing. A renewal may then find the cursor
|
|
3471
|
+
* already past the head; completion is the fence, not that report.
|
|
3472
|
+
*/
|
|
3473
|
+
completing: boolean
|
|
2953
3474
|
}
|
|
2954
|
-
|
|
2955
|
-
const
|
|
3475
|
+
/** Running heads, keyed by `headKey`: one event can run for several consumers. */
|
|
3476
|
+
const active = new Map<string, ActiveEvent>()
|
|
3477
|
+
const excluded = new Map<string, HeadRef>()
|
|
2956
3478
|
let processed = 0
|
|
2957
3479
|
let sawFailure = false
|
|
2958
3480
|
let schedulerArm: Promise<void> | undefined
|
|
@@ -2969,13 +3491,13 @@ export function createServer<
|
|
|
2969
3491
|
const lapseDue = (): void => {
|
|
2970
3492
|
lapseTimer = null
|
|
2971
3493
|
const nowMs = Date.now()
|
|
2972
|
-
for (const
|
|
3494
|
+
for (const entry of active.values()) {
|
|
2973
3495
|
if (entry.lapsed || entry.expiresAtMs > nowMs) continue
|
|
2974
3496
|
entry.lapsed = true
|
|
2975
3497
|
entry.supersede.abort(
|
|
2976
3498
|
new A2Error(
|
|
2977
3499
|
'CLAIM_EXPIRED',
|
|
2978
|
-
`the claim on event ${index} in session '${sessionId}' lapsed without renewal`,
|
|
3500
|
+
`the claim on event ${entry.ref.index} for consumer '${entry.ref.consumer}' in session '${sessionId}' lapsed without renewal`,
|
|
2979
3501
|
),
|
|
2980
3502
|
)
|
|
2981
3503
|
}
|
|
@@ -3036,8 +3558,8 @@ export function createServer<
|
|
|
3036
3558
|
}
|
|
3037
3559
|
const window = nextClaimWindow()
|
|
3038
3560
|
requestWatchdog(window.watchdogAtMs)
|
|
3039
|
-
const claims = [...active.
|
|
3040
|
-
|
|
3561
|
+
const claims = [...active.values()].map((entry) => ({
|
|
3562
|
+
...entry.ref,
|
|
3041
3563
|
attempt: entry.attempt,
|
|
3042
3564
|
}))
|
|
3043
3565
|
const operation = store
|
|
@@ -3052,20 +3574,20 @@ export function createServer<
|
|
|
3052
3574
|
})
|
|
3053
3575
|
.then(
|
|
3054
3576
|
(result) => {
|
|
3055
|
-
for (const
|
|
3056
|
-
const entry = active.get(
|
|
3577
|
+
for (const ref of result.renewed) {
|
|
3578
|
+
const entry = active.get(headKey(ref))
|
|
3057
3579
|
if (entry && !entry.lapsed) {
|
|
3058
3580
|
entry.expiresAtMs = window.expiresAtMs
|
|
3059
3581
|
}
|
|
3060
3582
|
}
|
|
3061
|
-
for (const
|
|
3062
|
-
const entry = active.get(
|
|
3063
|
-
if (!entry) continue
|
|
3583
|
+
for (const ref of result.superseded) {
|
|
3584
|
+
const entry = active.get(headKey(ref))
|
|
3585
|
+
if (!entry || entry.completing) continue
|
|
3064
3586
|
// Idempotent: the local lapse normally aborted this signal
|
|
3065
3587
|
// already — this is the proof, whose job is the release.
|
|
3066
3588
|
endSuperseded(
|
|
3067
3589
|
entry,
|
|
3068
|
-
`a newer attempt owns event ${index} in session '${sessionId}'`,
|
|
3590
|
+
`a newer attempt owns event ${ref.index} for consumer '${ref.consumer}' in session '${sessionId}'`,
|
|
3069
3591
|
)
|
|
3070
3592
|
}
|
|
3071
3593
|
if (window.deadlineCapped) stopRenewing = true
|
|
@@ -3085,8 +3607,8 @@ export function createServer<
|
|
|
3085
3607
|
return tracked
|
|
3086
3608
|
}
|
|
3087
3609
|
|
|
3088
|
-
/** Records
|
|
3089
|
-
const reportExhausted = (event:
|
|
3610
|
+
/** Records a head the store dead-lettered on takeover; it never runs. */
|
|
3611
|
+
const reportExhausted = (event: HeadEvent): Promise<void> =>
|
|
3090
3612
|
telemetry.span(
|
|
3091
3613
|
'a2.event',
|
|
3092
3614
|
{
|
|
@@ -3095,8 +3617,9 @@ export function createServer<
|
|
|
3095
3617
|
'a2.event.type': event.type,
|
|
3096
3618
|
'a2.event.index': event.index,
|
|
3097
3619
|
'a2.event.id': event.id,
|
|
3620
|
+
'a2.event.consumer': event.consumer,
|
|
3098
3621
|
'a2.event.attempt': event.attemptCount,
|
|
3099
|
-
'a2.event.handled':
|
|
3622
|
+
'a2.event.handled': registeredConsumers.has(event.consumer),
|
|
3100
3623
|
...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
|
|
3101
3624
|
},
|
|
3102
3625
|
async (eventSpan) => {
|
|
@@ -3105,11 +3628,17 @@ export function createServer<
|
|
|
3105
3628
|
)
|
|
3106
3629
|
|
|
3107
3630
|
const runEvent = async (
|
|
3108
|
-
event:
|
|
3631
|
+
event: HeadEvent,
|
|
3109
3632
|
supersedeSignal: AbortSignal,
|
|
3110
3633
|
endAttempt: () => void,
|
|
3634
|
+
beginCompleting: () => void,
|
|
3635
|
+
sessionHead: number | undefined,
|
|
3111
3636
|
): Promise<EventOutcome> => {
|
|
3112
|
-
const registration =
|
|
3637
|
+
const registration =
|
|
3638
|
+
only?.name === event.consumer
|
|
3639
|
+
? only
|
|
3640
|
+
: registeredConsumers.get(event.consumer)
|
|
3641
|
+
const abortOn = registration?.abortOn(event.type) ?? null
|
|
3113
3642
|
return telemetry.span(
|
|
3114
3643
|
'a2.event',
|
|
3115
3644
|
{
|
|
@@ -3118,8 +3647,16 @@ export function createServer<
|
|
|
3118
3647
|
'a2.event.type': event.type,
|
|
3119
3648
|
'a2.event.index': event.index,
|
|
3120
3649
|
'a2.event.id': event.id,
|
|
3650
|
+
'a2.event.consumer': event.consumer,
|
|
3121
3651
|
'a2.event.attempt': event.attemptCount,
|
|
3122
3652
|
'a2.event.handled': registration !== undefined,
|
|
3653
|
+
'a2.event.age_ms': Math.max(
|
|
3654
|
+
0,
|
|
3655
|
+
Date.now() - event.createdAt.getTime(),
|
|
3656
|
+
),
|
|
3657
|
+
...(sessionHead === undefined
|
|
3658
|
+
? {}
|
|
3659
|
+
: { 'a2.event.lag': Math.max(0, sessionHead - event.index) }),
|
|
3123
3660
|
...(event.lane === null ? {} : { 'a2.event.lane': event.lane }),
|
|
3124
3661
|
},
|
|
3125
3662
|
async (eventSpan) => {
|
|
@@ -3130,24 +3667,25 @@ export function createServer<
|
|
|
3130
3667
|
close(): Promise<void>
|
|
3131
3668
|
}
|
|
3132
3669
|
| undefined
|
|
3133
|
-
let
|
|
3134
|
-
let
|
|
3670
|
+
let runSignal = supersedeSignal
|
|
3671
|
+
let completion: CompleteAttemptResult
|
|
3672
|
+
let completing = false
|
|
3135
3673
|
try {
|
|
3136
|
-
if (
|
|
3674
|
+
if (abortOn) {
|
|
3137
3675
|
subscription = await subscribeAbort(
|
|
3138
3676
|
store,
|
|
3139
3677
|
ns,
|
|
3140
3678
|
event,
|
|
3141
|
-
|
|
3679
|
+
abortOn,
|
|
3142
3680
|
event.attemptCount,
|
|
3143
3681
|
)
|
|
3144
|
-
|
|
3682
|
+
runSignal = AbortSignal.any([
|
|
3145
3683
|
supersedeSignal,
|
|
3146
3684
|
subscription.signal,
|
|
3147
3685
|
])
|
|
3148
3686
|
}
|
|
3149
|
-
const operation = registration?.
|
|
3150
|
-
makeCtx(event, event.attemptCount,
|
|
3687
|
+
const operation = registration?.run(
|
|
3688
|
+
makeCtx(event, event.attemptCount, runSignal, endAttempt),
|
|
3151
3689
|
)
|
|
3152
3690
|
const result = operation
|
|
3153
3691
|
? await (subscription
|
|
@@ -3158,12 +3696,41 @@ export function createServer<
|
|
|
3158
3696
|
await subscription.close()
|
|
3159
3697
|
subscription = undefined
|
|
3160
3698
|
}
|
|
3161
|
-
returned = await returnedEvents(sessionId, event, result)
|
|
3699
|
+
const returned = await returnedEvents(sessionId, event, result)
|
|
3700
|
+
await renewal
|
|
3701
|
+
completing = true
|
|
3702
|
+
beginCompleting()
|
|
3703
|
+
completion = await telemetry.span(
|
|
3704
|
+
'a2.commit',
|
|
3705
|
+
{
|
|
3706
|
+
'a2.contract': name,
|
|
3707
|
+
'a2.session_id': sessionId,
|
|
3708
|
+
'a2.commit.operation': 'complete',
|
|
3709
|
+
'a2.commit.index': event.index,
|
|
3710
|
+
'a2.commit.attempt': event.attemptCount,
|
|
3711
|
+
},
|
|
3712
|
+
async (commitSpan) => {
|
|
3713
|
+
const committed = await store.completeAttempt({
|
|
3714
|
+
sessionId: ns,
|
|
3715
|
+
consumer: event.consumer,
|
|
3716
|
+
lane: event.lane,
|
|
3717
|
+
index: event.index,
|
|
3718
|
+
attempt: event.attemptCount,
|
|
3719
|
+
events: returned,
|
|
3720
|
+
consumers: storeConsumers,
|
|
3721
|
+
})
|
|
3722
|
+
commitSpan.setAttribute(
|
|
3723
|
+
'a2.commit.outcome',
|
|
3724
|
+
committed.outcome,
|
|
3725
|
+
)
|
|
3726
|
+
return committed
|
|
3727
|
+
},
|
|
3728
|
+
)
|
|
3162
3729
|
} catch (err) {
|
|
3163
3730
|
if (supersedeSignal.aborted && err === supersedeSignal.reason) {
|
|
3164
|
-
// The
|
|
3731
|
+
// The consumer surrendered to its own abort — identity, not
|
|
3165
3732
|
// code, so it cannot arrive from another context. A lapse
|
|
3166
|
-
// is not a
|
|
3733
|
+
// is not a consumer failure: no completion; the claim simply
|
|
3167
3734
|
// expires, and the claim that takes it over counts it.
|
|
3168
3735
|
eventSpan.setAttribute('a2.event.outcome', 'surrendered')
|
|
3169
3736
|
return {
|
|
@@ -3172,6 +3739,14 @@ export function createServer<
|
|
|
3172
3739
|
appendsEvents: false,
|
|
3173
3740
|
}
|
|
3174
3741
|
}
|
|
3742
|
+
const refused =
|
|
3743
|
+
completing &&
|
|
3744
|
+
err instanceof A2Error &&
|
|
3745
|
+
err.code === 'PARTIAL_DUPLICATE_BATCH'
|
|
3746
|
+
// A store that cannot record the completion is not the
|
|
3747
|
+
// consumer failing: the claim lapses, and the claim that takes
|
|
3748
|
+
// it over counts it. A returned batch it refuses is.
|
|
3749
|
+
if (completing && !refused) throw err
|
|
3175
3750
|
// Everything else — including a store fence rejection — is
|
|
3176
3751
|
// adjudicated by failAttempt, which is itself fenced: a
|
|
3177
3752
|
// genuinely superseded attempt gets 'superseded' back with
|
|
@@ -3189,14 +3764,19 @@ export function createServer<
|
|
|
3189
3764
|
async (commitSpan) => {
|
|
3190
3765
|
const result = await store.failAttempt({
|
|
3191
3766
|
sessionId: ns,
|
|
3767
|
+
consumer: event.consumer,
|
|
3768
|
+
lane: event.lane,
|
|
3192
3769
|
index: event.index,
|
|
3193
3770
|
attempt: event.attemptCount,
|
|
3194
3771
|
error: describeError(err),
|
|
3195
|
-
// A NonRetriableError is terminal on its first throw
|
|
3196
|
-
//
|
|
3197
|
-
//
|
|
3772
|
+
// A NonRetriableError is terminal on its first throw,
|
|
3773
|
+
// and so is a refused batch, whose retry returns the
|
|
3774
|
+
// same ids: deterministic failures should not burn the
|
|
3775
|
+
// retry budget before dead-lettering.
|
|
3198
3776
|
maxFailures:
|
|
3199
|
-
err instanceof NonRetriableError
|
|
3777
|
+
err instanceof NonRetriableError || refused
|
|
3778
|
+
? 1
|
|
3779
|
+
: MAX_FAILURES,
|
|
3200
3780
|
retryAfterMs: failureRetryDelayMs(event.failureCount + 1),
|
|
3201
3781
|
})
|
|
3202
3782
|
commitSpan.setAttribute('a2.commit.outcome', result.outcome)
|
|
@@ -3212,27 +3792,6 @@ export function createServer<
|
|
|
3212
3792
|
} finally {
|
|
3213
3793
|
await subscription?.close()
|
|
3214
3794
|
}
|
|
3215
|
-
await renewal
|
|
3216
|
-
const completion = await telemetry.span(
|
|
3217
|
-
'a2.commit',
|
|
3218
|
-
{
|
|
3219
|
-
'a2.contract': name,
|
|
3220
|
-
'a2.session_id': sessionId,
|
|
3221
|
-
'a2.commit.operation': 'complete',
|
|
3222
|
-
'a2.commit.index': event.index,
|
|
3223
|
-
'a2.commit.attempt': event.attemptCount,
|
|
3224
|
-
},
|
|
3225
|
-
async (commitSpan) => {
|
|
3226
|
-
const result = await store.completeAttempt({
|
|
3227
|
-
sessionId: ns,
|
|
3228
|
-
index: event.index,
|
|
3229
|
-
attempt: event.attemptCount,
|
|
3230
|
-
events: returned,
|
|
3231
|
-
})
|
|
3232
|
-
commitSpan.setAttribute('a2.commit.outcome', result.outcome)
|
|
3233
|
-
return result
|
|
3234
|
-
},
|
|
3235
|
-
)
|
|
3236
3795
|
eventSpan.setAttribute(
|
|
3237
3796
|
'a2.event.outcome',
|
|
3238
3797
|
completion.outcome === 'completed'
|
|
@@ -3246,13 +3805,13 @@ export function createServer<
|
|
|
3246
3805
|
appendsEvents: false,
|
|
3247
3806
|
}
|
|
3248
3807
|
}
|
|
3249
|
-
if (
|
|
3808
|
+
if (runSignal.aborted) {
|
|
3250
3809
|
eventSpan.setAttribute('a2.event.aborted', true)
|
|
3251
3810
|
}
|
|
3252
3811
|
return {
|
|
3253
3812
|
status: 'processed',
|
|
3254
3813
|
opensLane: event.lane !== null,
|
|
3255
|
-
appendsEvents:
|
|
3814
|
+
appendsEvents: completion.events.length > 0,
|
|
3256
3815
|
}
|
|
3257
3816
|
},
|
|
3258
3817
|
)
|
|
@@ -3260,16 +3819,22 @@ export function createServer<
|
|
|
3260
3819
|
|
|
3261
3820
|
let claimAgain = false
|
|
3262
3821
|
|
|
3263
|
-
const start = (
|
|
3264
|
-
|
|
3822
|
+
const start = (
|
|
3823
|
+
event: HeadEvent,
|
|
3824
|
+
expiresAtMs: number,
|
|
3825
|
+
sessionHead: number | undefined,
|
|
3826
|
+
): void => {
|
|
3827
|
+
const ref = headRefOf(event)
|
|
3828
|
+
const key = headKey(ref)
|
|
3829
|
+
// Re-claiming a head this drain still tracks is durable proof
|
|
3265
3830
|
// that the old attempt lost: our own new claim advanced the
|
|
3266
3831
|
// ordinal. Fence the old entry before it can be confused with
|
|
3267
3832
|
// the new one.
|
|
3268
|
-
const previous = active.get(
|
|
3833
|
+
const previous = active.get(key)
|
|
3269
3834
|
if (previous) {
|
|
3270
3835
|
endSuperseded(
|
|
3271
3836
|
previous,
|
|
3272
|
-
`a newer attempt owns event ${event.index} in session '${sessionId}'`,
|
|
3837
|
+
`a newer attempt owns event ${event.index} for consumer '${event.consumer}' in session '${sessionId}'`,
|
|
3273
3838
|
)
|
|
3274
3839
|
}
|
|
3275
3840
|
const supersede = new AbortController()
|
|
@@ -3277,40 +3842,50 @@ export function createServer<
|
|
|
3277
3842
|
const fenced = new Promise<EventOutcome>((resolve) => {
|
|
3278
3843
|
fence = resolve
|
|
3279
3844
|
})
|
|
3280
|
-
// A
|
|
3845
|
+
// A consumer's own committed clear supersedes it immediately.
|
|
3281
3846
|
const endAttempt = (): void =>
|
|
3282
3847
|
endSuperseded(
|
|
3283
3848
|
{ supersede, fence },
|
|
3284
|
-
`event ${event.index} in session '${sessionId}' was cleared`,
|
|
3849
|
+
`event ${event.index} for consumer '${event.consumer}' in session '${sessionId}' was cleared`,
|
|
3285
3850
|
)
|
|
3286
3851
|
// The race lets a durable supersession release this slot while the
|
|
3287
|
-
// zombie
|
|
3852
|
+
// zombie consumer keeps running — its completion and failure are
|
|
3288
3853
|
// fenced by the store, so nothing it does from here is counted.
|
|
3289
3854
|
let entry!: ActiveEvent
|
|
3290
3855
|
const execution = Promise.race([
|
|
3291
|
-
runEvent(
|
|
3856
|
+
runEvent(
|
|
3857
|
+
event,
|
|
3858
|
+
supersede.signal,
|
|
3859
|
+
endAttempt,
|
|
3860
|
+
() => {
|
|
3861
|
+
entry.completing = true
|
|
3862
|
+
},
|
|
3863
|
+
sessionHead,
|
|
3864
|
+
),
|
|
3292
3865
|
fenced,
|
|
3293
3866
|
]).then((eventOutcome) => {
|
|
3294
3867
|
// Identity-guarded: a fenced predecessor's continuation must
|
|
3295
3868
|
// not delete the entry of the attempt that replaced it.
|
|
3296
|
-
if (active.get(
|
|
3869
|
+
if (active.get(key) === entry) active.delete(key)
|
|
3297
3870
|
if (eventOutcome.status === 'processed') processed += 1
|
|
3298
3871
|
if (eventOutcome.status === 'failed') {
|
|
3299
|
-
excluded.
|
|
3872
|
+
excluded.set(key, ref)
|
|
3300
3873
|
sawFailure = true
|
|
3301
3874
|
}
|
|
3302
3875
|
claimAgain ||= eventOutcome.opensLane || eventOutcome.appendsEvents
|
|
3303
3876
|
return eventOutcome
|
|
3304
3877
|
})
|
|
3305
3878
|
entry = {
|
|
3879
|
+
ref,
|
|
3306
3880
|
attempt: event.attemptCount,
|
|
3307
3881
|
execution,
|
|
3308
3882
|
supersede,
|
|
3309
3883
|
fence,
|
|
3310
3884
|
expiresAtMs,
|
|
3311
3885
|
lapsed: false,
|
|
3886
|
+
completing: false,
|
|
3312
3887
|
}
|
|
3313
|
-
active.set(
|
|
3888
|
+
active.set(key, entry)
|
|
3314
3889
|
scheduleLapse()
|
|
3315
3890
|
if (!heartbeat && !stopRenewing) {
|
|
3316
3891
|
heartbeat = setInterval(
|
|
@@ -3345,9 +3920,11 @@ export function createServer<
|
|
|
3345
3920
|
...(window.deadlineCapped
|
|
3346
3921
|
? { expiresAtMs: window.expiresAtMs }
|
|
3347
3922
|
: {}),
|
|
3923
|
+
consumers: only ? [storeConsumerOf(only)] : storeConsumers,
|
|
3924
|
+
...(through === undefined ? {} : { through }),
|
|
3348
3925
|
...(excluded.size === 0
|
|
3349
3926
|
? {}
|
|
3350
|
-
: {
|
|
3927
|
+
: { exclude: [...excluded.values()] }),
|
|
3351
3928
|
})
|
|
3352
3929
|
claimSpan.setAttribute('a2.claim.outcome', result.outcome)
|
|
3353
3930
|
claimSpan.setAttribute(
|
|
@@ -3367,7 +3944,8 @@ export function createServer<
|
|
|
3367
3944
|
if (event.failedAt !== null) await reportExhausted(event)
|
|
3368
3945
|
if (runnable.length > 0) requestWatchdog(window.watchdogAtMs)
|
|
3369
3946
|
if (window.deadlineCapped) stopRenewing = true
|
|
3370
|
-
for (const event of runnable)
|
|
3947
|
+
for (const event of runnable)
|
|
3948
|
+
start(event, window.expiresAtMs, claim.head)
|
|
3371
3949
|
shouldClaim = false
|
|
3372
3950
|
}
|
|
3373
3951
|
}
|
|
@@ -3413,6 +3991,12 @@ export function createServer<
|
|
|
3413
3991
|
}
|
|
3414
3992
|
if (claim === undefined) continue
|
|
3415
3993
|
if (signal && signal.version !== signalVersion) continue
|
|
3994
|
+
// A run that finished while the claim was in flight may have
|
|
3995
|
+
// moved its cursor to a head the claim's snapshot missed.
|
|
3996
|
+
if (claimAgain) {
|
|
3997
|
+
claimAgain = false
|
|
3998
|
+
continue
|
|
3999
|
+
}
|
|
3416
4000
|
if (claim.outcome === 'busy') {
|
|
3417
4001
|
requestWatchdog(
|
|
3418
4002
|
claim.retryAt.getTime() + DRAIN_TIMINGS.watchdogGraceMs,
|
|
@@ -3424,6 +4008,12 @@ export function createServer<
|
|
|
3424
4008
|
if (signal) signal.closed = true
|
|
3425
4009
|
break
|
|
3426
4010
|
}
|
|
4011
|
+
} catch (error) {
|
|
4012
|
+
// The successor armed for this drain's claims drains the session
|
|
4013
|
+
// again, so the scheduler delivery need not stay as a second retry.
|
|
4014
|
+
if (!handOffFailure || schedulerArm === undefined) throw error
|
|
4015
|
+
span.recordError(error)
|
|
4016
|
+
outcome = 'failed'
|
|
3427
4017
|
} finally {
|
|
3428
4018
|
if (signal) signal.closed = true
|
|
3429
4019
|
stopRenewing = true
|
|
@@ -3481,8 +4071,10 @@ export function createServer<
|
|
|
3481
4071
|
}) as SessionAppend<D>
|
|
3482
4072
|
return Object.assign(makeSession(id, append, null), {
|
|
3483
4073
|
deadLetters: () => listDeadLetters(id),
|
|
3484
|
-
redrive: (
|
|
3485
|
-
|
|
4074
|
+
redrive: (
|
|
4075
|
+
target: { consumer: string; index: number },
|
|
4076
|
+
redriveOptions: SessionRedriveOptions,
|
|
4077
|
+
) => redriveEvent(id, target, redriveOptions),
|
|
3486
4078
|
})
|
|
3487
4079
|
}
|
|
3488
4080
|
|
|
@@ -3525,7 +4117,10 @@ export function createServer<
|
|
|
3525
4117
|
},
|
|
3526
4118
|
schedulerDrain: (sessionId, opts) => {
|
|
3527
4119
|
assertSessionId(sessionId)
|
|
3528
|
-
return drainSession(sessionId,
|
|
4120
|
+
return drainSession(sessionId, {
|
|
4121
|
+
watchdogDueAt: opts?.watchdogDueAt,
|
|
4122
|
+
handOffFailure: true,
|
|
4123
|
+
})
|
|
3529
4124
|
},
|
|
3530
4125
|
schedulerAppend: async (sessionId, events, originIndex) => {
|
|
3531
4126
|
if (!scheduler) {
|
|
@@ -3543,7 +4138,7 @@ export function createServer<
|
|
|
3543
4138
|
originIndex,
|
|
3544
4139
|
)
|
|
3545
4140
|
} catch (error) {
|
|
3546
|
-
// The scheduling
|
|
4141
|
+
// The scheduling consumer's lifetime was cleared: an acknowledged no-op.
|
|
3547
4142
|
if (
|
|
3548
4143
|
originIndex !== undefined &&
|
|
3549
4144
|
error instanceof A2Error &&
|
|
@@ -3591,6 +4186,12 @@ function assertStateIndex(value: number): void {
|
|
|
3591
4186
|
}
|
|
3592
4187
|
}
|
|
3593
4188
|
|
|
4189
|
+
function assertConsumeIndex(value: number): void {
|
|
4190
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
4191
|
+
throw new TypeError('consume through must be a non-negative safe integer')
|
|
4192
|
+
}
|
|
4193
|
+
}
|
|
4194
|
+
|
|
3594
4195
|
function assertRedriveIndex(value: number): void {
|
|
3595
4196
|
if (!Number.isSafeInteger(value) || value < 1) {
|
|
3596
4197
|
throw new TypeError('redrive requires a positive safe-integer index')
|
|
@@ -3605,7 +4206,7 @@ function assertClearIndex(value: number): void {
|
|
|
3605
4206
|
|
|
3606
4207
|
function assertAppendName(name: string): void {
|
|
3607
4208
|
if (typeof name !== 'string' || name.length === 0) {
|
|
3608
|
-
throw new TypeError('
|
|
4209
|
+
throw new TypeError('consumer append name must be a non-empty string')
|
|
3609
4210
|
}
|
|
3610
4211
|
}
|
|
3611
4212
|
|