experimental-a2 0.20.0 → 0.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +55 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
- package/dist/actor-CqibLgjQ.d.ts.map +1 -0
- 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-DFET5q16.js.map +1 -0
- 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-DAu77CDr.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
- package/dist/ai-EEfKyc-z.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 +4 -2
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +214 -67
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
- package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
- package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +3 -3
- 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 +86 -73
- 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-BQS4i_V9.js} +29 -6
- package/dist/client-BQS4i_V9.js.map +1 -0
- 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-CvsrprnU.js} +40 -16
- package/dist/internal-CvsrprnU.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-CIeL33wj.js +12 -0
- package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.js +3 -3
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.js +3 -3
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
- package/dist/server-DtFrWxi3.js.map +1 -0
- package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
- package/dist/server-qgez0Xny.d.ts.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-D1PFe7xl.d.ts} +2 -2
- package/dist/session-clear-D1PFe7xl.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-D0MhBvg6.d.ts +532 -0
- package/dist/store-D0MhBvg6.d.ts.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- 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 +406 -225
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.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 +1598 -587
- package/dist/store-postgres.js.map +1 -1
- package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
- package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
- package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.d.ts.map +1 -1
- package/dist/store-redis.js +4 -5
- package/dist/store-redis.js.map +1 -1
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +527 -299
- package/dist/store-sqlite.js.map +1 -1
- package/dist/stream-buffer-DrTyiIuo.js +57 -0
- package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
- 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-CV-Z7wz-.js} +6 -5
- package/dist/wire-CV-Z7wz-.js.map +1 -0
- package/docs/01-quickstart.mdx +21 -15
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +13 -2
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +12 -11
- package/docs/agents/03-conversations.mdx +36 -25
- package/docs/agents/04-harnesses.mdx +14 -7
- package/docs/client/03-presence.mdx +18 -14
- 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 +29 -24
- package/docs/reference/01-core.mdx +7 -6
- package/docs/reference/02-server.mdx +250 -191
- package/docs/reference/04-ai.mdx +33 -23
- package/docs/reference/05-telemetry.mdx +27 -13
- package/docs/reference/06-errors.mdx +25 -19
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +8 -8
- package/docs/schedulers/02-vercel-queues.mdx +2 -2
- package/docs/schedulers/03-qstash.mdx +4 -4
- package/docs/server/01-consumers.mdx +418 -0
- package/docs/server/02-timers.mdx +31 -21
- 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 +61 -51
- package/docs/stores/01-overview.mdx +3 -3
- package/docs/stores/02-redis.mdx +24 -10
- package/docs/stores/03-redis-http.mdx +8 -6
- package/docs/stores/04-postgres.mdx +65 -14
- package/docs/stores/05-sqlite.mdx +5 -3
- package/docs/upgrades/01-overview.mdx +31 -0
- package/docs/upgrades/78-0-22.mdx +115 -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 +39 -7
- package/examples/playground/app/a0/chat.test.tsx +2 -2
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/sandbox.ts +58 -13
- package/examples/playground/app/a0/server.ts +4 -4
- 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 +209 -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/0004_a2_session_event_ids.sql +2 -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/0004_snapshot.json +1165 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -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/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- 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/lib/store.ts +1 -1
- package/examples/playground/next.config.ts +6 -1
- package/examples/playground/package.json +12 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +5 -2
- package/sql/postgres/drizzle-schema.ts +209 -0
- package/sql/postgres/schema.sql +227 -67
- package/src/actor-shared.ts +10 -2
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +162 -111
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +324 -82
- package/src/ai-harness.ts +17 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/client.ts +19 -2
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +27 -4
- package/src/errors.ts +8 -5
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +57 -16
- package/src/otel.ts +1 -1
- package/src/postgres-schema.ts +98 -57
- package/src/presence-recovery.ts +18 -11
- package/src/presence-tombstones.ts +52 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +1 -1
- package/src/server.browser.ts +1 -1
- package/src/server.ts +819 -218
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +604 -286
- package/src/store-postgres.ts +2006 -857
- package/src/store-presence-polling.ts +59 -34
- package/src/store-redis-core.ts +1961 -1333
- package/src/store-redis-notify.ts +76 -26
- package/src/store-redis.ts +2 -3
- package/src/store-sqlite.ts +915 -516
- package/src/store.ts +241 -102
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/actor-C7b-1arn.d.ts.map +0 -1
- package/dist/actor-shared-t7w8xIKP.js.map +0 -1
- package/dist/ai-harness-Clbg5ei7.js.map +0 -1
- package/dist/ai-mMiWul1T.js.map +0 -1
- package/dist/client-BhHLoNKF.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-DYAATFuT.d.ts.map +0 -1
- package/dist/server-qyb5aJJG.js.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-presence-polling-B1ixeqIS.js +0 -94
- package/dist/store-presence-polling-B1ixeqIS.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/stream-buffer-Cx7AAPGq.js +0 -23
- package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/server/01-handlers.mdx +0 -227
package/src/store-memory.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { A2Error, supersededAttemptError } from './errors.ts'
|
|
|
11
11
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
12
12
|
import { nullProtoRecord } from './internal.ts'
|
|
13
13
|
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
14
|
+
import { idleLogCursor } from './store-codec.ts'
|
|
14
15
|
import {
|
|
15
16
|
RANDOM_IDS,
|
|
16
17
|
SYSTEM_CLOCK,
|
|
@@ -19,8 +20,13 @@ import {
|
|
|
19
20
|
type Clock,
|
|
20
21
|
type Event,
|
|
21
22
|
type EventCause,
|
|
23
|
+
type HeadDispatch,
|
|
24
|
+
type HeadEvent,
|
|
25
|
+
type HeadRef,
|
|
22
26
|
type IdSource,
|
|
23
27
|
type PresenceRow,
|
|
28
|
+
type StoreConsumer,
|
|
29
|
+
type StoredCursor,
|
|
24
30
|
type StoredEvent,
|
|
25
31
|
type StoreStateRead,
|
|
26
32
|
} from './store.ts'
|
|
@@ -40,22 +46,35 @@ type Row = {
|
|
|
40
46
|
sessionId: string
|
|
41
47
|
createdAt: Date
|
|
42
48
|
cause: EventCause | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
type Dispatch = Omit<HeadDispatch, 'consumer' | 'lane'>
|
|
52
|
+
|
|
53
|
+
type Completion = { attempt: number; returnedEventIds: string[] }
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* One (consumer, lane) record. A log cursor reads the whole log by type, a
|
|
57
|
+
* lane cursor queues its lane's events, and a concurrent cursor (lane null)
|
|
58
|
+
* covers one event.
|
|
59
|
+
*/
|
|
60
|
+
type Cursor = {
|
|
61
|
+
consumer: string
|
|
43
62
|
lane: string | null
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
failedAt: Date | null
|
|
63
|
+
kind: 'log' | 'lane' | 'event'
|
|
64
|
+
position: number
|
|
65
|
+
head: number | null
|
|
66
|
+
dispatch: Dispatch
|
|
67
|
+
/** Lane cursors: the lane's event indexes in log order. */
|
|
68
|
+
queue: number[]
|
|
69
|
+
/** Lane cursors: where the head sits in `queue`. */
|
|
70
|
+
queued: number
|
|
71
|
+
/** Completions by head index; a log cursor keeps only its latest. */
|
|
72
|
+
completions: Map<number, Completion>
|
|
73
|
+
/** Log cursors: the types it takes, null for every type. */
|
|
74
|
+
types: readonly string[] | null
|
|
57
75
|
}
|
|
58
76
|
|
|
77
|
+
/** A null `value` is a tombstone: it keeps a delete's `at` until it expires. */
|
|
59
78
|
type PresenceEntry = {
|
|
60
79
|
value: unknown
|
|
61
80
|
seen: number
|
|
@@ -63,12 +82,6 @@ type PresenceEntry = {
|
|
|
63
82
|
expiresAt: Date
|
|
64
83
|
}
|
|
65
84
|
|
|
66
|
-
type LaneQueue = { rows: Row[]; head: number }
|
|
67
|
-
type SessionDispatch = {
|
|
68
|
-
ready: Map<number, Row>
|
|
69
|
-
lanes: Map<string, LaneQueue>
|
|
70
|
-
}
|
|
71
|
-
|
|
72
85
|
/** Contiguous rows; `reclaim` removes from the front below `clearIndex`. */
|
|
73
86
|
type SessionLog = {
|
|
74
87
|
rows: Row[]
|
|
@@ -76,6 +89,8 @@ type SessionLog = {
|
|
|
76
89
|
offset: number
|
|
77
90
|
/** Latest clear marker, or 0. */
|
|
78
91
|
clearIndex: number
|
|
92
|
+
/** The session's unique index on event ids, like the SQL schemas'. */
|
|
93
|
+
byEventId: Map<string, Row>
|
|
79
94
|
}
|
|
80
95
|
|
|
81
96
|
const RECLAIM_BATCH = 256
|
|
@@ -83,6 +98,22 @@ const RECLAIM_BATCH = 256
|
|
|
83
98
|
const headOf = (log: SessionLog | undefined): number =>
|
|
84
99
|
log ? log.offset + log.rows.length : 0
|
|
85
100
|
|
|
101
|
+
const freshDispatch = (): Dispatch => ({
|
|
102
|
+
firstClaimedAt: null,
|
|
103
|
+
lastClaimedAt: null,
|
|
104
|
+
attemptCount: 0,
|
|
105
|
+
claimHolder: null,
|
|
106
|
+
claimExpiresAt: null,
|
|
107
|
+
failureCount: 0,
|
|
108
|
+
lastFailedAt: null,
|
|
109
|
+
lastFailedAttempt: null,
|
|
110
|
+
lastError: null,
|
|
111
|
+
failedAt: null,
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
const date = (value: Date | null): Date | null =>
|
|
115
|
+
value ? new Date(value) : null
|
|
116
|
+
|
|
86
117
|
const toStored = (row: Row): StoredEvent => ({
|
|
87
118
|
id: row.id,
|
|
88
119
|
type: row.type,
|
|
@@ -91,20 +122,6 @@ const toStored = (row: Row): StoredEvent => ({
|
|
|
91
122
|
sessionId: row.sessionId,
|
|
92
123
|
createdAt: new Date(row.createdAt),
|
|
93
124
|
cause: row.cause ? { ...row.cause } : null,
|
|
94
|
-
lane: row.lane,
|
|
95
|
-
processedAt: row.processedAt ? new Date(row.processedAt) : null,
|
|
96
|
-
processedByAttempt: row.processedByAttempt,
|
|
97
|
-
returnedEventIds: row.returnedEventIds ? [...row.returnedEventIds] : null,
|
|
98
|
-
firstClaimedAt: row.firstClaimedAt ? new Date(row.firstClaimedAt) : null,
|
|
99
|
-
lastClaimedAt: row.lastClaimedAt ? new Date(row.lastClaimedAt) : null,
|
|
100
|
-
attemptCount: row.attemptCount,
|
|
101
|
-
claimHolder: row.claimHolder,
|
|
102
|
-
claimExpiresAt: row.claimExpiresAt ? new Date(row.claimExpiresAt) : null,
|
|
103
|
-
failureCount: row.failureCount,
|
|
104
|
-
lastFailedAt: row.lastFailedAt ? new Date(row.lastFailedAt) : null,
|
|
105
|
-
lastFailedAttempt: row.lastFailedAttempt,
|
|
106
|
-
lastError: row.lastError,
|
|
107
|
-
failedAt: row.failedAt ? new Date(row.failedAt) : null,
|
|
108
125
|
})
|
|
109
126
|
|
|
110
127
|
const toEvent = (row: Row): Event => ({
|
|
@@ -116,20 +133,95 @@ const toEvent = (row: Row): Event => ({
|
|
|
116
133
|
createdAt: new Date(row.createdAt),
|
|
117
134
|
})
|
|
118
135
|
|
|
136
|
+
const toHead = (cursor: Cursor, row: Row): HeadEvent => ({
|
|
137
|
+
...toStored(row),
|
|
138
|
+
consumer: cursor.consumer,
|
|
139
|
+
lane: cursor.lane,
|
|
140
|
+
firstClaimedAt: date(cursor.dispatch.firstClaimedAt),
|
|
141
|
+
lastClaimedAt: date(cursor.dispatch.lastClaimedAt),
|
|
142
|
+
attemptCount: cursor.dispatch.attemptCount,
|
|
143
|
+
claimHolder: cursor.dispatch.claimHolder,
|
|
144
|
+
claimExpiresAt: date(cursor.dispatch.claimExpiresAt),
|
|
145
|
+
failureCount: cursor.dispatch.failureCount,
|
|
146
|
+
lastFailedAt: date(cursor.dispatch.lastFailedAt),
|
|
147
|
+
lastFailedAttempt: cursor.dispatch.lastFailedAttempt,
|
|
148
|
+
lastError: cursor.dispatch.lastError,
|
|
149
|
+
failedAt: date(cursor.dispatch.failedAt),
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
const toCursor = (cursor: Cursor): StoredCursor => ({
|
|
153
|
+
consumer: cursor.consumer,
|
|
154
|
+
lane: cursor.lane,
|
|
155
|
+
position: cursor.position,
|
|
156
|
+
head: cursor.head,
|
|
157
|
+
firstClaimedAt: date(cursor.dispatch.firstClaimedAt),
|
|
158
|
+
lastClaimedAt: date(cursor.dispatch.lastClaimedAt),
|
|
159
|
+
attemptCount: cursor.dispatch.attemptCount,
|
|
160
|
+
claimHolder: cursor.dispatch.claimHolder,
|
|
161
|
+
claimExpiresAt: date(cursor.dispatch.claimExpiresAt),
|
|
162
|
+
failureCount: cursor.dispatch.failureCount,
|
|
163
|
+
lastFailedAt: date(cursor.dispatch.lastFailedAt),
|
|
164
|
+
lastFailedAttempt: cursor.dispatch.lastFailedAttempt,
|
|
165
|
+
lastError: cursor.dispatch.lastError,
|
|
166
|
+
failedAt: date(cursor.dispatch.failedAt),
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
const compareText = (a: string, b: string): number =>
|
|
170
|
+
a < b ? -1 : a > b ? 1 : 0
|
|
171
|
+
|
|
172
|
+
/** Consumer, then lane, with concurrent cursors last by head. */
|
|
173
|
+
const byCursor = (a: StoredCursor, b: StoredCursor): number =>
|
|
174
|
+
compareText(a.consumer, b.consumer) ||
|
|
175
|
+
(a.lane === null
|
|
176
|
+
? b.lane === null
|
|
177
|
+
? (a.head ?? 0) - (b.head ?? 0)
|
|
178
|
+
: 1
|
|
179
|
+
: b.lane === null
|
|
180
|
+
? -1
|
|
181
|
+
: compareText(a.lane, b.lane))
|
|
182
|
+
|
|
183
|
+
const cursorKey = (
|
|
184
|
+
consumer: string,
|
|
185
|
+
lane: string | null,
|
|
186
|
+
index: number,
|
|
187
|
+
): string =>
|
|
188
|
+
lane === null ? `${consumer}\u0000#${index}` : `${consumer}\u0000L${lane}`
|
|
189
|
+
|
|
190
|
+
const takes = (types: readonly string[] | null, type: string): boolean =>
|
|
191
|
+
type !== CLEAR_EVENT_TYPE && (types === null || types.includes(type))
|
|
192
|
+
|
|
193
|
+
const byHead = (a: HeadEvent, b: HeadEvent): number =>
|
|
194
|
+
a.index - b.index || compareText(a.consumer, b.consumer)
|
|
195
|
+
|
|
119
196
|
const snapshotKey = (sessionId: string, reducerName: string): string =>
|
|
120
197
|
`${sessionId}\u0000${reducerName}`
|
|
121
198
|
|
|
122
|
-
const
|
|
123
|
-
`${sessionId}\u0000${index}`
|
|
199
|
+
const headPinKey = (sessionId: string, ref: HeadRef): string =>
|
|
200
|
+
`${sessionId}\u0000${cursorKey(ref.consumer, ref.lane, ref.index)}\u0000${ref.index}`
|
|
201
|
+
|
|
202
|
+
/** Whether a cursor belongs to a consumer the caller serves; omitted, every one. */
|
|
203
|
+
const servedBy = (
|
|
204
|
+
consumers: readonly StoreConsumer[] | undefined,
|
|
205
|
+
): ((cursor: Cursor) => boolean) => {
|
|
206
|
+
if (!consumers) return () => true
|
|
207
|
+
const names = new Set(consumers.map((consumer) => consumer.name))
|
|
208
|
+
return (cursor) => names.has(cursor.consumer)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A cursor's current head. */
|
|
212
|
+
const refOf = (cursor: Cursor): HeadRef => ({
|
|
213
|
+
consumer: cursor.consumer,
|
|
214
|
+
lane: cursor.lane,
|
|
215
|
+
index: cursor.head!,
|
|
216
|
+
})
|
|
124
217
|
|
|
125
218
|
export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
126
219
|
const clock = options.clock ?? SYSTEM_CLOCK
|
|
127
220
|
const generateId = options.ids ?? RANDOM_IDS
|
|
128
221
|
|
|
129
222
|
const sessions = new Map<string, SessionLog>()
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const byEventId = new Map<string, Row>()
|
|
223
|
+
/** Session id → cursor key → cursor. */
|
|
224
|
+
const cursors = new Map<string, Map<string, Cursor>>()
|
|
133
225
|
/** Keyed by `${sessionId}\u0000${reducerName}` — a pure cache. */
|
|
134
226
|
const snapshots = new Map<
|
|
135
227
|
string,
|
|
@@ -163,7 +255,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
163
255
|
const logOf = (sessionId: string): SessionLog => {
|
|
164
256
|
let log = sessions.get(sessionId)
|
|
165
257
|
if (!log) {
|
|
166
|
-
log = { rows: [], offset: 0, clearIndex: 0 }
|
|
258
|
+
log = { rows: [], offset: 0, clearIndex: 0, byEventId: new Map() }
|
|
167
259
|
sessions.set(sessionId, log)
|
|
168
260
|
}
|
|
169
261
|
return log
|
|
@@ -179,22 +271,33 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
179
271
|
const clearIndexOf = (sessionId: string): number =>
|
|
180
272
|
sessions.get(sessionId)?.clearIndex ?? 0
|
|
181
273
|
|
|
182
|
-
/** The row owning `id`, ignoring
|
|
274
|
+
/** The session's row owning `id`, ignoring rows below its clear. */
|
|
183
275
|
const ownerOf = (sessionId: string, id: string): Row | undefined => {
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
276
|
+
const log = sessions.get(sessionId)
|
|
277
|
+
const row = log?.byEventId.get(id)
|
|
278
|
+
return log && row && row.index >= log.clearIndex ? row : undefined
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const cursorsOf = (sessionId: string): Map<string, Cursor> => {
|
|
282
|
+
let map = cursors.get(sessionId)
|
|
283
|
+
if (!map) {
|
|
284
|
+
map = new Map()
|
|
285
|
+
cursors.set(sessionId, map)
|
|
286
|
+
}
|
|
287
|
+
return map
|
|
188
288
|
}
|
|
189
289
|
|
|
290
|
+
const cursorAt = (sessionId: string, ref: HeadRef): Cursor | undefined =>
|
|
291
|
+
cursors.get(sessionId)?.get(cursorKey(ref.consumer, ref.lane, ref.index))
|
|
292
|
+
|
|
190
293
|
const checkpointKey = (
|
|
191
294
|
sessionId: string,
|
|
192
295
|
reducerName: string,
|
|
193
296
|
index: number,
|
|
194
297
|
): string => `${snapshotKey(sessionId, reducerName)}\u0000${index}`
|
|
195
298
|
|
|
196
|
-
const releaseSnapshotPins = (sessionId: string,
|
|
197
|
-
const pinKey =
|
|
299
|
+
const releaseSnapshotPins = (sessionId: string, ref: HeadRef): void => {
|
|
300
|
+
const pinKey = headPinKey(sessionId, ref)
|
|
198
301
|
const checkpoints = snapshotPins.get(pinKey)
|
|
199
302
|
if (!checkpoints) return
|
|
200
303
|
for (const key of checkpoints) {
|
|
@@ -209,114 +312,114 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
209
312
|
snapshotPins.delete(pinKey)
|
|
210
313
|
}
|
|
211
314
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
return
|
|
227
|
-
}
|
|
228
|
-
let queue = state.lanes.get(row.lane)
|
|
229
|
-
if (!queue) {
|
|
230
|
-
queue = { rows: [], head: 0 }
|
|
231
|
-
state.lanes.set(row.lane, queue)
|
|
315
|
+
/** The next event after `after` that a log cursor takes, or null. */
|
|
316
|
+
const nextLogHead = (
|
|
317
|
+
sessionId: string,
|
|
318
|
+
after: number,
|
|
319
|
+
types: readonly string[] | null,
|
|
320
|
+
): number | null => {
|
|
321
|
+
const log = sessions.get(sessionId)
|
|
322
|
+
if (!log) return null
|
|
323
|
+
for (
|
|
324
|
+
let offset = Math.max(0, after - log.offset);
|
|
325
|
+
offset < log.rows.length;
|
|
326
|
+
offset += 1
|
|
327
|
+
) {
|
|
328
|
+
const row = log.rows[offset]!
|
|
329
|
+
if (takes(types, row.type)) return row.index
|
|
232
330
|
}
|
|
233
|
-
|
|
234
|
-
if (queue.rows.length === 1) state.ready.set(row.index, row)
|
|
331
|
+
return null
|
|
235
332
|
}
|
|
236
333
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
334
|
+
/** Moves a cursor past its head: the next head starts with fresh bookkeeping. */
|
|
335
|
+
const advance = (sessionId: string, cursor: Cursor): void => {
|
|
336
|
+
const done = cursor.head!
|
|
337
|
+
releaseSnapshotPins(sessionId, refOf(cursor))
|
|
338
|
+
cursor.position = done
|
|
339
|
+
cursor.dispatch = freshDispatch()
|
|
340
|
+
if (cursor.kind === 'event') {
|
|
341
|
+
cursor.head = null
|
|
342
|
+
} else if (cursor.kind === 'lane') {
|
|
343
|
+
cursor.queued += 1
|
|
344
|
+
cursor.head = cursor.queue[cursor.queued] ?? null
|
|
345
|
+
} else {
|
|
346
|
+
cursor.head = nextLogHead(sessionId, done, cursor.types)
|
|
247
347
|
}
|
|
248
|
-
const next = queue.rows[queue.head]!
|
|
249
|
-
if (next.failedAt === null) state.ready.set(next.index, next)
|
|
250
348
|
}
|
|
251
349
|
|
|
252
|
-
const
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
350
|
+
const hasPending = (
|
|
351
|
+
sessionId: string,
|
|
352
|
+
consumers: readonly StoreConsumer[] | undefined,
|
|
353
|
+
): boolean => {
|
|
354
|
+
const served = servedBy(consumers)
|
|
355
|
+
for (const cursor of cursors.get(sessionId)?.values() ?? []) {
|
|
356
|
+
if (
|
|
357
|
+
cursor.head !== null &&
|
|
358
|
+
cursor.dispatch.failedAt === null &&
|
|
359
|
+
served(cursor)
|
|
360
|
+
) {
|
|
361
|
+
return true
|
|
362
|
+
}
|
|
258
363
|
}
|
|
259
|
-
return
|
|
364
|
+
return false
|
|
260
365
|
}
|
|
261
366
|
|
|
262
|
-
|
|
263
|
-
const log = sessions.get(sessionId)
|
|
264
|
-
return (log?.rows ?? []).some(
|
|
265
|
-
(row) => row.index > log!.clearIndex && row.processedAt === null,
|
|
266
|
-
)
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
const appendResult = (sessionId: string, events: StoredEvent[]) => ({
|
|
270
|
-
events,
|
|
271
|
-
hasPending: hasPending(sessionId),
|
|
272
|
-
})
|
|
273
|
-
|
|
274
|
-
/** The attempt-currency fence shared by handler appends and clears. */
|
|
367
|
+
/** The attempt-currency fence shared by consumer appends and clears. */
|
|
275
368
|
const assertCurrentCause = (sessionId: string, cause: EventCause): void => {
|
|
276
369
|
if (cause.index <= clearIndexOf(sessionId)) {
|
|
277
370
|
throw supersededAttemptError(sessionId, cause)
|
|
278
371
|
}
|
|
279
|
-
const
|
|
280
|
-
if (
|
|
372
|
+
const cursor = cursorAt(sessionId, cause)
|
|
373
|
+
if (
|
|
374
|
+
!cursor ||
|
|
375
|
+
cursor.head !== cause.index ||
|
|
376
|
+
cursor.dispatch.attemptCount !== cause.attempt ||
|
|
377
|
+
cursor.dispatch.failedAt !== null
|
|
378
|
+
) {
|
|
281
379
|
throw supersededAttemptError(sessionId, cause)
|
|
282
380
|
}
|
|
283
381
|
}
|
|
284
382
|
|
|
383
|
+
const deadLetter = (sessionId: string, cursor: Cursor, now: Date): void => {
|
|
384
|
+
cursor.dispatch.failedAt = new Date(now)
|
|
385
|
+
releaseSnapshotPins(sessionId, refOf(cursor))
|
|
386
|
+
}
|
|
387
|
+
|
|
285
388
|
const claim = ({
|
|
286
389
|
sessionId,
|
|
287
|
-
|
|
390
|
+
cursor,
|
|
288
391
|
holder,
|
|
289
392
|
now,
|
|
290
393
|
expiresAt,
|
|
291
394
|
maxFailures,
|
|
292
395
|
}: {
|
|
293
396
|
sessionId: string
|
|
294
|
-
|
|
397
|
+
cursor: Cursor
|
|
295
398
|
holder: string
|
|
296
399
|
now: Date
|
|
297
400
|
expiresAt: Date
|
|
298
401
|
maxFailures: number | undefined
|
|
299
|
-
}):
|
|
402
|
+
}): HeadEvent => {
|
|
403
|
+
const dispatch = cursor.dispatch
|
|
404
|
+
const row = rowAt(sessionId, cursor.head!)!
|
|
300
405
|
// A claim still held here lapsed: its attempt never finished.
|
|
301
|
-
if (
|
|
302
|
-
|
|
303
|
-
if (maxFailures !== undefined &&
|
|
406
|
+
if (dispatch.claimHolder !== null) {
|
|
407
|
+
dispatch.failureCount += 1
|
|
408
|
+
if (maxFailures !== undefined && dispatch.failureCount >= maxFailures) {
|
|
304
409
|
// Advancing the attempt fences the lapsed one; nothing runs.
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
releaseSnapshotPins(sessionId, row.index)
|
|
311
|
-
return toStored(row)
|
|
410
|
+
dispatch.attemptCount += 1
|
|
411
|
+
dispatch.claimHolder = null
|
|
412
|
+
dispatch.claimExpiresAt = null
|
|
413
|
+
deadLetter(sessionId, cursor, now)
|
|
414
|
+
return toHead(cursor, row)
|
|
312
415
|
}
|
|
313
416
|
}
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
return
|
|
417
|
+
dispatch.attemptCount += 1
|
|
418
|
+
dispatch.firstClaimedAt ??= new Date(now)
|
|
419
|
+
dispatch.lastClaimedAt = new Date(now)
|
|
420
|
+
dispatch.claimHolder = holder
|
|
421
|
+
dispatch.claimExpiresAt = new Date(expiresAt)
|
|
422
|
+
return toHead(cursor, row)
|
|
320
423
|
}
|
|
321
424
|
|
|
322
425
|
const checkBatchIds = (
|
|
@@ -334,23 +437,110 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
334
437
|
'batch contains the same event id more than once',
|
|
335
438
|
)
|
|
336
439
|
}
|
|
337
|
-
|
|
440
|
+
return supplied
|
|
338
441
|
.map((candidate) => ownerOf(sessionId, candidate.id))
|
|
339
442
|
.filter((row): row is Row => row !== undefined)
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Records the types a declared log consumer takes on its cursor. */
|
|
446
|
+
const refreshLogTypes = (
|
|
447
|
+
sessionId: string,
|
|
448
|
+
consumers: readonly StoreConsumer[] | undefined,
|
|
449
|
+
): void => {
|
|
450
|
+
const map = cursors.get(sessionId)
|
|
451
|
+
if (!map) return
|
|
452
|
+
for (const consumer of consumers ?? []) {
|
|
453
|
+
if (!consumer.log) continue
|
|
454
|
+
const cursor = map.get(cursorKey(consumer.name, consumer.log.lane, 0))
|
|
455
|
+
if (cursor?.kind === 'log') cursor.types = consumer.log.types
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Starts the cursor work an inserted batch carries. */
|
|
460
|
+
const enqueue = (
|
|
461
|
+
sessionId: string,
|
|
462
|
+
rows: readonly Row[],
|
|
463
|
+
events: readonly AppendEvent[],
|
|
464
|
+
base: number,
|
|
465
|
+
consumers: readonly StoreConsumer[] | undefined,
|
|
466
|
+
): void => {
|
|
467
|
+
const map = cursorsOf(sessionId)
|
|
468
|
+
rows.forEach((row, offset) => {
|
|
469
|
+
for (const target of events[offset]!.targets ?? []) {
|
|
470
|
+
const key = cursorKey(target.consumer, target.lane, row.index)
|
|
471
|
+
if (target.lane === null) {
|
|
472
|
+
map.set(key, {
|
|
473
|
+
consumer: target.consumer,
|
|
474
|
+
lane: null,
|
|
475
|
+
kind: 'event',
|
|
476
|
+
position: row.index - 1,
|
|
477
|
+
head: row.index,
|
|
478
|
+
dispatch: freshDispatch(),
|
|
479
|
+
queue: [],
|
|
480
|
+
queued: 0,
|
|
481
|
+
completions: new Map(),
|
|
482
|
+
types: null,
|
|
483
|
+
})
|
|
484
|
+
continue
|
|
485
|
+
}
|
|
486
|
+
let cursor = map.get(key)
|
|
487
|
+
if (!cursor) {
|
|
488
|
+
cursor = {
|
|
489
|
+
consumer: target.consumer,
|
|
490
|
+
lane: target.lane,
|
|
491
|
+
kind: 'lane',
|
|
492
|
+
position: clearIndexOf(sessionId),
|
|
493
|
+
head: null,
|
|
494
|
+
dispatch: freshDispatch(),
|
|
495
|
+
queue: [],
|
|
496
|
+
queued: 0,
|
|
497
|
+
completions: new Map(),
|
|
498
|
+
types: null,
|
|
499
|
+
}
|
|
500
|
+
map.set(key, cursor)
|
|
501
|
+
}
|
|
502
|
+
cursor.queue.push(row.index)
|
|
503
|
+
cursor.head ??= row.index
|
|
504
|
+
}
|
|
505
|
+
})
|
|
506
|
+
const clearIndex = clearIndexOf(sessionId)
|
|
507
|
+
for (const consumer of consumers ?? []) {
|
|
508
|
+
const log = consumer.log
|
|
509
|
+
if (!log) continue
|
|
510
|
+
const first = rows.find((row) => takes(log.types, row.type))
|
|
511
|
+
if (!first) continue
|
|
512
|
+
const key = cursorKey(consumer.name, log.lane, 0)
|
|
513
|
+
const cursor = map.get(key)
|
|
514
|
+
if (!cursor) {
|
|
515
|
+
const position = log.backfill ? clearIndex : base
|
|
516
|
+
map.set(key, {
|
|
517
|
+
consumer: consumer.name,
|
|
518
|
+
lane: log.lane,
|
|
519
|
+
kind: 'log',
|
|
520
|
+
position,
|
|
521
|
+
head: log.backfill
|
|
522
|
+
? nextLogHead(sessionId, position, log.types)
|
|
523
|
+
: first.index,
|
|
524
|
+
dispatch: freshDispatch(),
|
|
525
|
+
queue: [],
|
|
526
|
+
queued: 0,
|
|
527
|
+
completions: new Map(),
|
|
528
|
+
types: log.types,
|
|
529
|
+
})
|
|
530
|
+
} else {
|
|
531
|
+
cursor.types = log.types
|
|
532
|
+
// Not `first`: a deployment that doesn't serve this consumer may
|
|
533
|
+
// have appended events it takes since it went idle.
|
|
534
|
+
cursor.head ??= nextLogHead(sessionId, cursor.position, log.types)
|
|
535
|
+
}
|
|
346
536
|
}
|
|
347
|
-
return existing
|
|
348
537
|
}
|
|
349
538
|
|
|
350
539
|
const insert = (
|
|
351
540
|
sessionId: string,
|
|
352
541
|
events: readonly AppendEvent[],
|
|
353
542
|
now: Date,
|
|
543
|
+
consumers: readonly StoreConsumer[] | undefined,
|
|
354
544
|
cause?: EventCause,
|
|
355
545
|
): Row[] => {
|
|
356
546
|
const log = logOf(sessionId)
|
|
@@ -367,20 +557,6 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
367
557
|
: candidate.cause
|
|
368
558
|
? { ...candidate.cause }
|
|
369
559
|
: null,
|
|
370
|
-
lane: candidate.lane ?? null,
|
|
371
|
-
processedAt: candidate.settled ? new Date(now) : null,
|
|
372
|
-
processedByAttempt: null,
|
|
373
|
-
returnedEventIds: null,
|
|
374
|
-
firstClaimedAt: null,
|
|
375
|
-
lastClaimedAt: null,
|
|
376
|
-
attemptCount: 0,
|
|
377
|
-
claimHolder: null,
|
|
378
|
-
claimExpiresAt: null,
|
|
379
|
-
failureCount: 0,
|
|
380
|
-
lastFailedAt: null,
|
|
381
|
-
lastFailedAttempt: null,
|
|
382
|
-
lastError: null,
|
|
383
|
-
failedAt: null,
|
|
384
560
|
}))
|
|
385
561
|
for (const row of inserted) {
|
|
386
562
|
if (ownerOf(sessionId, row.id)) {
|
|
@@ -392,9 +568,9 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
392
568
|
}
|
|
393
569
|
for (const row of inserted) {
|
|
394
570
|
log.rows.push(row)
|
|
395
|
-
byEventId.set(row.id, row)
|
|
396
|
-
enqueue(sessionId, row)
|
|
571
|
+
log.byEventId.set(row.id, row)
|
|
397
572
|
}
|
|
573
|
+
enqueue(sessionId, inserted, events, base, consumers)
|
|
398
574
|
return inserted
|
|
399
575
|
}
|
|
400
576
|
|
|
@@ -462,9 +638,31 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
462
638
|
}
|
|
463
639
|
}
|
|
464
640
|
|
|
641
|
+
/** The dead-lettered head a redrive names, or undefined. */
|
|
642
|
+
const deadHeadOf = (
|
|
643
|
+
sessionId: string,
|
|
644
|
+
consumer: string,
|
|
645
|
+
index: number,
|
|
646
|
+
): Cursor | undefined => {
|
|
647
|
+
if (index <= clearIndexOf(sessionId)) return undefined
|
|
648
|
+
for (const cursor of cursors.get(sessionId)?.values() ?? []) {
|
|
649
|
+
if (
|
|
650
|
+
cursor.consumer === consumer &&
|
|
651
|
+
cursor.head === index &&
|
|
652
|
+
cursor.dispatch.failedAt !== null
|
|
653
|
+
) {
|
|
654
|
+
return cursor
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return undefined
|
|
658
|
+
}
|
|
659
|
+
|
|
465
660
|
return {
|
|
466
661
|
async append(sessionId, events, appendOptions) {
|
|
467
|
-
|
|
662
|
+
const consumers = appendOptions?.consumers
|
|
663
|
+
if (events.length === 0) {
|
|
664
|
+
return { events: [], hasPending: hasPending(sessionId, consumers) }
|
|
665
|
+
}
|
|
468
666
|
|
|
469
667
|
// Batch-scoped idempotency (a2-implementation.md §3): all ids
|
|
470
668
|
// already present → lost-ack retry, return the original rows;
|
|
@@ -472,10 +670,10 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
472
670
|
const existing = checkBatchIds(sessionId, events)
|
|
473
671
|
if (existing.length > 0) {
|
|
474
672
|
if (existing.length === events.length) {
|
|
475
|
-
return
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
673
|
+
return {
|
|
674
|
+
events: idempotentReplay(events, existing.map(toStored)),
|
|
675
|
+
hasPending: hasPending(sessionId, consumers),
|
|
676
|
+
}
|
|
479
677
|
}
|
|
480
678
|
throw new A2Error(
|
|
481
679
|
'PARTIAL_DUPLICATE_BATCH',
|
|
@@ -489,16 +687,18 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
489
687
|
`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
|
|
490
688
|
)
|
|
491
689
|
}
|
|
492
|
-
// Attempt-currency fence: a fresh
|
|
493
|
-
// its causal attempt
|
|
690
|
+
// Attempt-currency fence: a fresh consumer append commits only while
|
|
691
|
+
// its causal attempt still owns the cursor's head (store.ts `append`).
|
|
494
692
|
for (const event of events) {
|
|
495
693
|
if (event.cause) assertCurrentCause(sessionId, event.cause)
|
|
496
694
|
}
|
|
497
695
|
|
|
498
|
-
const
|
|
499
|
-
const inserted = insert(sessionId, events, now)
|
|
696
|
+
const inserted = insert(sessionId, events, clock.now(), consumers)
|
|
500
697
|
notifyStreams(sessionId, inserted)
|
|
501
|
-
return
|
|
698
|
+
return {
|
|
699
|
+
events: inserted.map(toStored),
|
|
700
|
+
hasPending: hasPending(sessionId, consumers),
|
|
701
|
+
}
|
|
502
702
|
},
|
|
503
703
|
|
|
504
704
|
async clear({ sessionId, ifLastIndex, cause }) {
|
|
@@ -517,12 +717,25 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
517
717
|
}
|
|
518
718
|
const [marker] = insert(
|
|
519
719
|
sessionId,
|
|
520
|
-
[{ type: CLEAR_EVENT_TYPE, payload: null
|
|
720
|
+
[{ type: CLEAR_EVENT_TYPE, payload: null }],
|
|
521
721
|
clock.now(),
|
|
722
|
+
undefined,
|
|
522
723
|
)
|
|
523
724
|
logOf(sessionId).clearIndex = marker!.index
|
|
524
|
-
// Every
|
|
525
|
-
|
|
725
|
+
// Every event is at or below the marker: no head survives, and log
|
|
726
|
+
// cursors count from the marker.
|
|
727
|
+
const map = cursors.get(sessionId)
|
|
728
|
+
for (const [key, cursor] of map ?? []) {
|
|
729
|
+
if (cursor.head !== null) releaseSnapshotPins(sessionId, refOf(cursor))
|
|
730
|
+
if (cursor.kind !== 'log') {
|
|
731
|
+
map!.delete(key)
|
|
732
|
+
continue
|
|
733
|
+
}
|
|
734
|
+
cursor.position = marker!.index
|
|
735
|
+
cursor.head = null
|
|
736
|
+
cursor.dispatch = freshDispatch()
|
|
737
|
+
cursor.completions.clear()
|
|
738
|
+
}
|
|
526
739
|
notifyStreams(sessionId, [marker!])
|
|
527
740
|
return { cleared: true, index: marker!.index, clearIndex: marker!.index }
|
|
528
741
|
},
|
|
@@ -539,8 +752,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
539
752
|
log.offset += removed.length
|
|
540
753
|
for (const row of removed) {
|
|
541
754
|
// A reused id belongs to its new row now.
|
|
542
|
-
if (byEventId.get(row.id) === row) byEventId.delete(row.id)
|
|
543
|
-
releaseSnapshotPins(sessionId, row.index)
|
|
755
|
+
if (log.byEventId.get(row.id) === row) log.byEventId.delete(row.id)
|
|
544
756
|
}
|
|
545
757
|
const done = log.offset === log.clearIndex - 1
|
|
546
758
|
// Writes below the marker are dropped, so one sweep at the end suffices.
|
|
@@ -548,6 +760,49 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
548
760
|
return { deleted: removed.length, done }
|
|
549
761
|
},
|
|
550
762
|
|
|
763
|
+
async wakeLog({ sessionId, consumer }) {
|
|
764
|
+
const { log } = consumer
|
|
765
|
+
const key = cursorKey(consumer.name, log.lane, 0)
|
|
766
|
+
const head = headOf(sessions.get(sessionId))
|
|
767
|
+
let cursor = cursors.get(sessionId)?.get(key)
|
|
768
|
+
if (!cursor) {
|
|
769
|
+
const marker = clearIndexOf(sessionId)
|
|
770
|
+
if (head === marker) {
|
|
771
|
+
return {
|
|
772
|
+
cursor: idleLogCursor(consumer.name, log.lane, marker),
|
|
773
|
+
head,
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
const position = log.backfill ? marker : head
|
|
777
|
+
cursor = {
|
|
778
|
+
consumer: consumer.name,
|
|
779
|
+
lane: log.lane,
|
|
780
|
+
kind: 'log',
|
|
781
|
+
position,
|
|
782
|
+
head: log.backfill
|
|
783
|
+
? nextLogHead(sessionId, position, log.types)
|
|
784
|
+
: null,
|
|
785
|
+
dispatch: freshDispatch(),
|
|
786
|
+
queue: [],
|
|
787
|
+
queued: 0,
|
|
788
|
+
completions: new Map(),
|
|
789
|
+
types: log.types,
|
|
790
|
+
}
|
|
791
|
+
cursorsOf(sessionId).set(key, cursor)
|
|
792
|
+
} else {
|
|
793
|
+
cursor.types = log.types
|
|
794
|
+
cursor.head ??= nextLogHead(sessionId, cursor.position, log.types)
|
|
795
|
+
}
|
|
796
|
+
return { cursor: toCursor(cursor), head }
|
|
797
|
+
},
|
|
798
|
+
|
|
799
|
+
async cursors(sessionId) {
|
|
800
|
+
return [...(cursors.get(sessionId)?.values() ?? [])]
|
|
801
|
+
.filter((cursor) => cursor.kind !== 'event' || cursor.head !== null)
|
|
802
|
+
.map(toCursor)
|
|
803
|
+
.toSorted(byCursor)
|
|
804
|
+
},
|
|
805
|
+
|
|
551
806
|
async read(sessionId, opts) {
|
|
552
807
|
const log = sessions.get(sessionId)
|
|
553
808
|
if (!log) return []
|
|
@@ -569,46 +824,64 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
569
824
|
holder,
|
|
570
825
|
ttlMs,
|
|
571
826
|
expiresAtMs,
|
|
572
|
-
|
|
827
|
+
exclude,
|
|
828
|
+
consumers,
|
|
573
829
|
maxFailures,
|
|
830
|
+
through,
|
|
574
831
|
}) {
|
|
832
|
+
refreshLogTypes(sessionId, consumers)
|
|
575
833
|
const claimedAt = clock.now()
|
|
576
834
|
const now = claimedAt.getTime()
|
|
577
835
|
const expiresAt = new Date(expiresAtMs ?? now + ttlMs)
|
|
578
|
-
const excluded = new Set(
|
|
579
|
-
|
|
836
|
+
const excluded = new Set(
|
|
837
|
+
(exclude ?? []).map((ref) =>
|
|
838
|
+
cursorKey(ref.consumer, ref.lane, ref.index),
|
|
839
|
+
),
|
|
840
|
+
)
|
|
841
|
+
const served = servedBy(consumers)
|
|
842
|
+
const eligible: Cursor[] = []
|
|
580
843
|
let retryAt: Date | null = null
|
|
581
844
|
|
|
582
|
-
for (const
|
|
583
|
-
if (
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
845
|
+
for (const [key, cursor] of cursors.get(sessionId) ?? []) {
|
|
846
|
+
if (
|
|
847
|
+
cursor.head === null ||
|
|
848
|
+
(through !== undefined && cursor.head > through) ||
|
|
849
|
+
cursor.dispatch.failedAt !== null ||
|
|
850
|
+
!served(cursor) ||
|
|
851
|
+
excluded.has(key)
|
|
852
|
+
) {
|
|
853
|
+
continue
|
|
854
|
+
}
|
|
855
|
+
const expiry = cursor.dispatch.claimExpiresAt
|
|
856
|
+
if (expiry && expiry.getTime() > now) {
|
|
857
|
+
if (!retryAt || expiry < retryAt) retryAt = new Date(expiry)
|
|
588
858
|
continue
|
|
589
859
|
}
|
|
590
|
-
eligible.push(
|
|
860
|
+
eligible.push(cursor)
|
|
591
861
|
}
|
|
592
862
|
|
|
593
863
|
if (eligible.length === 0) {
|
|
594
864
|
return retryAt ? { outcome: 'busy', retryAt } : { outcome: 'settled' }
|
|
595
865
|
}
|
|
596
|
-
eligible.sort((a, b) => a.index - b.index)
|
|
597
866
|
const clearIndex = clearIndexOf(sessionId)
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const claimed = claim({
|
|
867
|
+
const claimed = eligible
|
|
868
|
+
.map((cursor) => {
|
|
869
|
+
const head = claim({
|
|
602
870
|
sessionId,
|
|
603
|
-
|
|
871
|
+
cursor,
|
|
604
872
|
holder,
|
|
605
873
|
now: claimedAt,
|
|
606
874
|
expiresAt,
|
|
607
875
|
maxFailures,
|
|
608
876
|
})
|
|
609
|
-
if (clearIndex > 0)
|
|
610
|
-
return
|
|
611
|
-
})
|
|
877
|
+
if (clearIndex > 0) head.clearIndex = clearIndex
|
|
878
|
+
return head
|
|
879
|
+
})
|
|
880
|
+
.toSorted(byHead)
|
|
881
|
+
return {
|
|
882
|
+
outcome: 'claimed',
|
|
883
|
+
events: claimed,
|
|
884
|
+
head: headOf(sessions.get(sessionId)),
|
|
612
885
|
}
|
|
613
886
|
},
|
|
614
887
|
|
|
@@ -616,48 +889,69 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
616
889
|
const now = clock.now()
|
|
617
890
|
const expiresAt = new Date(expiresAtMs ?? now.getTime() + ttlMs)
|
|
618
891
|
const clearIndex = clearIndexOf(sessionId)
|
|
619
|
-
const renewed:
|
|
620
|
-
const superseded:
|
|
621
|
-
|
|
622
|
-
(a, b) => a.index - b.index,
|
|
623
|
-
)
|
|
624
|
-
|
|
625
|
-
|
|
892
|
+
const renewed: HeadRef[] = []
|
|
893
|
+
const superseded: HeadRef[] = []
|
|
894
|
+
const ordered = claims.toSorted(
|
|
895
|
+
(a, b) => a.index - b.index || compareText(a.consumer, b.consumer),
|
|
896
|
+
)
|
|
897
|
+
for (const { attempt, ...ref } of ordered) {
|
|
898
|
+
if (ref.index <= clearIndex) {
|
|
899
|
+
superseded.push(ref)
|
|
626
900
|
continue
|
|
627
901
|
}
|
|
628
|
-
const
|
|
629
|
-
if (!
|
|
630
|
-
|
|
631
|
-
|
|
902
|
+
const cursor = cursorAt(sessionId, ref)
|
|
903
|
+
if (!cursor) continue
|
|
904
|
+
const dispatch = cursor.dispatch
|
|
905
|
+
// An event the cursor has not reached yet holds no claim to renew.
|
|
906
|
+
if (cursor.head !== ref.index && cursor.position < ref.index) continue
|
|
907
|
+
if (cursor.head !== ref.index || dispatch.attemptCount > attempt) {
|
|
908
|
+
superseded.push(ref)
|
|
632
909
|
continue
|
|
633
910
|
}
|
|
634
911
|
if (
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
row.claimExpiresAt > now
|
|
912
|
+
dispatch.failedAt === null &&
|
|
913
|
+
dispatch.claimHolder === holder &&
|
|
914
|
+
dispatch.claimExpiresAt !== null &&
|
|
915
|
+
dispatch.claimExpiresAt > now
|
|
640
916
|
) {
|
|
641
|
-
|
|
642
|
-
renewed.push(
|
|
917
|
+
dispatch.claimExpiresAt = new Date(expiresAt)
|
|
918
|
+
renewed.push(ref)
|
|
643
919
|
}
|
|
644
920
|
}
|
|
645
921
|
return { renewed, superseded }
|
|
646
922
|
},
|
|
647
923
|
|
|
648
|
-
async completeAttempt({
|
|
924
|
+
async completeAttempt({
|
|
925
|
+
sessionId,
|
|
926
|
+
consumer,
|
|
927
|
+
lane,
|
|
928
|
+
index,
|
|
929
|
+
attempt,
|
|
930
|
+
events,
|
|
931
|
+
consumers,
|
|
932
|
+
}) {
|
|
649
933
|
if (index <= clearIndexOf(sessionId)) return { outcome: 'superseded' }
|
|
650
|
-
const
|
|
651
|
-
const
|
|
652
|
-
if (
|
|
653
|
-
if (
|
|
934
|
+
const ref = { consumer, lane, index }
|
|
935
|
+
const cursor = cursorAt(sessionId, ref)
|
|
936
|
+
if (!cursor) {
|
|
937
|
+
if (!rowAt(sessionId, index)) {
|
|
938
|
+
throw new TypeError(
|
|
939
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
940
|
+
)
|
|
941
|
+
}
|
|
942
|
+
return { outcome: 'superseded' }
|
|
943
|
+
}
|
|
944
|
+
refreshLogTypes(sessionId, consumers)
|
|
945
|
+
|
|
946
|
+
if (cursor.head !== index) {
|
|
947
|
+
const completion = cursor.completions.get(index)
|
|
948
|
+
if (!completion || completion.attempt !== attempt) {
|
|
654
949
|
return { outcome: 'superseded' }
|
|
655
950
|
}
|
|
656
951
|
const requestedIds = events.map((event) => event.id)
|
|
657
952
|
if (
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
parent.returnedEventIds.some(
|
|
953
|
+
completion.returnedEventIds.length !== requestedIds.length ||
|
|
954
|
+
completion.returnedEventIds.some(
|
|
661
955
|
(id, offset) => id !== requestedIds[offset],
|
|
662
956
|
)
|
|
663
957
|
) {
|
|
@@ -671,7 +965,10 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
671
965
|
existing.length !== events.length ||
|
|
672
966
|
existing.some(
|
|
673
967
|
(row) =>
|
|
674
|
-
row.cause?.
|
|
968
|
+
row.cause?.consumer !== consumer ||
|
|
969
|
+
row.cause.lane !== lane ||
|
|
970
|
+
row.cause.index !== index ||
|
|
971
|
+
row.cause.attempt !== attempt,
|
|
675
972
|
)
|
|
676
973
|
) {
|
|
677
974
|
throw new A2Error(
|
|
@@ -681,13 +978,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
681
978
|
}
|
|
682
979
|
return {
|
|
683
980
|
outcome: 'completed',
|
|
684
|
-
events:
|
|
981
|
+
events:
|
|
982
|
+
events.length === 0
|
|
983
|
+
? []
|
|
984
|
+
: idempotentReplay(events, existing.map(toStored)),
|
|
685
985
|
}
|
|
686
986
|
}
|
|
987
|
+
const dispatch = cursor.dispatch
|
|
687
988
|
if (
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
989
|
+
dispatch.attemptCount !== attempt ||
|
|
990
|
+
dispatch.failedAt !== null ||
|
|
991
|
+
dispatch.claimHolder === null
|
|
691
992
|
) {
|
|
692
993
|
return { outcome: 'superseded' }
|
|
693
994
|
}
|
|
@@ -699,21 +1000,26 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
699
1000
|
'returned event batch contains already-appended ids',
|
|
700
1001
|
)
|
|
701
1002
|
}
|
|
702
|
-
const
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
1003
|
+
const inserted = insert(sessionId, events, clock.now(), consumers, {
|
|
1004
|
+
consumer,
|
|
1005
|
+
lane,
|
|
1006
|
+
index,
|
|
1007
|
+
attempt,
|
|
1008
|
+
})
|
|
1009
|
+
if (cursor.kind === 'log') cursor.completions.clear()
|
|
1010
|
+
cursor.completions.set(index, {
|
|
1011
|
+
attempt,
|
|
1012
|
+
returnedEventIds: events.map((event) => event.id),
|
|
1013
|
+
})
|
|
1014
|
+
advance(sessionId, cursor)
|
|
711
1015
|
notifyStreams(sessionId, inserted)
|
|
712
1016
|
return { outcome: 'completed', events: inserted.map(toStored) }
|
|
713
1017
|
},
|
|
714
1018
|
|
|
715
1019
|
async failAttempt({
|
|
716
1020
|
sessionId,
|
|
1021
|
+
consumer,
|
|
1022
|
+
lane,
|
|
717
1023
|
index,
|
|
718
1024
|
attempt,
|
|
719
1025
|
error,
|
|
@@ -723,76 +1029,88 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
723
1029
|
if (index <= clearIndexOf(sessionId)) {
|
|
724
1030
|
return { outcome: 'superseded', failureCount: 0 }
|
|
725
1031
|
}
|
|
726
|
-
const
|
|
1032
|
+
const cursor = cursorAt(sessionId, { consumer, lane, index })
|
|
1033
|
+
if (!cursor) {
|
|
1034
|
+
if (!rowAt(sessionId, index)) {
|
|
1035
|
+
throw new TypeError(
|
|
1036
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
return { outcome: 'superseded', failureCount: 0 }
|
|
1040
|
+
}
|
|
1041
|
+
const dispatch = cursor.dispatch
|
|
727
1042
|
if (
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
(
|
|
1043
|
+
cursor.head !== index ||
|
|
1044
|
+
dispatch.attemptCount !== attempt ||
|
|
1045
|
+
(dispatch.claimHolder === null &&
|
|
1046
|
+
dispatch.lastFailedAttempt !== attempt)
|
|
731
1047
|
) {
|
|
732
|
-
return {
|
|
1048
|
+
return {
|
|
1049
|
+
outcome: 'superseded',
|
|
1050
|
+
failureCount: cursor.head === index ? dispatch.failureCount : 0,
|
|
1051
|
+
}
|
|
733
1052
|
}
|
|
734
|
-
if (
|
|
1053
|
+
if (
|
|
1054
|
+
dispatch.lastFailedAttempt === attempt &&
|
|
1055
|
+
dispatch.claimHolder === null
|
|
1056
|
+
) {
|
|
735
1057
|
return {
|
|
736
|
-
outcome:
|
|
737
|
-
failureCount:
|
|
1058
|
+
outcome: dispatch.failedAt ? 'dead_lettered' : 'failed',
|
|
1059
|
+
failureCount: dispatch.failureCount,
|
|
738
1060
|
}
|
|
739
1061
|
}
|
|
740
|
-
if (
|
|
741
|
-
return { outcome: 'dead_lettered', failureCount:
|
|
1062
|
+
if (dispatch.failedAt) {
|
|
1063
|
+
return { outcome: 'dead_lettered', failureCount: dispatch.failureCount }
|
|
742
1064
|
}
|
|
743
|
-
|
|
744
|
-
|
|
1065
|
+
dispatch.failureCount += 1
|
|
1066
|
+
dispatch.lastError = error
|
|
745
1067
|
const failedAt = clock.now()
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
retryAfterMs === undefined ||
|
|
1068
|
+
dispatch.lastFailedAt = new Date(failedAt)
|
|
1069
|
+
dispatch.lastFailedAttempt = attempt
|
|
1070
|
+
dispatch.claimHolder = null
|
|
1071
|
+
dispatch.claimExpiresAt =
|
|
1072
|
+
retryAfterMs === undefined || dispatch.failureCount >= maxFailures
|
|
751
1073
|
? null
|
|
752
1074
|
: new Date(failedAt.getTime() + retryAfterMs)
|
|
753
|
-
if (
|
|
754
|
-
|
|
755
|
-
dispatchOf(sessionId).ready.delete(row.index)
|
|
756
|
-
releaseSnapshotPins(sessionId, index)
|
|
1075
|
+
if (dispatch.failureCount >= maxFailures) {
|
|
1076
|
+
deadLetter(sessionId, cursor, failedAt)
|
|
757
1077
|
return {
|
|
758
1078
|
outcome: 'dead_lettered',
|
|
759
|
-
failureCount:
|
|
1079
|
+
failureCount: dispatch.failureCount,
|
|
760
1080
|
}
|
|
761
1081
|
}
|
|
762
|
-
return { outcome: 'failed', failureCount:
|
|
1082
|
+
return { outcome: 'failed', failureCount: dispatch.failureCount }
|
|
763
1083
|
},
|
|
764
1084
|
|
|
765
1085
|
async deadLetters(sessionId) {
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
)
|
|
774
|
-
|
|
1086
|
+
const clearIndex = clearIndexOf(sessionId)
|
|
1087
|
+
const dead: HeadEvent[] = []
|
|
1088
|
+
for (const cursor of cursors.get(sessionId)?.values() ?? []) {
|
|
1089
|
+
if (
|
|
1090
|
+
cursor.head === null ||
|
|
1091
|
+
cursor.head <= clearIndex ||
|
|
1092
|
+
cursor.dispatch.failedAt === null
|
|
1093
|
+
) {
|
|
1094
|
+
continue
|
|
1095
|
+
}
|
|
1096
|
+
dead.push(toHead(cursor, rowAt(sessionId, cursor.head)!))
|
|
1097
|
+
}
|
|
1098
|
+
return dead.toSorted(byHead)
|
|
775
1099
|
},
|
|
776
1100
|
|
|
777
|
-
async redrive({ sessionId, index, action }) {
|
|
778
|
-
const
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
return { redriven: false, hasPending: hasPending(sessionId) }
|
|
1101
|
+
async redrive({ sessionId, consumer, index, action, consumers }) {
|
|
1102
|
+
const cursor = deadHeadOf(sessionId, consumer, index)
|
|
1103
|
+
if (!cursor) {
|
|
1104
|
+
return { redriven: false, hasPending: hasPending(sessionId, consumers) }
|
|
782
1105
|
}
|
|
1106
|
+
refreshLogTypes(sessionId, consumers)
|
|
783
1107
|
if (action === 'skip') {
|
|
784
|
-
|
|
785
|
-
settle(sessionId, row)
|
|
1108
|
+
advance(sessionId, cursor)
|
|
786
1109
|
} else {
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
const state = dispatchOf(sessionId)
|
|
790
|
-
const queue = row.lane === null ? undefined : state.lanes.get(row.lane)
|
|
791
|
-
if (row.lane === null || queue?.rows[queue.head] === row) {
|
|
792
|
-
state.ready.set(row.index, row)
|
|
793
|
-
}
|
|
1110
|
+
cursor.dispatch.failedAt = null
|
|
1111
|
+
cursor.dispatch.failureCount = 0
|
|
794
1112
|
}
|
|
795
|
-
return { redriven: true, hasPending: hasPending(sessionId) }
|
|
1113
|
+
return { redriven: true, hasPending: hasPending(sessionId, consumers) }
|
|
796
1114
|
},
|
|
797
1115
|
|
|
798
1116
|
async readState(sessionId, reducerName, stateOptions) {
|
|
@@ -821,17 +1139,17 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
821
1139
|
for (const write of writes.toSorted((a, b) => a.index - b.index)) {
|
|
822
1140
|
if (write.index < clearIndex) continue
|
|
823
1141
|
const checkpoint = checkpointKey(sessionId, reducerName, write.index)
|
|
824
|
-
for (const
|
|
825
|
-
const
|
|
1142
|
+
for (const pin of write.pins ?? []) {
|
|
1143
|
+
const cursor = cursorAt(sessionId, pin)
|
|
826
1144
|
if (
|
|
827
|
-
!
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
1145
|
+
!cursor ||
|
|
1146
|
+
pin.index <= clearIndex ||
|
|
1147
|
+
cursor.head !== pin.index ||
|
|
1148
|
+
cursor.dispatch.failedAt !== null
|
|
831
1149
|
) {
|
|
832
1150
|
continue
|
|
833
1151
|
}
|
|
834
|
-
const pinKey =
|
|
1152
|
+
const pinKey = headPinKey(sessionId, pin)
|
|
835
1153
|
let pinned = snapshotPins.get(pinKey)
|
|
836
1154
|
if (!pinned) {
|
|
837
1155
|
pinned = new Set()
|
|
@@ -900,26 +1218,25 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
900
1218
|
|
|
901
1219
|
// Expiry anchors on the storage's own clock — the sender's
|
|
902
1220
|
// `at` orders writes but never extends or shortens a lifetime.
|
|
903
|
-
const
|
|
1221
|
+
const now = clock.now()
|
|
1222
|
+
const expiresAtMs = now.getTime() + meta.ttlMs
|
|
904
1223
|
// Field-keyed and caller-named — null-prototype, like every
|
|
905
1224
|
// presence map (see `nullProtoRecord`).
|
|
906
1225
|
const applied: Record<string, unknown> = nullProtoRecord()
|
|
907
1226
|
let appliedCount = 0
|
|
908
1227
|
for (const [field, value] of Object.entries(values)) {
|
|
909
|
-
// Field-wise LWW by `at
|
|
1228
|
+
// Field-wise LWW by `at` against unexpired values and
|
|
1229
|
+
// tombstones alike; ties go to the incoming write, so
|
|
910
1230
|
// same-stamp sets keep set-then-read intuition.
|
|
911
1231
|
const existing = fields.get(field)
|
|
912
|
-
if (existing && existing.at > meta.at)
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
expiresAt: new Date(expiresAtMs),
|
|
921
|
-
})
|
|
922
|
-
}
|
|
1232
|
+
if (existing && existing.expiresAt > now && existing.at > meta.at)
|
|
1233
|
+
continue
|
|
1234
|
+
fields.set(field, {
|
|
1235
|
+
value: structuredClone(value),
|
|
1236
|
+
seen: meta.seen,
|
|
1237
|
+
at: new Date(meta.at),
|
|
1238
|
+
expiresAt: new Date(expiresAtMs),
|
|
1239
|
+
})
|
|
923
1240
|
applied[field] = structuredClone(value)
|
|
924
1241
|
appliedCount += 1
|
|
925
1242
|
}
|
|
@@ -953,6 +1270,7 @@ export function memory(options: MemoryStoreOptions = {}): A2Store {
|
|
|
953
1270
|
fields.delete(field)
|
|
954
1271
|
continue
|
|
955
1272
|
}
|
|
1273
|
+
if (entry.value === null) continue
|
|
956
1274
|
rows.push({
|
|
957
1275
|
participant,
|
|
958
1276
|
field,
|