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-sqlite.ts
CHANGED
|
@@ -18,16 +18,26 @@ import { DatabaseSync } from 'node:sqlite'
|
|
|
18
18
|
import { A2Error, supersededAttemptError } from './errors.ts'
|
|
19
19
|
import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
|
|
20
20
|
import { idempotentReplay } from './idempotent-replay.ts'
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
cursorKeyOf,
|
|
23
|
+
decodeCause,
|
|
24
|
+
decodeReturnedEventIds,
|
|
25
|
+
idleLogCursor,
|
|
26
|
+
} from './store-codec.ts'
|
|
22
27
|
import { pollingStream } from './store-polling.ts'
|
|
23
28
|
import {
|
|
24
29
|
RANDOM_IDS,
|
|
25
30
|
SYSTEM_CLOCK,
|
|
26
31
|
type A2Store,
|
|
32
|
+
type AppendEvent,
|
|
27
33
|
type Clock,
|
|
28
34
|
type Event,
|
|
29
35
|
type EventCause,
|
|
36
|
+
type HeadEvent,
|
|
37
|
+
type HeadRef,
|
|
30
38
|
type IdSource,
|
|
39
|
+
type StoreConsumer,
|
|
40
|
+
type StoredCursor,
|
|
31
41
|
type StoredEvent,
|
|
32
42
|
} from './store.ts'
|
|
33
43
|
|
|
@@ -45,7 +55,15 @@ export type SqliteStore = A2Store & {
|
|
|
45
55
|
close(): void
|
|
46
56
|
}
|
|
47
57
|
|
|
58
|
+
/** The cursor layout; databases from before it are rejected, not migrated. */
|
|
59
|
+
const LAYOUT = 'cursors-1'
|
|
60
|
+
|
|
48
61
|
const SCHEMA = `
|
|
62
|
+
create table if not exists a2_meta (
|
|
63
|
+
key text not null primary key,
|
|
64
|
+
value text not null
|
|
65
|
+
) strict;
|
|
66
|
+
|
|
49
67
|
create table if not exists a2_events (
|
|
50
68
|
session_id text not null,
|
|
51
69
|
idx integer not null,
|
|
@@ -54,35 +72,52 @@ create table if not exists a2_events (
|
|
|
54
72
|
event_id text not null,
|
|
55
73
|
created_at integer not null,
|
|
56
74
|
cause text,
|
|
75
|
+
primary key (session_id, idx)
|
|
76
|
+
) strict;
|
|
77
|
+
|
|
78
|
+
create unique index if not exists a2_events_session_event_id
|
|
79
|
+
on a2_events (session_id, event_id);
|
|
80
|
+
drop index if exists a2_events_event_id;
|
|
81
|
+
create index if not exists a2_events_types on a2_events (session_id, event_type, idx);
|
|
82
|
+
|
|
83
|
+
create table if not exists a2_cursors (
|
|
84
|
+
session_id text not null,
|
|
85
|
+
consumer text not null,
|
|
86
|
+
cursor_key text not null,
|
|
57
87
|
lane text,
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
88
|
+
kind text not null,
|
|
89
|
+
position integer not null,
|
|
90
|
+
head integer,
|
|
91
|
+
types text,
|
|
92
|
+
attempt_count integer not null default 0,
|
|
62
93
|
first_claimed_at integer,
|
|
63
94
|
last_claimed_at integer,
|
|
64
|
-
|
|
95
|
+
claim_holder text,
|
|
96
|
+
claim_expires_at integer,
|
|
65
97
|
failure_count integer not null default 0,
|
|
66
98
|
last_failed_at integer,
|
|
67
99
|
last_failed_attempt integer,
|
|
68
100
|
last_error text,
|
|
69
101
|
failed_at integer,
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
102
|
+
completed_idx integer,
|
|
103
|
+
completed_attempt integer,
|
|
104
|
+
returned_event_ids text,
|
|
105
|
+
primary key (session_id, consumer, cursor_key)
|
|
73
106
|
) strict;
|
|
74
107
|
|
|
75
|
-
create
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
create
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
108
|
+
create index if not exists a2_cursors_heads
|
|
109
|
+
on a2_cursors (session_id, head) where head is not null;
|
|
110
|
+
|
|
111
|
+
create table if not exists a2_lane_events (
|
|
112
|
+
session_id text not null,
|
|
113
|
+
consumer text not null,
|
|
114
|
+
lane text not null,
|
|
115
|
+
idx integer not null,
|
|
116
|
+
done integer not null default 0,
|
|
117
|
+
completed_attempt integer,
|
|
118
|
+
returned_event_ids text,
|
|
119
|
+
primary key (session_id, consumer, lane, idx)
|
|
120
|
+
) strict;
|
|
86
121
|
|
|
87
122
|
create table if not exists a2_snapshots (
|
|
88
123
|
session_id text not null,
|
|
@@ -104,10 +139,12 @@ create table if not exists a2_snapshot_history (
|
|
|
104
139
|
|
|
105
140
|
create table if not exists a2_snapshot_pins (
|
|
106
141
|
session_id text not null,
|
|
142
|
+
consumer text not null,
|
|
143
|
+
cursor_key text not null,
|
|
107
144
|
event_index integer not null,
|
|
108
145
|
reducer_name text not null,
|
|
109
146
|
up_to_index integer not null,
|
|
110
|
-
primary key (session_id, event_index, reducer_name, up_to_index)
|
|
147
|
+
primary key (session_id, consumer, cursor_key, event_index, reducer_name, up_to_index)
|
|
111
148
|
) strict;
|
|
112
149
|
|
|
113
150
|
create index if not exists a2_snapshot_pins_checkpoint
|
|
@@ -165,34 +202,51 @@ const wrap = <T>(fn: () => T): T => {
|
|
|
165
202
|
const toDate = (ms: number | bigint | null): Date | null =>
|
|
166
203
|
ms === null ? null : new Date(Number(ms))
|
|
167
204
|
|
|
168
|
-
const
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
205
|
+
const toNumber = (value: number | bigint | null): number | null =>
|
|
206
|
+
value === null ? null : Number(value)
|
|
207
|
+
|
|
208
|
+
type EventRow = {
|
|
209
|
+
session_id: string
|
|
210
|
+
idx: number | bigint
|
|
211
|
+
event_type: string
|
|
212
|
+
payload: string
|
|
213
|
+
event_id: string
|
|
214
|
+
created_at: number | bigint
|
|
215
|
+
cause: string | null
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
type CursorRow = {
|
|
219
|
+
session_id: string
|
|
220
|
+
consumer: string
|
|
221
|
+
cursor_key: string
|
|
222
|
+
lane: string | null
|
|
223
|
+
kind: 'log' | 'lane' | 'event'
|
|
224
|
+
position: number | bigint
|
|
225
|
+
head: number | bigint | null
|
|
226
|
+
types: string | null
|
|
227
|
+
attempt_count: number | bigint
|
|
228
|
+
first_claimed_at: number | bigint | null
|
|
229
|
+
last_claimed_at: number | bigint | null
|
|
230
|
+
claim_holder: string | null
|
|
231
|
+
claim_expires_at: number | bigint | null
|
|
232
|
+
failure_count: number | bigint
|
|
233
|
+
last_failed_at: number | bigint | null
|
|
234
|
+
last_failed_attempt: number | bigint | null
|
|
235
|
+
last_error: string | null
|
|
236
|
+
failed_at: number | bigint | null
|
|
237
|
+
completed_idx: number | bigint | null
|
|
238
|
+
completed_attempt: number | bigint | null
|
|
239
|
+
returned_event_ids: string | null
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** A cursor joined with its head event's columns, prefixed `e_`. */
|
|
243
|
+
type HeadRow = CursorRow & {
|
|
244
|
+
e_idx: number | bigint
|
|
245
|
+
e_event_type: string
|
|
246
|
+
e_payload: string
|
|
247
|
+
e_event_id: string
|
|
248
|
+
e_created_at: number | bigint
|
|
249
|
+
e_cause: string | null
|
|
196
250
|
}
|
|
197
251
|
|
|
198
252
|
const toStored = (row: EventRow): StoredEvent => ({
|
|
@@ -202,26 +256,7 @@ const toStored = (row: EventRow): StoredEvent => ({
|
|
|
202
256
|
index: Number(row.idx),
|
|
203
257
|
sessionId: row.session_id,
|
|
204
258
|
createdAt: new Date(Number(row.created_at)),
|
|
205
|
-
cause:
|
|
206
|
-
lane: row.lane,
|
|
207
|
-
processedAt: toDate(row.processed_at),
|
|
208
|
-
processedByAttempt:
|
|
209
|
-
row.processed_by_attempt === null ? null : Number(row.processed_by_attempt),
|
|
210
|
-
returnedEventIds:
|
|
211
|
-
row.returned_event_ids === null
|
|
212
|
-
? null
|
|
213
|
-
: decodeReturnedEventIds(row.returned_event_ids),
|
|
214
|
-
firstClaimedAt: toDate(row.first_claimed_at),
|
|
215
|
-
lastClaimedAt: toDate(row.last_claimed_at),
|
|
216
|
-
attemptCount: Number(row.attempt_count),
|
|
217
|
-
failureCount: Number(row.failure_count),
|
|
218
|
-
lastFailedAt: toDate(row.last_failed_at),
|
|
219
|
-
lastFailedAttempt:
|
|
220
|
-
row.last_failed_attempt === null ? null : Number(row.last_failed_attempt),
|
|
221
|
-
lastError: row.last_error,
|
|
222
|
-
failedAt: toDate(row.failed_at),
|
|
223
|
-
claimHolder: row.claim_holder,
|
|
224
|
-
claimExpiresAt: toDate(row.claim_expires_at),
|
|
259
|
+
cause: decodeCause(row.cause),
|
|
225
260
|
})
|
|
226
261
|
|
|
227
262
|
const toEvent = (row: EventRow): Event => ({
|
|
@@ -233,28 +268,37 @@ const toEvent = (row: EventRow): Event => ({
|
|
|
233
268
|
createdAt: new Date(Number(row.created_at)),
|
|
234
269
|
})
|
|
235
270
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
271
|
+
const toCursor = (row: CursorRow): StoredCursor => ({
|
|
272
|
+
consumer: row.consumer,
|
|
273
|
+
lane: row.lane,
|
|
274
|
+
position: Number(row.position),
|
|
275
|
+
head: toNumber(row.head),
|
|
276
|
+
firstClaimedAt: toDate(row.first_claimed_at),
|
|
277
|
+
lastClaimedAt: toDate(row.last_claimed_at),
|
|
278
|
+
attemptCount: Number(row.attempt_count),
|
|
279
|
+
claimHolder: row.claim_holder,
|
|
280
|
+
claimExpiresAt: toDate(row.claim_expires_at),
|
|
281
|
+
failureCount: Number(row.failure_count),
|
|
282
|
+
lastFailedAt: toDate(row.last_failed_at),
|
|
283
|
+
lastFailedAttempt: toNumber(row.last_failed_attempt),
|
|
284
|
+
lastError: row.last_error,
|
|
285
|
+
failedAt: toDate(row.failed_at),
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
const toHead = (row: HeadRow): HeadEvent => {
|
|
289
|
+
const { position: _position, head: _head, ...dispatch } = toCursor(row)
|
|
290
|
+
return {
|
|
291
|
+
...toStored({
|
|
292
|
+
session_id: row.session_id,
|
|
293
|
+
idx: row.e_idx,
|
|
294
|
+
event_type: row.e_event_type,
|
|
295
|
+
payload: row.e_payload,
|
|
296
|
+
event_id: row.e_event_id,
|
|
297
|
+
created_at: row.e_created_at,
|
|
298
|
+
cause: row.e_cause,
|
|
299
|
+
}),
|
|
300
|
+
...dispatch,
|
|
301
|
+
}
|
|
258
302
|
}
|
|
259
303
|
|
|
260
304
|
type PresenceDbRow = {
|
|
@@ -280,16 +324,30 @@ type SnapshotHeadRow = {
|
|
|
280
324
|
updated_at: number | bigint
|
|
281
325
|
}
|
|
282
326
|
|
|
283
|
-
|
|
284
|
-
max: number | bigint
|
|
285
|
-
has_pending: number | bigint
|
|
286
|
-
}
|
|
327
|
+
const RECLAIM_BATCH = 256
|
|
287
328
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
329
|
+
/** The head columns every claim, dead letter, and listing joins in. */
|
|
330
|
+
const HEAD_SELECT = `
|
|
331
|
+
select cursor.*,
|
|
332
|
+
event.idx as e_idx, event.event_type as e_event_type,
|
|
333
|
+
event.payload as e_payload, event.event_id as e_event_id,
|
|
334
|
+
event.created_at as e_created_at, event.cause as e_cause
|
|
335
|
+
from a2_cursors cursor
|
|
336
|
+
join a2_events event
|
|
337
|
+
on event.session_id = cursor.session_id and event.idx = cursor.head`
|
|
291
338
|
|
|
292
|
-
|
|
339
|
+
/** A fresh head's dispatch columns, for `set` clauses. */
|
|
340
|
+
const FRESH_DISPATCH = `
|
|
341
|
+
attempt_count = 0, first_claimed_at = null, last_claimed_at = null,
|
|
342
|
+
claim_holder = null, claim_expires_at = null, failure_count = 0,
|
|
343
|
+
last_failed_at = null, last_failed_attempt = null, last_error = null,
|
|
344
|
+
failed_at = null`
|
|
345
|
+
|
|
346
|
+
const placeholders = (count: number): string =>
|
|
347
|
+
Array.from({ length: count }, () => '?').join(', ')
|
|
348
|
+
|
|
349
|
+
const compareText = (a: string, b: string): number =>
|
|
350
|
+
a < b ? -1 : a > b ? 1 : 0
|
|
293
351
|
|
|
294
352
|
export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
295
353
|
const path = options.path ?? '.a2/dev.db'
|
|
@@ -311,44 +369,126 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
311
369
|
db.exec('pragma busy_timeout = 5000')
|
|
312
370
|
setWalJournalMode(db)
|
|
313
371
|
db.exec('pragma synchronous = normal')
|
|
372
|
+
const legacy = db
|
|
373
|
+
.prepare("select 1 from pragma_table_info('a2_events') where name = ?")
|
|
374
|
+
.get('processed_at')
|
|
375
|
+
if (legacy) {
|
|
376
|
+
db.close()
|
|
377
|
+
throw new A2Error(
|
|
378
|
+
'STORE_NOT_CONFIGURED',
|
|
379
|
+
`the sqlite database at '${path}' has the A2 layout from before consumers; delete it, or point the store at a fresh database file`,
|
|
380
|
+
)
|
|
381
|
+
}
|
|
314
382
|
db.exec(SCHEMA)
|
|
383
|
+
db.prepare(
|
|
384
|
+
"insert into a2_meta (key, value) values ('layout', ?) on conflict (key) do nothing",
|
|
385
|
+
).run(LAYOUT)
|
|
315
386
|
|
|
316
387
|
const insertEvent = db.prepare(
|
|
317
388
|
`insert into a2_events
|
|
318
|
-
(session_id, idx, event_type, payload, event_id, created_at,
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
389
|
+
(session_id, idx, event_type, payload, event_id, created_at, cause)
|
|
390
|
+
values (?, ?, ?, ?, ?, ?, ?)`,
|
|
391
|
+
)
|
|
392
|
+
const selectHead = db.prepare(
|
|
393
|
+
'select coalesce(max(idx), 0) as max from a2_events where session_id = ?',
|
|
394
|
+
)
|
|
395
|
+
const headOf = (sessionId: string): number =>
|
|
396
|
+
Number((selectHead.get(sessionId) as { max: number | bigint }).max)
|
|
397
|
+
const selectEvent = db.prepare(
|
|
398
|
+
'select 1 from a2_events where session_id = ? and idx = ?',
|
|
399
|
+
)
|
|
400
|
+
const selectCursor = db.prepare(
|
|
401
|
+
'select * from a2_cursors where session_id = ? and consumer = ? and cursor_key = ?',
|
|
331
402
|
)
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
403
|
+
const cursorOf = (sessionId: string, ref: HeadRef): CursorRow | undefined =>
|
|
404
|
+
selectCursor.get(
|
|
405
|
+
sessionId,
|
|
406
|
+
ref.consumer,
|
|
407
|
+
cursorKeyOf(ref.lane, ref.index),
|
|
408
|
+
) as CursorRow | undefined
|
|
409
|
+
const insertEventCursor = db.prepare(
|
|
410
|
+
`insert into a2_cursors
|
|
411
|
+
(session_id, consumer, cursor_key, lane, kind, position, head)
|
|
412
|
+
values (?, ?, ?, null, 'event', ?, ?)`,
|
|
413
|
+
)
|
|
414
|
+
const insertLaneEvent = db.prepare(
|
|
415
|
+
`insert into a2_lane_events (session_id, consumer, lane, idx)
|
|
416
|
+
values (?, ?, ?, ?)`,
|
|
417
|
+
)
|
|
418
|
+
// A lane cursor whose head lies at or below the clear marker is idle: the
|
|
419
|
+
// marker superseded everything it had queued.
|
|
420
|
+
const queueLaneHead = db.prepare(
|
|
421
|
+
`insert into a2_cursors
|
|
422
|
+
(session_id, consumer, cursor_key, lane, kind, position, head)
|
|
423
|
+
values (?, ?, ?, ?, 'lane', ?, ?)
|
|
424
|
+
on conflict (session_id, consumer, cursor_key) do update set
|
|
425
|
+
head = excluded.head,
|
|
426
|
+
position = max(a2_cursors.position, excluded.position),
|
|
427
|
+
${FRESH_DISPATCH}
|
|
428
|
+
where a2_cursors.head is null or a2_cursors.head <= excluded.position`,
|
|
429
|
+
)
|
|
430
|
+
const openLogCursor = db.prepare(
|
|
431
|
+
`insert into a2_cursors
|
|
432
|
+
(session_id, consumer, cursor_key, lane, kind, position, head, types)
|
|
433
|
+
values (?, ?, ?, ?, 'log', ?, ?, ?)`,
|
|
434
|
+
)
|
|
435
|
+
const wakeLogCursor = db.prepare(
|
|
436
|
+
`update a2_cursors set types = ?, head = ?
|
|
437
|
+
where session_id = ? and consumer = ? and cursor_key = ?`,
|
|
438
|
+
)
|
|
439
|
+
const refreshTypes = db.prepare(
|
|
440
|
+
`update a2_cursors set types = ?
|
|
441
|
+
where session_id = ? and consumer = ? and kind = 'log'
|
|
442
|
+
and types is not ?`,
|
|
443
|
+
)
|
|
444
|
+
const nextLogHeadAll = db.prepare(
|
|
445
|
+
`select min(idx) as next from a2_events
|
|
446
|
+
where session_id = ? and idx > ? and event_type != ?`,
|
|
447
|
+
)
|
|
448
|
+
const nextLogHeadOf = db.prepare(
|
|
449
|
+
`select min(idx) as next from a2_events
|
|
450
|
+
where session_id = ? and idx > ? and event_type != ?
|
|
451
|
+
and event_type in (select value from json_each(?))`,
|
|
452
|
+
)
|
|
453
|
+
const nextLogHead = (
|
|
454
|
+
sessionId: string,
|
|
455
|
+
after: number,
|
|
456
|
+
types: string | null,
|
|
457
|
+
): number | null =>
|
|
458
|
+
toNumber(
|
|
459
|
+
(
|
|
460
|
+
(types === null
|
|
461
|
+
? nextLogHeadAll.get(sessionId, after, CLEAR_EVENT_TYPE)
|
|
462
|
+
: nextLogHeadOf.get(sessionId, after, CLEAR_EVENT_TYPE, types)) as {
|
|
463
|
+
next: number | bigint | null
|
|
464
|
+
}
|
|
465
|
+
).next,
|
|
466
|
+
)
|
|
467
|
+
const nextLaneHead = db.prepare(
|
|
468
|
+
`select min(idx) as next from a2_lane_events
|
|
469
|
+
where session_id = ? and consumer = ? and lane = ? and done = 0
|
|
470
|
+
and idx > ?`,
|
|
471
|
+
)
|
|
472
|
+
const selectLaneEvent = db.prepare(
|
|
473
|
+
`select completed_attempt, returned_event_ids from a2_lane_events
|
|
474
|
+
where session_id = ? and consumer = ? and lane = ? and idx = ?`,
|
|
341
475
|
)
|
|
342
|
-
const
|
|
343
|
-
`
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
) as max,
|
|
347
|
-
exists(
|
|
348
|
-
select 1 from a2_events
|
|
349
|
-
where session_id = ? and processed_at is null and idx > ?
|
|
350
|
-
) as has_pending`,
|
|
476
|
+
const finishLaneEvent = db.prepare(
|
|
477
|
+
`update a2_lane_events
|
|
478
|
+
set done = 1, completed_attempt = ?, returned_event_ids = ?
|
|
479
|
+
where session_id = ? and consumer = ? and lane = ? and idx = ?`,
|
|
351
480
|
)
|
|
481
|
+
const moveCursor = db.prepare(
|
|
482
|
+
`update a2_cursors set position = ?, head = ?, ${FRESH_DISPATCH}
|
|
483
|
+
where session_id = ? and consumer = ? and cursor_key = ?`,
|
|
484
|
+
)
|
|
485
|
+
const recordCompletion = db.prepare(
|
|
486
|
+
`update a2_cursors
|
|
487
|
+
set completed_idx = ?, completed_attempt = ?, returned_event_ids = ?
|
|
488
|
+
where session_id = ? and consumer = ? and cursor_key = ?`,
|
|
489
|
+
)
|
|
490
|
+
// A JSON null value is a tombstone: a delete's `at`, kept until it
|
|
491
|
+
// expires so an older set arriving after it still loses.
|
|
352
492
|
const upsertPresence = db.prepare(
|
|
353
493
|
`insert into a2_presence (ns, participant, field, value, seen, at, expires_at)
|
|
354
494
|
values (?, ?, ?, ?, ?, ?, ?)
|
|
@@ -357,11 +497,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
357
497
|
seen = excluded.seen,
|
|
358
498
|
at = excluded.at,
|
|
359
499
|
expires_at = excluded.expires_at
|
|
360
|
-
where a2_presence.at <= excluded.at
|
|
361
|
-
)
|
|
362
|
-
const deletePresence = db.prepare(
|
|
363
|
-
`delete from a2_presence
|
|
364
|
-
where ns = ? and participant = ? and field = ? and at <= ?`,
|
|
500
|
+
where a2_presence.at <= excluded.at or a2_presence.expires_at <= ?`,
|
|
365
501
|
)
|
|
366
502
|
const sweepPresence = db.prepare(
|
|
367
503
|
'delete from a2_presence where ns = ? and expires_at <= ?',
|
|
@@ -369,21 +505,14 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
369
505
|
const selectPresence = db.prepare(
|
|
370
506
|
`select participant, field, value, seen, at, expires_at
|
|
371
507
|
from a2_presence
|
|
372
|
-
where ns = ? and expires_at > ?
|
|
508
|
+
where ns = ? and expires_at > ? and value != 'null'
|
|
373
509
|
order by participant, field`,
|
|
374
510
|
)
|
|
375
511
|
const selectByIds = (count: number) =>
|
|
376
512
|
db.prepare(
|
|
377
|
-
`select
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
where pending.session_id = ?
|
|
381
|
-
and pending.processed_at is null
|
|
382
|
-
and pending.idx > ?
|
|
383
|
-
) as session_has_pending
|
|
384
|
-
from a2_events stored
|
|
385
|
-
where stored.event_id in (${Array.from({ length: count }, () => '?').join(', ')})
|
|
386
|
-
order by stored.idx`,
|
|
513
|
+
`select * from a2_events
|
|
514
|
+
where session_id = ? and event_id in (${placeholders(count)})
|
|
515
|
+
order by idx`,
|
|
387
516
|
)
|
|
388
517
|
|
|
389
518
|
const selectClearIndex = db.prepare(
|
|
@@ -400,33 +529,59 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
400
529
|
'delete from a2_events where session_id = ? and event_id = ?',
|
|
401
530
|
)
|
|
402
531
|
|
|
532
|
+
/** Served consumers as a SQL condition on `cursor.consumer`. */
|
|
533
|
+
const servedClause = (
|
|
534
|
+
consumers: readonly StoreConsumer[] | undefined,
|
|
535
|
+
): { sql: string; params: string[] } =>
|
|
536
|
+
consumers === undefined
|
|
537
|
+
? { sql: '', params: [] }
|
|
538
|
+
: consumers.length === 0
|
|
539
|
+
? { sql: 'and 0', params: [] }
|
|
540
|
+
: {
|
|
541
|
+
sql: `and cursor.consumer in (${placeholders(consumers.length)})`,
|
|
542
|
+
params: consumers.map((consumer) => consumer.name),
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
const hasPending = (
|
|
546
|
+
sessionId: string,
|
|
547
|
+
clearIndex: number,
|
|
548
|
+
consumers: readonly StoreConsumer[] | undefined,
|
|
549
|
+
): boolean => {
|
|
550
|
+
const served = servedClause(consumers)
|
|
551
|
+
return (
|
|
552
|
+
db
|
|
553
|
+
.prepare(
|
|
554
|
+
`select 1 from a2_cursors cursor
|
|
555
|
+
where cursor.session_id = ? and cursor.head > ?
|
|
556
|
+
and cursor.failed_at is null ${served.sql}
|
|
557
|
+
limit 1`,
|
|
558
|
+
)
|
|
559
|
+
.get(sessionId, clearIndex, ...served.params) !== undefined
|
|
560
|
+
)
|
|
561
|
+
}
|
|
562
|
+
|
|
403
563
|
/**
|
|
404
|
-
* The rows owning `ids`.
|
|
405
|
-
*
|
|
564
|
+
* The session's rows owning `ids`. Rows below its clear marker do not
|
|
565
|
+
* own their ids: they are deleted here so the ids can be reused.
|
|
406
566
|
*/
|
|
407
567
|
const liveOwnersOf = (
|
|
408
568
|
sessionId: string,
|
|
409
569
|
clearIndex: number,
|
|
410
570
|
ids: readonly string[],
|
|
411
|
-
):
|
|
571
|
+
): EventRow[] => {
|
|
412
572
|
if (ids.length === 0) return []
|
|
413
573
|
const rows = selectByIds(ids.length).all(
|
|
414
574
|
sessionId,
|
|
415
|
-
clearIndex,
|
|
416
575
|
...ids,
|
|
417
|
-
) as unknown as
|
|
576
|
+
) as unknown as EventRow[]
|
|
418
577
|
return rows.filter((row) => {
|
|
419
|
-
if (
|
|
420
|
-
return true
|
|
578
|
+
if (Number(row.idx) >= clearIndex) return true
|
|
421
579
|
deleteEventById.run(sessionId, row.event_id)
|
|
422
580
|
return false
|
|
423
581
|
})
|
|
424
582
|
}
|
|
425
583
|
|
|
426
|
-
|
|
427
|
-
'select attempt_count, failed_at from a2_events where session_id = ? and idx = ?',
|
|
428
|
-
)
|
|
429
|
-
/** The attempt-currency fence shared by handler appends and clears. */
|
|
584
|
+
/** The attempt-currency fence shared by consumer appends and clears. */
|
|
430
585
|
const assertCurrentCause = ({
|
|
431
586
|
sessionId,
|
|
432
587
|
cause,
|
|
@@ -438,16 +593,12 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
438
593
|
}): void => {
|
|
439
594
|
if (cause.index <= clearIndex)
|
|
440
595
|
throw supersededAttemptError(sessionId, cause)
|
|
441
|
-
const
|
|
442
|
-
{ attempt_count: number | bigint; failed_at: unknown } | undefined
|
|
443
|
-
if (!parent) {
|
|
444
|
-
throw new TypeError(
|
|
445
|
-
`no event at index ${cause.index} in session '${sessionId}'`,
|
|
446
|
-
)
|
|
447
|
-
}
|
|
596
|
+
const cursor = cursorOf(sessionId, cause)
|
|
448
597
|
if (
|
|
449
|
-
|
|
450
|
-
|
|
598
|
+
!cursor ||
|
|
599
|
+
toNumber(cursor.head) !== cause.index ||
|
|
600
|
+
Number(cursor.attempt_count) !== cause.attempt ||
|
|
601
|
+
cursor.failed_at !== null
|
|
451
602
|
) {
|
|
452
603
|
throw supersededAttemptError(sessionId, cause)
|
|
453
604
|
}
|
|
@@ -470,11 +621,17 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
470
621
|
}
|
|
471
622
|
}
|
|
472
623
|
|
|
473
|
-
/** A dead
|
|
474
|
-
const releasePins = (
|
|
624
|
+
/** A finished or dead head's snapshot pins, and history no pin keeps. */
|
|
625
|
+
const releasePins = (
|
|
626
|
+
sessionId: string,
|
|
627
|
+
consumer: string,
|
|
628
|
+
cursorKey: string,
|
|
629
|
+
index: number,
|
|
630
|
+
): void => {
|
|
475
631
|
db.prepare(
|
|
476
|
-
|
|
477
|
-
|
|
632
|
+
`delete from a2_snapshot_pins
|
|
633
|
+
where session_id = ? and consumer = ? and cursor_key = ? and event_index = ?`,
|
|
634
|
+
).run(sessionId, consumer, cursorKey, index)
|
|
478
635
|
db.prepare(
|
|
479
636
|
`delete from a2_snapshot_history
|
|
480
637
|
where session_id = ?
|
|
@@ -486,20 +643,201 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
486
643
|
)`,
|
|
487
644
|
).run(sessionId)
|
|
488
645
|
}
|
|
646
|
+
|
|
647
|
+
/** Moves a cursor past its head; the next head starts fresh. */
|
|
648
|
+
const advance = (sessionId: string, cursor: CursorRow): void => {
|
|
649
|
+
const done = Number(cursor.head)
|
|
650
|
+
releasePins(sessionId, cursor.consumer, cursor.cursor_key, done)
|
|
651
|
+
let next: number | null
|
|
652
|
+
if (cursor.kind === 'event') {
|
|
653
|
+
next = null
|
|
654
|
+
} else if (cursor.kind === 'lane') {
|
|
655
|
+
next = toNumber(
|
|
656
|
+
(
|
|
657
|
+
nextLaneHead.get(
|
|
658
|
+
sessionId,
|
|
659
|
+
cursor.consumer,
|
|
660
|
+
cursor.lane,
|
|
661
|
+
Math.max(done, clearIndexOf(sessionId)),
|
|
662
|
+
) as { next: number | bigint | null }
|
|
663
|
+
).next,
|
|
664
|
+
)
|
|
665
|
+
} else {
|
|
666
|
+
const { types } = selectCursor.get(
|
|
667
|
+
sessionId,
|
|
668
|
+
cursor.consumer,
|
|
669
|
+
cursor.cursor_key,
|
|
670
|
+
) as CursorRow
|
|
671
|
+
next = nextLogHead(sessionId, done, types)
|
|
672
|
+
}
|
|
673
|
+
moveCursor.run(done, next, sessionId, cursor.consumer, cursor.cursor_key)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
* Brings a log consumer's cursor up to date and records its types. A
|
|
678
|
+
* missing cursor opens at the clear marker with `backfill`, otherwise at
|
|
679
|
+
* `base`; an idle one takes the next event of its types after its
|
|
680
|
+
* position; one with a head keeps it, claim and all. Returns its key.
|
|
681
|
+
*/
|
|
682
|
+
const wakeLogAt = ({
|
|
683
|
+
sessionId,
|
|
684
|
+
consumer,
|
|
685
|
+
log,
|
|
686
|
+
base,
|
|
687
|
+
clearIndex,
|
|
688
|
+
}: {
|
|
689
|
+
sessionId: string
|
|
690
|
+
consumer: string
|
|
691
|
+
log: NonNullable<StoreConsumer['log']>
|
|
692
|
+
base: number
|
|
693
|
+
clearIndex: number
|
|
694
|
+
}): string => {
|
|
695
|
+
const types = log.types === null ? null : JSON.stringify(log.types)
|
|
696
|
+
const key = cursorKeyOf(log.lane, 0)
|
|
697
|
+
const existing = selectCursor.get(sessionId, consumer, key) as
|
|
698
|
+
CursorRow | undefined
|
|
699
|
+
if (existing) {
|
|
700
|
+
wakeLogCursor.run(
|
|
701
|
+
types,
|
|
702
|
+
existing.head === null
|
|
703
|
+
? // A deployment that doesn't serve this consumer may have
|
|
704
|
+
// appended events it takes since it went idle.
|
|
705
|
+
nextLogHead(sessionId, Number(existing.position), types)
|
|
706
|
+
: Number(existing.head),
|
|
707
|
+
sessionId,
|
|
708
|
+
consumer,
|
|
709
|
+
key,
|
|
710
|
+
)
|
|
711
|
+
return key
|
|
712
|
+
}
|
|
713
|
+
const position = log.backfill ? clearIndex : base
|
|
714
|
+
openLogCursor.run(
|
|
715
|
+
sessionId,
|
|
716
|
+
consumer,
|
|
717
|
+
key,
|
|
718
|
+
log.lane,
|
|
719
|
+
position,
|
|
720
|
+
nextLogHead(sessionId, position, types),
|
|
721
|
+
types,
|
|
722
|
+
)
|
|
723
|
+
return key
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/** Starts the cursor work an inserted batch carries. */
|
|
727
|
+
const enqueue = ({
|
|
728
|
+
sessionId,
|
|
729
|
+
events,
|
|
730
|
+
base,
|
|
731
|
+
clearIndex,
|
|
732
|
+
consumers,
|
|
733
|
+
}: {
|
|
734
|
+
sessionId: string
|
|
735
|
+
events: readonly AppendEvent[]
|
|
736
|
+
base: number
|
|
737
|
+
clearIndex: number
|
|
738
|
+
consumers: readonly StoreConsumer[] | undefined
|
|
739
|
+
}): void => {
|
|
740
|
+
events.forEach((event, offset) => {
|
|
741
|
+
const index = base + 1 + offset
|
|
742
|
+
for (const target of event.targets ?? []) {
|
|
743
|
+
if (target.lane === null) {
|
|
744
|
+
insertEventCursor.run(
|
|
745
|
+
sessionId,
|
|
746
|
+
target.consumer,
|
|
747
|
+
cursorKeyOf(null, index),
|
|
748
|
+
index - 1,
|
|
749
|
+
index,
|
|
750
|
+
)
|
|
751
|
+
continue
|
|
752
|
+
}
|
|
753
|
+
insertLaneEvent.run(sessionId, target.consumer, target.lane, index)
|
|
754
|
+
queueLaneHead.run(
|
|
755
|
+
sessionId,
|
|
756
|
+
target.consumer,
|
|
757
|
+
cursorKeyOf(target.lane, index),
|
|
758
|
+
target.lane,
|
|
759
|
+
clearIndex,
|
|
760
|
+
index,
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
})
|
|
764
|
+
// The batch is inserted already, so a cursor it wakes finds the batch's
|
|
765
|
+
// first event of its types, or an earlier one.
|
|
766
|
+
for (const consumer of consumers ?? []) {
|
|
767
|
+
const log = consumer.log
|
|
768
|
+
if (!log) continue
|
|
769
|
+
const carries = events.some(
|
|
770
|
+
(event) =>
|
|
771
|
+
event.type !== CLEAR_EVENT_TYPE &&
|
|
772
|
+
(log.types === null || log.types.includes(event.type)),
|
|
773
|
+
)
|
|
774
|
+
if (!carries) continue
|
|
775
|
+
wakeLogAt({ sessionId, consumer: consumer.name, log, base, clearIndex })
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
const refreshLogTypes = (
|
|
780
|
+
sessionId: string,
|
|
781
|
+
consumers: readonly StoreConsumer[] | undefined,
|
|
782
|
+
): void => {
|
|
783
|
+
for (const consumer of consumers ?? []) {
|
|
784
|
+
if (!consumer.log) continue
|
|
785
|
+
const types =
|
|
786
|
+
consumer.log.types === null ? null : JSON.stringify(consumer.log.types)
|
|
787
|
+
refreshTypes.run(types, sessionId, consumer.name, types)
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
const insertBatch = ({
|
|
792
|
+
sessionId,
|
|
793
|
+
events,
|
|
794
|
+
clearIndex,
|
|
795
|
+
consumers,
|
|
796
|
+
cause,
|
|
797
|
+
}: {
|
|
798
|
+
sessionId: string
|
|
799
|
+
events: readonly AppendEvent[]
|
|
800
|
+
clearIndex: number
|
|
801
|
+
consumers: readonly StoreConsumer[] | undefined
|
|
802
|
+
cause?: EventCause
|
|
803
|
+
}): StoredEvent[] => {
|
|
804
|
+
const base = headOf(sessionId)
|
|
805
|
+
const now = clock.now().getTime()
|
|
806
|
+
const inserted = events.map((event, offset): StoredEvent => {
|
|
807
|
+
const id = event.id ?? generateId()
|
|
808
|
+
const index = base + 1 + offset
|
|
809
|
+
const eventCause = cause ?? event.cause ?? null
|
|
810
|
+
insertEvent.run(
|
|
811
|
+
sessionId,
|
|
812
|
+
index,
|
|
813
|
+
event.type,
|
|
814
|
+
JSON.stringify(event.payload) ?? 'null',
|
|
815
|
+
id,
|
|
816
|
+
now,
|
|
817
|
+
eventCause ? JSON.stringify(eventCause) : null,
|
|
818
|
+
)
|
|
819
|
+
return {
|
|
820
|
+
id,
|
|
821
|
+
type: event.type,
|
|
822
|
+
payload: structuredClone(event.payload),
|
|
823
|
+
index,
|
|
824
|
+
sessionId,
|
|
825
|
+
createdAt: new Date(now),
|
|
826
|
+
cause: eventCause ? { ...eventCause } : null,
|
|
827
|
+
}
|
|
828
|
+
})
|
|
829
|
+
enqueue({ sessionId, events, base, clearIndex, consumers })
|
|
830
|
+
return inserted
|
|
831
|
+
}
|
|
832
|
+
|
|
489
833
|
return {
|
|
490
834
|
async append(sessionId, events, appendOptions) {
|
|
835
|
+
const consumers = appendOptions?.consumers
|
|
491
836
|
if (events.length === 0) {
|
|
492
|
-
return wrap(() => {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
clearIndexOf(sessionId),
|
|
497
|
-
) as AppendStatusRow
|
|
498
|
-
return {
|
|
499
|
-
events: [],
|
|
500
|
-
hasPending: Number(status.has_pending) === 1,
|
|
501
|
-
}
|
|
502
|
-
})
|
|
837
|
+
return wrap(() => ({
|
|
838
|
+
events: [],
|
|
839
|
+
hasPending: hasPending(sessionId, clearIndexOf(sessionId), consumers),
|
|
840
|
+
}))
|
|
503
841
|
}
|
|
504
842
|
return wrap(() =>
|
|
505
843
|
tx(() => {
|
|
@@ -515,19 +853,10 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
515
853
|
if (suppliedIds.length > 0) {
|
|
516
854
|
const existing = liveOwnersOf(sessionId, clearIndex, suppliedIds)
|
|
517
855
|
if (existing.length > 0) {
|
|
518
|
-
const foreign = existing.find(
|
|
519
|
-
(row) => row.session_id !== sessionId,
|
|
520
|
-
)
|
|
521
|
-
if (foreign) {
|
|
522
|
-
throw new A2Error(
|
|
523
|
-
'PARTIAL_DUPLICATE_BATCH',
|
|
524
|
-
`event id '${foreign.event_id}' already exists in another session`,
|
|
525
|
-
)
|
|
526
|
-
}
|
|
527
856
|
if (existing.length === events.length) {
|
|
528
857
|
return {
|
|
529
858
|
events: idempotentReplay(events, existing.map(toStored)),
|
|
530
|
-
hasPending:
|
|
859
|
+
hasPending: hasPending(sessionId, clearIndex, consumers),
|
|
531
860
|
}
|
|
532
861
|
}
|
|
533
862
|
throw new A2Error(
|
|
@@ -543,8 +872,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
543
872
|
`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
|
|
544
873
|
)
|
|
545
874
|
}
|
|
546
|
-
// Attempt-currency fence: a fresh
|
|
547
|
-
// while its causal attempt
|
|
875
|
+
// Attempt-currency fence: a fresh consumer append commits only
|
|
876
|
+
// while its causal attempt still owns the cursor's head
|
|
548
877
|
// (store.ts `append`). `begin immediate` serializes this read
|
|
549
878
|
// against a concurrent claim.
|
|
550
879
|
for (const e of events) {
|
|
@@ -552,65 +881,69 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
552
881
|
assertCurrentCause({ sessionId, cause: e.cause, clearIndex })
|
|
553
882
|
}
|
|
554
883
|
|
|
555
|
-
const
|
|
556
|
-
sessionId,
|
|
884
|
+
const inserted = insertBatch({
|
|
557
885
|
sessionId,
|
|
886
|
+
events,
|
|
558
887
|
clearIndex,
|
|
559
|
-
|
|
560
|
-
const base = Number(status.max)
|
|
561
|
-
const now = clock.now().getTime()
|
|
562
|
-
const inserted: StoredEvent[] = events.map((e, i) => {
|
|
563
|
-
const id = e.id ?? generateId()
|
|
564
|
-
const index = base + 1 + i
|
|
565
|
-
insertEvent.run(
|
|
566
|
-
sessionId,
|
|
567
|
-
index,
|
|
568
|
-
e.type,
|
|
569
|
-
JSON.stringify(e.payload) ?? 'null',
|
|
570
|
-
id,
|
|
571
|
-
now,
|
|
572
|
-
e.cause ? JSON.stringify(e.cause) : null,
|
|
573
|
-
e.lane ?? null,
|
|
574
|
-
e.lane ?? null,
|
|
575
|
-
sessionId,
|
|
576
|
-
e.lane ?? null,
|
|
577
|
-
clearIndex,
|
|
578
|
-
e.settled ? now : null,
|
|
579
|
-
)
|
|
580
|
-
return {
|
|
581
|
-
id,
|
|
582
|
-
type: e.type,
|
|
583
|
-
payload: structuredClone(e.payload),
|
|
584
|
-
index,
|
|
585
|
-
sessionId,
|
|
586
|
-
createdAt: new Date(now),
|
|
587
|
-
cause: e.cause ? { ...e.cause } : null,
|
|
588
|
-
lane: e.lane ?? null,
|
|
589
|
-
processedAt: e.settled ? new Date(now) : null,
|
|
590
|
-
processedByAttempt: null,
|
|
591
|
-
returnedEventIds: null,
|
|
592
|
-
firstClaimedAt: null,
|
|
593
|
-
lastClaimedAt: null,
|
|
594
|
-
attemptCount: 0,
|
|
595
|
-
failureCount: 0,
|
|
596
|
-
lastFailedAt: null,
|
|
597
|
-
lastFailedAttempt: null,
|
|
598
|
-
lastError: null,
|
|
599
|
-
failedAt: null,
|
|
600
|
-
claimHolder: null,
|
|
601
|
-
claimExpiresAt: null,
|
|
602
|
-
}
|
|
888
|
+
consumers,
|
|
603
889
|
})
|
|
604
890
|
return {
|
|
605
891
|
events: inserted,
|
|
606
|
-
hasPending:
|
|
607
|
-
Number(status.has_pending) === 1 ||
|
|
608
|
-
events.some((event) => event.settled !== true),
|
|
892
|
+
hasPending: hasPending(sessionId, clearIndex, consumers),
|
|
609
893
|
}
|
|
610
894
|
}),
|
|
611
895
|
)
|
|
612
896
|
},
|
|
613
897
|
|
|
898
|
+
async wakeLog({ sessionId, consumer }) {
|
|
899
|
+
return wrap(() => {
|
|
900
|
+
// Nothing after the latest clear: a missing cursor stays missing,
|
|
901
|
+
// and nothing takes the write lock.
|
|
902
|
+
const { lane } = consumer.log
|
|
903
|
+
const marker = clearIndexOf(sessionId)
|
|
904
|
+
if (
|
|
905
|
+
headOf(sessionId) === marker &&
|
|
906
|
+
!selectCursor.get(sessionId, consumer.name, cursorKeyOf(lane, 0))
|
|
907
|
+
) {
|
|
908
|
+
return {
|
|
909
|
+
cursor: idleLogCursor(consumer.name, lane, marker),
|
|
910
|
+
head: marker,
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return tx(() => {
|
|
914
|
+
const head = headOf(sessionId)
|
|
915
|
+
const key = wakeLogAt({
|
|
916
|
+
sessionId,
|
|
917
|
+
consumer: consumer.name,
|
|
918
|
+
log: consumer.log,
|
|
919
|
+
base: head,
|
|
920
|
+
clearIndex: clearIndexOf(sessionId),
|
|
921
|
+
})
|
|
922
|
+
const cursor = selectCursor.get(sessionId, consumer.name, key)
|
|
923
|
+
return { cursor: toCursor(cursor as CursorRow), head }
|
|
924
|
+
})
|
|
925
|
+
})
|
|
926
|
+
},
|
|
927
|
+
|
|
928
|
+
async cursors(sessionId) {
|
|
929
|
+
return wrap(() => {
|
|
930
|
+
const clearIndex = clearIndexOf(sessionId)
|
|
931
|
+
const rows = db
|
|
932
|
+
.prepare(
|
|
933
|
+
`select * from a2_cursors
|
|
934
|
+
where session_id = ?
|
|
935
|
+
and (
|
|
936
|
+
kind = 'log'
|
|
937
|
+
or head > ?
|
|
938
|
+
or (kind = 'lane' and head is null and position > ?)
|
|
939
|
+
)
|
|
940
|
+
order by consumer, lane is null, lane, head`,
|
|
941
|
+
)
|
|
942
|
+
.all(sessionId, clearIndex, clearIndex) as unknown as CursorRow[]
|
|
943
|
+
return rows.map(toCursor)
|
|
944
|
+
})
|
|
945
|
+
},
|
|
946
|
+
|
|
614
947
|
async read(sessionId, opts) {
|
|
615
948
|
return wrap(() => {
|
|
616
949
|
// Reads start at the latest clear marker.
|
|
@@ -641,44 +974,61 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
641
974
|
holder,
|
|
642
975
|
ttlMs,
|
|
643
976
|
expiresAtMs,
|
|
644
|
-
|
|
977
|
+
exclude = [],
|
|
978
|
+
consumers,
|
|
645
979
|
maxFailures,
|
|
980
|
+
through,
|
|
646
981
|
}) {
|
|
647
982
|
return wrap(() =>
|
|
648
983
|
tx(() => {
|
|
984
|
+
refreshLogTypes(sessionId, consumers)
|
|
649
985
|
const now = clock.now().getTime()
|
|
650
986
|
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
651
987
|
const clearIndex = clearIndexOf(sessionId)
|
|
988
|
+
const served = servedClause(consumers)
|
|
989
|
+
const excludedKeys = exclude.map(
|
|
990
|
+
(ref) => `${ref.consumer}\u0000${cursorKeyOf(ref.lane, ref.index)}`,
|
|
991
|
+
)
|
|
652
992
|
const exclusion =
|
|
653
|
-
|
|
993
|
+
excludedKeys.length === 0
|
|
654
994
|
? ''
|
|
655
|
-
: `and
|
|
995
|
+
: `and cursor.consumer || char(0) || cursor.cursor_key
|
|
996
|
+
not in (${placeholders(excludedKeys.length)})`
|
|
997
|
+
const bounded = through === undefined ? '' : 'and cursor.head <= ?'
|
|
998
|
+
const filter = `
|
|
999
|
+
where cursor.session_id = ?
|
|
1000
|
+
and cursor.head > ?
|
|
1001
|
+
and cursor.failed_at is null
|
|
1002
|
+
${bounded}
|
|
1003
|
+
${served.sql}
|
|
1004
|
+
${exclusion}`
|
|
1005
|
+
const params = [
|
|
1006
|
+
sessionId,
|
|
1007
|
+
clearIndex,
|
|
1008
|
+
...(through === undefined ? [] : [through]),
|
|
1009
|
+
...served.params,
|
|
1010
|
+
...excludedKeys,
|
|
1011
|
+
now,
|
|
1012
|
+
]
|
|
656
1013
|
const eligible = db
|
|
657
1014
|
.prepare(
|
|
658
|
-
`select
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
and
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
and (event.claim_expires_at is null or event.claim_expires_at <= ?)
|
|
666
|
-
${exclusion}
|
|
667
|
-
order by event.idx`,
|
|
1015
|
+
`select cursor.consumer, cursor.cursor_key,
|
|
1016
|
+
cursor.claim_holder, cursor.failure_count
|
|
1017
|
+
from a2_cursors cursor
|
|
1018
|
+
${filter}
|
|
1019
|
+
and (cursor.claim_expires_at is null
|
|
1020
|
+
or cursor.claim_expires_at <= ?)
|
|
1021
|
+
order by cursor.head, cursor.consumer`,
|
|
668
1022
|
)
|
|
669
|
-
.all(
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
idx: number | bigint
|
|
676
|
-
claim_holder: string | null
|
|
677
|
-
failure_count: number | bigint
|
|
678
|
-
}>
|
|
1023
|
+
.all(...params) as unknown as Array<
|
|
1024
|
+
Pick<
|
|
1025
|
+
CursorRow,
|
|
1026
|
+
'consumer' | 'cursor_key' | 'claim_holder' | 'failure_count'
|
|
1027
|
+
>
|
|
1028
|
+
>
|
|
679
1029
|
if (eligible.length > 0) {
|
|
680
|
-
const
|
|
681
|
-
`update
|
|
1030
|
+
const claimHead = db.prepare(
|
|
1031
|
+
`update a2_cursors set
|
|
682
1032
|
attempt_count = attempt_count + 1,
|
|
683
1033
|
failure_count = failure_count
|
|
684
1034
|
+ case when claim_holder is null then 0 else 1 end,
|
|
@@ -686,55 +1036,71 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
686
1036
|
last_claimed_at = ?,
|
|
687
1037
|
claim_holder = ?,
|
|
688
1038
|
claim_expires_at = ?
|
|
689
|
-
where session_id = ? and
|
|
690
|
-
returning *`,
|
|
1039
|
+
where session_id = ? and consumer = ? and cursor_key = ?`,
|
|
691
1040
|
)
|
|
692
1041
|
// Advancing the attempt fences the lapsed one; nothing runs.
|
|
693
|
-
const
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1042
|
+
const deadLetterHead = db.prepare(
|
|
1043
|
+
`update a2_cursors set
|
|
1044
|
+
attempt_count = attempt_count + 1,
|
|
1045
|
+
failure_count = failure_count + 1,
|
|
1046
|
+
failed_at = ?,
|
|
1047
|
+
claim_holder = null,
|
|
1048
|
+
claim_expires_at = null
|
|
1049
|
+
where session_id = ? and consumer = ? and cursor_key = ?`,
|
|
1050
|
+
)
|
|
1051
|
+
const selectHeadRow = db.prepare(
|
|
1052
|
+
`${HEAD_SELECT}
|
|
1053
|
+
where cursor.session_id = ? and cursor.consumer = ?
|
|
1054
|
+
and cursor.cursor_key = ?`,
|
|
1055
|
+
)
|
|
704
1056
|
const events = eligible.map((row) => {
|
|
705
1057
|
// A lapsed claim's takeover that spends the last failed attempt.
|
|
706
1058
|
const exhausted =
|
|
707
1059
|
row.claim_holder !== null &&
|
|
708
1060
|
maxFailures !== undefined &&
|
|
709
1061
|
Number(row.failure_count) + 1 >= maxFailures
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
1062
|
+
if (exhausted) {
|
|
1063
|
+
deadLetterHead.run(now, sessionId, row.consumer, row.cursor_key)
|
|
1064
|
+
} else {
|
|
1065
|
+
claimHead.run(
|
|
1066
|
+
now,
|
|
1067
|
+
now,
|
|
1068
|
+
holder,
|
|
1069
|
+
expiresAt,
|
|
1070
|
+
sessionId,
|
|
1071
|
+
row.consumer,
|
|
1072
|
+
row.cursor_key,
|
|
1073
|
+
)
|
|
1074
|
+
}
|
|
1075
|
+
const claimed = toHead(
|
|
1076
|
+
selectHeadRow.get(
|
|
1077
|
+
sessionId,
|
|
1078
|
+
row.consumer,
|
|
1079
|
+
row.cursor_key,
|
|
1080
|
+
) as HeadRow,
|
|
1081
|
+
)
|
|
1082
|
+
if (exhausted) {
|
|
1083
|
+
releasePins(
|
|
1084
|
+
sessionId,
|
|
1085
|
+
row.consumer,
|
|
1086
|
+
row.cursor_key,
|
|
1087
|
+
claimed.index,
|
|
1088
|
+
)
|
|
1089
|
+
}
|
|
1090
|
+
if (clearIndex > 0) claimed.clearIndex = clearIndex
|
|
1091
|
+
return claimed
|
|
719
1092
|
})
|
|
720
|
-
return { outcome: 'claimed', events }
|
|
1093
|
+
return { outcome: 'claimed', events, head: headOf(sessionId) }
|
|
721
1094
|
}
|
|
722
1095
|
|
|
723
1096
|
const active = db
|
|
724
1097
|
.prepare(
|
|
725
|
-
`select min(claim_expires_at) as retry_at
|
|
726
|
-
from
|
|
727
|
-
|
|
728
|
-
and
|
|
729
|
-
and failed_at is null
|
|
730
|
-
and lane_ready = 1
|
|
731
|
-
and idx > ?
|
|
732
|
-
and claim_expires_at > ?
|
|
733
|
-
${exclusion}`,
|
|
1098
|
+
`select min(cursor.claim_expires_at) as retry_at
|
|
1099
|
+
from a2_cursors cursor
|
|
1100
|
+
${filter}
|
|
1101
|
+
and cursor.claim_expires_at > ?`,
|
|
734
1102
|
)
|
|
735
|
-
.get(
|
|
736
|
-
retry_at: number | bigint | null
|
|
737
|
-
}
|
|
1103
|
+
.get(...params) as { retry_at: number | bigint | null }
|
|
738
1104
|
return active.retry_at === null
|
|
739
1105
|
? { outcome: 'settled' }
|
|
740
1106
|
: { outcome: 'busy', retryAt: new Date(Number(active.retry_at)) }
|
|
@@ -748,65 +1114,76 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
748
1114
|
tx(() => {
|
|
749
1115
|
const now = clock.now().getTime()
|
|
750
1116
|
const expiresAt = expiresAtMs ?? now + ttlMs
|
|
751
|
-
const renewed:
|
|
752
|
-
const superseded:
|
|
753
|
-
const currentAttempt = db.prepare(
|
|
754
|
-
'select attempt_count from a2_events where session_id = ? and idx = ?',
|
|
755
|
-
)
|
|
1117
|
+
const renewed: HeadRef[] = []
|
|
1118
|
+
const superseded: HeadRef[] = []
|
|
756
1119
|
const extend = db.prepare(
|
|
757
|
-
`update
|
|
758
|
-
where session_id = ?
|
|
759
|
-
and
|
|
1120
|
+
`update a2_cursors set claim_expires_at = ?
|
|
1121
|
+
where session_id = ? and consumer = ? and cursor_key = ?
|
|
1122
|
+
and head = ?
|
|
760
1123
|
and attempt_count = ?
|
|
761
|
-
and processed_at is null
|
|
762
1124
|
and failed_at is null
|
|
763
1125
|
and claim_holder = ?
|
|
764
1126
|
and claim_expires_at > ?`,
|
|
765
1127
|
)
|
|
766
1128
|
const clearIndex = clearIndexOf(sessionId)
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1129
|
+
const ordered = claims.toSorted(
|
|
1130
|
+
(a, b) => a.index - b.index || compareText(a.consumer, b.consumer),
|
|
1131
|
+
)
|
|
1132
|
+
for (const { attempt, ...ref } of ordered) {
|
|
1133
|
+
if (ref.index <= clearIndex) {
|
|
1134
|
+
superseded.push(ref)
|
|
770
1135
|
continue
|
|
771
1136
|
}
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1137
|
+
const cursor = cursorOf(sessionId, ref)
|
|
1138
|
+
if (!cursor) continue
|
|
1139
|
+
const head = toNumber(cursor.head)
|
|
1140
|
+
// An event the cursor has not reached yet holds no claim to renew.
|
|
1141
|
+
if (head !== ref.index && Number(cursor.position) < ref.index)
|
|
1142
|
+
continue
|
|
1143
|
+
if (head !== ref.index || Number(cursor.attempt_count) > attempt) {
|
|
1144
|
+
superseded.push(ref)
|
|
777
1145
|
continue
|
|
778
1146
|
}
|
|
779
1147
|
const updated = extend.run(
|
|
780
1148
|
expiresAt,
|
|
781
1149
|
sessionId,
|
|
782
|
-
|
|
783
|
-
|
|
1150
|
+
ref.consumer,
|
|
1151
|
+
cursor.cursor_key,
|
|
1152
|
+
ref.index,
|
|
1153
|
+
attempt,
|
|
784
1154
|
holder,
|
|
785
1155
|
now,
|
|
786
1156
|
)
|
|
787
|
-
if (Number(updated.changes) === 1) renewed.push(
|
|
788
|
-
}
|
|
789
|
-
return {
|
|
790
|
-
renewed: renewed.toSorted((a, b) => a - b),
|
|
791
|
-
superseded: superseded.toSorted((a, b) => a - b),
|
|
1157
|
+
if (Number(updated.changes) === 1) renewed.push(ref)
|
|
792
1158
|
}
|
|
1159
|
+
return { renewed, superseded }
|
|
793
1160
|
}),
|
|
794
1161
|
)
|
|
795
1162
|
},
|
|
796
1163
|
|
|
797
|
-
async completeAttempt({
|
|
1164
|
+
async completeAttempt({
|
|
1165
|
+
sessionId,
|
|
1166
|
+
consumer,
|
|
1167
|
+
lane,
|
|
1168
|
+
index,
|
|
1169
|
+
attempt,
|
|
1170
|
+
events,
|
|
1171
|
+
consumers,
|
|
1172
|
+
}) {
|
|
798
1173
|
return wrap(() =>
|
|
799
1174
|
tx(() => {
|
|
800
1175
|
const clearIndex = clearIndexOf(sessionId)
|
|
801
1176
|
if (index <= clearIndex) return { outcome: 'superseded' }
|
|
802
|
-
const
|
|
803
|
-
|
|
804
|
-
.get(sessionId, index)
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1177
|
+
const cursor = cursorOf(sessionId, { consumer, lane, index })
|
|
1178
|
+
if (!cursor) {
|
|
1179
|
+
if (!selectEvent.get(sessionId, index)) {
|
|
1180
|
+
throw new TypeError(
|
|
1181
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
1182
|
+
)
|
|
1183
|
+
}
|
|
1184
|
+
return { outcome: 'superseded' }
|
|
809
1185
|
}
|
|
1186
|
+
refreshLogTypes(sessionId, consumers)
|
|
810
1187
|
const ids = events.map((event) => event.id)
|
|
811
1188
|
if (new Set(ids).size !== ids.length) {
|
|
812
1189
|
throw new A2Error(
|
|
@@ -815,14 +1192,38 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
815
1192
|
)
|
|
816
1193
|
}
|
|
817
1194
|
|
|
818
|
-
if (
|
|
819
|
-
|
|
1195
|
+
if (toNumber(cursor.head) !== index) {
|
|
1196
|
+
let completion:
|
|
1197
|
+
{ attempt: number | null; ids: string | null } | undefined
|
|
1198
|
+
if (cursor.kind === 'lane') {
|
|
1199
|
+
const row = selectLaneEvent.get(
|
|
1200
|
+
sessionId,
|
|
1201
|
+
consumer,
|
|
1202
|
+
lane,
|
|
1203
|
+
index,
|
|
1204
|
+
) as
|
|
1205
|
+
| {
|
|
1206
|
+
completed_attempt: number | bigint | null
|
|
1207
|
+
returned_event_ids: string | null
|
|
1208
|
+
}
|
|
1209
|
+
| undefined
|
|
1210
|
+
completion = row && {
|
|
1211
|
+
attempt: toNumber(row.completed_attempt),
|
|
1212
|
+
ids: row.returned_event_ids,
|
|
1213
|
+
}
|
|
1214
|
+
} else if (toNumber(cursor.completed_idx) === index) {
|
|
1215
|
+
completion = {
|
|
1216
|
+
attempt: toNumber(cursor.completed_attempt),
|
|
1217
|
+
ids: cursor.returned_event_ids,
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
if (!completion || completion.attempt !== attempt) {
|
|
820
1221
|
return { outcome: 'superseded' }
|
|
821
1222
|
}
|
|
822
1223
|
const returnedIds =
|
|
823
|
-
|
|
1224
|
+
completion.ids === null
|
|
824
1225
|
? null
|
|
825
|
-
: decodeReturnedEventIds(
|
|
1226
|
+
: decodeReturnedEventIds(completion.ids)
|
|
826
1227
|
if (
|
|
827
1228
|
returnedIds === null ||
|
|
828
1229
|
returnedIds.length !== ids.length ||
|
|
@@ -838,20 +1239,21 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
838
1239
|
? []
|
|
839
1240
|
: (selectByIds(ids.length).all(
|
|
840
1241
|
sessionId,
|
|
841
|
-
clearIndex,
|
|
842
1242
|
...ids,
|
|
843
|
-
) as unknown as
|
|
1243
|
+
) as unknown as EventRow[])
|
|
844
1244
|
const byId = new Map(existing.map((row) => [row.event_id, row]))
|
|
845
1245
|
const ordered = ids.map((id) => byId.get(id))
|
|
846
1246
|
if (
|
|
847
|
-
ordered.some(
|
|
848
|
-
(row)
|
|
1247
|
+
ordered.some((row) => {
|
|
1248
|
+
const cause = row ? decodeCause(row.cause) : null
|
|
1249
|
+
return (
|
|
849
1250
|
!row ||
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1251
|
+
cause?.consumer !== consumer ||
|
|
1252
|
+
cause.lane !== lane ||
|
|
1253
|
+
cause.index !== index ||
|
|
1254
|
+
cause.attempt !== attempt
|
|
1255
|
+
)
|
|
1256
|
+
})
|
|
855
1257
|
) {
|
|
856
1258
|
throw new A2Error(
|
|
857
1259
|
'PARTIAL_DUPLICATE_BATCH',
|
|
@@ -860,16 +1262,19 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
860
1262
|
}
|
|
861
1263
|
return {
|
|
862
1264
|
outcome: 'completed',
|
|
863
|
-
events:
|
|
864
|
-
events
|
|
865
|
-
|
|
866
|
-
|
|
1265
|
+
events:
|
|
1266
|
+
events.length === 0
|
|
1267
|
+
? []
|
|
1268
|
+
: idempotentReplay(
|
|
1269
|
+
events,
|
|
1270
|
+
(ordered as EventRow[]).map(toStored),
|
|
1271
|
+
),
|
|
867
1272
|
}
|
|
868
1273
|
}
|
|
869
1274
|
if (
|
|
870
|
-
Number(
|
|
871
|
-
|
|
872
|
-
|
|
1275
|
+
Number(cursor.attempt_count) !== attempt ||
|
|
1276
|
+
cursor.claim_holder === null ||
|
|
1277
|
+
cursor.failed_at !== null
|
|
873
1278
|
) {
|
|
874
1279
|
return { outcome: 'superseded' }
|
|
875
1280
|
}
|
|
@@ -881,65 +1286,34 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
881
1286
|
)
|
|
882
1287
|
}
|
|
883
1288
|
|
|
884
|
-
const
|
|
885
|
-
sessionId,
|
|
1289
|
+
const inserted = insertBatch({
|
|
886
1290
|
sessionId,
|
|
1291
|
+
events,
|
|
887
1292
|
clearIndex,
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
claim_holder = null,
|
|
897
|
-
claim_expires_at = null
|
|
898
|
-
where session_id = ? and idx = ?`,
|
|
899
|
-
).run(now, attempt, JSON.stringify(ids), sessionId, index)
|
|
900
|
-
db.prepare(
|
|
901
|
-
'delete from a2_snapshot_pins where session_id = ? and event_index = ?',
|
|
902
|
-
).run(sessionId, index)
|
|
903
|
-
db.prepare(
|
|
904
|
-
`delete from a2_snapshot_history
|
|
905
|
-
where session_id = ?
|
|
906
|
-
and not exists (
|
|
907
|
-
select 1 from a2_snapshot_pins pin
|
|
908
|
-
where pin.session_id = a2_snapshot_history.session_id
|
|
909
|
-
and pin.reducer_name = a2_snapshot_history.reducer_name
|
|
910
|
-
and pin.up_to_index = a2_snapshot_history.up_to_index
|
|
911
|
-
)`,
|
|
912
|
-
).run(sessionId)
|
|
913
|
-
if (parent.lane !== null) {
|
|
914
|
-
promoteLaneHead.run(sessionId, sessionId, parent.lane, clearIndex)
|
|
915
|
-
}
|
|
916
|
-
const inserted = events.map((event, offset) => {
|
|
917
|
-
const id = event.id
|
|
918
|
-
const childIndex = Number(status.max) + offset + 1
|
|
919
|
-
const cause = { index, attempt }
|
|
920
|
-
insertEvent.run(
|
|
921
|
-
sessionId,
|
|
922
|
-
childIndex,
|
|
923
|
-
event.type,
|
|
924
|
-
JSON.stringify(event.payload) ?? 'null',
|
|
925
|
-
id,
|
|
926
|
-
now,
|
|
927
|
-
JSON.stringify(cause),
|
|
928
|
-
event.lane ?? null,
|
|
929
|
-
event.lane ?? null,
|
|
1293
|
+
consumers,
|
|
1294
|
+
cause: { consumer, lane, index, attempt },
|
|
1295
|
+
})
|
|
1296
|
+
const returned = JSON.stringify(ids)
|
|
1297
|
+
if (cursor.kind === 'lane') {
|
|
1298
|
+
finishLaneEvent.run(
|
|
1299
|
+
attempt,
|
|
1300
|
+
returned,
|
|
930
1301
|
sessionId,
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1302
|
+
consumer,
|
|
1303
|
+
lane,
|
|
1304
|
+
index,
|
|
934
1305
|
)
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
1306
|
+
} else {
|
|
1307
|
+
recordCompletion.run(
|
|
1308
|
+
index,
|
|
1309
|
+
attempt,
|
|
1310
|
+
returned,
|
|
1311
|
+
sessionId,
|
|
1312
|
+
consumer,
|
|
1313
|
+
cursor.cursor_key,
|
|
941
1314
|
)
|
|
942
|
-
}
|
|
1315
|
+
}
|
|
1316
|
+
advance(sessionId, cursor)
|
|
943
1317
|
return { outcome: 'completed', events: inserted }
|
|
944
1318
|
}),
|
|
945
1319
|
)
|
|
@@ -947,6 +1321,8 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
947
1321
|
|
|
948
1322
|
async failAttempt({
|
|
949
1323
|
sessionId,
|
|
1324
|
+
consumer,
|
|
1325
|
+
lane,
|
|
950
1326
|
index,
|
|
951
1327
|
attempt,
|
|
952
1328
|
error,
|
|
@@ -958,30 +1334,34 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
958
1334
|
if (index <= clearIndexOf(sessionId)) {
|
|
959
1335
|
return { outcome: 'superseded', failureCount: 0 }
|
|
960
1336
|
}
|
|
961
|
-
const current =
|
|
962
|
-
.prepare('select * from a2_events where session_id = ? and idx = ?')
|
|
963
|
-
.get(sessionId, index) as EventRow | undefined
|
|
1337
|
+
const current = cursorOf(sessionId, { consumer, lane, index })
|
|
964
1338
|
if (!current) {
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
1339
|
+
if (!selectEvent.get(sessionId, index)) {
|
|
1340
|
+
throw new TypeError(
|
|
1341
|
+
`no event at index ${index} in session '${sessionId}'`,
|
|
1342
|
+
)
|
|
1343
|
+
}
|
|
1344
|
+
return { outcome: 'superseded', failureCount: 0 }
|
|
1345
|
+
}
|
|
1346
|
+
if (toNumber(current.head) !== index) {
|
|
1347
|
+
return { outcome: 'superseded', failureCount: 0 }
|
|
968
1348
|
}
|
|
969
1349
|
const failureCount = Number(current.failure_count)
|
|
970
|
-
if (
|
|
971
|
-
current.processed_at !== null ||
|
|
972
|
-
Number(current.attempt_count) !== attempt
|
|
973
|
-
) {
|
|
1350
|
+
if (Number(current.attempt_count) !== attempt) {
|
|
974
1351
|
return { outcome: 'superseded', failureCount }
|
|
975
1352
|
}
|
|
976
|
-
if (current.failed_at !== null) {
|
|
977
|
-
return { outcome: 'dead_lettered', failureCount }
|
|
978
|
-
}
|
|
979
1353
|
if (
|
|
980
1354
|
current.claim_holder === null &&
|
|
981
1355
|
current.last_failed_attempt !== null &&
|
|
982
1356
|
Number(current.last_failed_attempt) === attempt
|
|
983
1357
|
) {
|
|
984
|
-
return {
|
|
1358
|
+
return {
|
|
1359
|
+
outcome: current.failed_at === null ? 'failed' : 'dead_lettered',
|
|
1360
|
+
failureCount,
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
if (current.failed_at !== null) {
|
|
1364
|
+
return { outcome: 'dead_lettered', failureCount }
|
|
985
1365
|
}
|
|
986
1366
|
if (current.claim_holder === null) {
|
|
987
1367
|
return { outcome: 'superseded', failureCount }
|
|
@@ -991,7 +1371,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
991
1371
|
const nextFailureCount = failureCount + 1
|
|
992
1372
|
const deadLettered = nextFailureCount >= maxFailures
|
|
993
1373
|
db.prepare(
|
|
994
|
-
`update
|
|
1374
|
+
`update a2_cursors set
|
|
995
1375
|
failure_count = ?,
|
|
996
1376
|
last_error = ?,
|
|
997
1377
|
last_failed_at = ?,
|
|
@@ -999,7 +1379,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
999
1379
|
failed_at = ?,
|
|
1000
1380
|
claim_holder = null,
|
|
1001
1381
|
claim_expires_at = ?
|
|
1002
|
-
where session_id = ? and
|
|
1382
|
+
where session_id = ? and consumer = ? and cursor_key = ?`,
|
|
1003
1383
|
).run(
|
|
1004
1384
|
nextFailureCount,
|
|
1005
1385
|
error,
|
|
@@ -1010,9 +1390,12 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1010
1390
|
? null
|
|
1011
1391
|
: now + retryAfterMs,
|
|
1012
1392
|
sessionId,
|
|
1013
|
-
|
|
1393
|
+
consumer,
|
|
1394
|
+
current.cursor_key,
|
|
1014
1395
|
)
|
|
1015
|
-
if (deadLettered)
|
|
1396
|
+
if (deadLettered) {
|
|
1397
|
+
releasePins(sessionId, consumer, current.cursor_key, index)
|
|
1398
|
+
}
|
|
1016
1399
|
return {
|
|
1017
1400
|
outcome: deadLettered ? 'dead_lettered' : 'failed',
|
|
1018
1401
|
failureCount: nextFailureCount,
|
|
@@ -1025,58 +1408,58 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1025
1408
|
return wrap(() => {
|
|
1026
1409
|
const rows = db
|
|
1027
1410
|
.prepare(
|
|
1028
|
-
|
|
1029
|
-
where session_id = ?
|
|
1030
|
-
and
|
|
1031
|
-
and
|
|
1032
|
-
and idx > coalesce(
|
|
1411
|
+
`${HEAD_SELECT}
|
|
1412
|
+
where cursor.session_id = ?
|
|
1413
|
+
and cursor.failed_at is not null
|
|
1414
|
+
and cursor.head > coalesce(
|
|
1033
1415
|
(select clear_index from a2_clears where session_id = ?), 0
|
|
1034
1416
|
)
|
|
1035
|
-
order by
|
|
1417
|
+
order by cursor.head, cursor.consumer`,
|
|
1036
1418
|
)
|
|
1037
|
-
.all(sessionId, sessionId) as unknown as
|
|
1038
|
-
return rows.map(
|
|
1419
|
+
.all(sessionId, sessionId) as unknown as HeadRow[]
|
|
1420
|
+
return rows.map(toHead)
|
|
1039
1421
|
})
|
|
1040
1422
|
},
|
|
1041
1423
|
|
|
1042
|
-
async redrive({ sessionId, index, action }) {
|
|
1424
|
+
async redrive({ sessionId, consumer, index, action, consumers }) {
|
|
1043
1425
|
return wrap(() =>
|
|
1044
1426
|
tx(() => {
|
|
1045
1427
|
const clearIndex = clearIndexOf(sessionId)
|
|
1046
|
-
const
|
|
1428
|
+
const cursor =
|
|
1047
1429
|
index > clearIndex
|
|
1048
1430
|
? (db
|
|
1049
1431
|
.prepare(
|
|
1050
|
-
|
|
1432
|
+
`select * from a2_cursors
|
|
1433
|
+
where session_id = ? and consumer = ? and head = ?
|
|
1434
|
+
and failed_at is not null`,
|
|
1051
1435
|
)
|
|
1052
|
-
.get(sessionId, index) as
|
|
1053
|
-
| Pick<EventRow, 'lane' | 'processed_at' | 'failed_at'>
|
|
1054
|
-
| undefined)
|
|
1436
|
+
.get(sessionId, consumer, index) as CursorRow | undefined)
|
|
1055
1437
|
: undefined
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1438
|
+
if (cursor) {
|
|
1439
|
+
refreshLogTypes(sessionId, consumers)
|
|
1440
|
+
if (action === 'skip') {
|
|
1441
|
+
if (cursor.kind === 'lane') {
|
|
1442
|
+
finishLaneEvent.run(
|
|
1443
|
+
null,
|
|
1444
|
+
null,
|
|
1445
|
+
sessionId,
|
|
1446
|
+
consumer,
|
|
1447
|
+
cursor.lane,
|
|
1448
|
+
index,
|
|
1449
|
+
)
|
|
1450
|
+
}
|
|
1451
|
+
advance(sessionId, cursor)
|
|
1452
|
+
} else {
|
|
1453
|
+
db.prepare(
|
|
1454
|
+
`update a2_cursors set failed_at = null, failure_count = 0
|
|
1455
|
+
where session_id = ? and consumer = ? and cursor_key = ?`,
|
|
1456
|
+
).run(sessionId, consumer, cursor.cursor_key)
|
|
1067
1457
|
}
|
|
1068
|
-
} else if (redriven) {
|
|
1069
|
-
db.prepare(
|
|
1070
|
-
`update a2_events set failed_at = null, failure_count = 0
|
|
1071
|
-
where session_id = ? and idx = ?`,
|
|
1072
|
-
).run(sessionId, index)
|
|
1073
1458
|
}
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
sessionId,
|
|
1077
|
-
|
|
1078
|
-
) as AppendStatusRow
|
|
1079
|
-
return { redriven, hasPending: Number(status.has_pending) === 1 }
|
|
1459
|
+
return {
|
|
1460
|
+
redriven: cursor !== undefined,
|
|
1461
|
+
hasPending: hasPending(sessionId, clearIndex, consumers),
|
|
1462
|
+
}
|
|
1080
1463
|
}),
|
|
1081
1464
|
)
|
|
1082
1465
|
},
|
|
@@ -1116,21 +1499,15 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1116
1499
|
(select state from snapshot) as snapshot_state,
|
|
1117
1500
|
null as session_id, null as idx, null as event_type,
|
|
1118
1501
|
null as payload, null as event_id, null as created_at,
|
|
1119
|
-
null as cause
|
|
1120
|
-
null as processed_at,
|
|
1121
|
-
null as processed_by_attempt, null as returned_event_ids,
|
|
1122
|
-
null as first_claimed_at,
|
|
1123
|
-
null as last_claimed_at, null as attempt_count,
|
|
1124
|
-
null as failure_count, null as last_failed_at,
|
|
1125
|
-
null as last_failed_attempt, null as last_error,
|
|
1126
|
-
null as failed_at, null as claim_holder,
|
|
1127
|
-
null as claim_expires_at
|
|
1502
|
+
null as cause
|
|
1128
1503
|
union all
|
|
1129
1504
|
select 1 as row_order, 'event' as row_kind,
|
|
1130
1505
|
null as clear_index,
|
|
1131
1506
|
(select up_to_index from head) as head_index,
|
|
1132
1507
|
null as snapshot_index, null as snapshot_state,
|
|
1133
|
-
event
|
|
1508
|
+
event.session_id, event.idx, event.event_type,
|
|
1509
|
+
event.payload, event.event_id, event.created_at,
|
|
1510
|
+
event.cause
|
|
1134
1511
|
from a2_events as event
|
|
1135
1512
|
where event.session_id = ?
|
|
1136
1513
|
and event.idx > max(
|
|
@@ -1182,19 +1559,19 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1182
1559
|
async putSnapshots(sessionId, reducerName, writes) {
|
|
1183
1560
|
await wrap(() =>
|
|
1184
1561
|
tx(() => {
|
|
1185
|
-
const
|
|
1562
|
+
const selectSnapshotHead = db.prepare(
|
|
1186
1563
|
`select up_to_index, state, updated_at from a2_snapshots
|
|
1187
1564
|
where session_id = ? and reducer_name = ?`,
|
|
1188
1565
|
)
|
|
1189
1566
|
const unfinished = db.prepare(
|
|
1190
|
-
`select 1 from
|
|
1191
|
-
where session_id = ? and
|
|
1192
|
-
and
|
|
1567
|
+
`select 1 from a2_cursors
|
|
1568
|
+
where session_id = ? and consumer = ? and cursor_key = ?
|
|
1569
|
+
and head = ? and head > ? and failed_at is null`,
|
|
1193
1570
|
)
|
|
1194
1571
|
const insertPin = db.prepare(
|
|
1195
1572
|
`insert or ignore into a2_snapshot_pins
|
|
1196
|
-
(session_id, event_index, reducer_name, up_to_index)
|
|
1197
|
-
values (?, ?, ?, ?)`,
|
|
1573
|
+
(session_id, consumer, cursor_key, event_index, reducer_name, up_to_index)
|
|
1574
|
+
values (?, ?, ?, ?, ?, ?)`,
|
|
1198
1575
|
)
|
|
1199
1576
|
const hasPin = db.prepare(
|
|
1200
1577
|
`select 1 from a2_snapshot_pins
|
|
@@ -1226,12 +1603,28 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1226
1603
|
for (const write of writes.toSorted((a, b) => a.index - b.index)) {
|
|
1227
1604
|
// A fold from before the latest clear describes a lifetime that is gone.
|
|
1228
1605
|
if (write.index < clearIndex) continue
|
|
1229
|
-
for (const
|
|
1230
|
-
|
|
1231
|
-
|
|
1606
|
+
for (const pin of write.pins ?? []) {
|
|
1607
|
+
const cursorKey = cursorKeyOf(pin.lane, pin.index)
|
|
1608
|
+
if (
|
|
1609
|
+
unfinished.get(
|
|
1610
|
+
sessionId,
|
|
1611
|
+
pin.consumer,
|
|
1612
|
+
cursorKey,
|
|
1613
|
+
pin.index,
|
|
1614
|
+
clearIndex,
|
|
1615
|
+
)
|
|
1616
|
+
) {
|
|
1617
|
+
insertPin.run(
|
|
1618
|
+
sessionId,
|
|
1619
|
+
pin.consumer,
|
|
1620
|
+
cursorKey,
|
|
1621
|
+
pin.index,
|
|
1622
|
+
reducerName,
|
|
1623
|
+
write.index,
|
|
1624
|
+
)
|
|
1232
1625
|
}
|
|
1233
1626
|
}
|
|
1234
|
-
const head =
|
|
1627
|
+
const head = selectSnapshotHead.get(sessionId, reducerName) as
|
|
1235
1628
|
SnapshotHeadRow | undefined
|
|
1236
1629
|
const now = clock.now().getTime()
|
|
1237
1630
|
if (!head || write.index >= Number(head.up_to_index)) {
|
|
@@ -1283,23 +1676,22 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1283
1676
|
const atMs = meta.at.getTime()
|
|
1284
1677
|
// Expiry anchors on the storage clock — the sender's `at`
|
|
1285
1678
|
// orders writes but never anchors their lifetime.
|
|
1286
|
-
const
|
|
1679
|
+
const nowMs = clock.now().getTime()
|
|
1680
|
+
const expiresAtMs = nowMs + meta.ttlMs
|
|
1287
1681
|
for (const [field, value] of entries) {
|
|
1288
|
-
// Field-wise LWW
|
|
1289
|
-
//
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
)
|
|
1302
|
-
}
|
|
1682
|
+
// Field-wise LWW against values and tombstones alike: an
|
|
1683
|
+
// unexpired stored row with a strictly newer `at` survives;
|
|
1684
|
+
// ties go to the incoming write (memory parity).
|
|
1685
|
+
upsertPresence.run(
|
|
1686
|
+
ns,
|
|
1687
|
+
participant,
|
|
1688
|
+
field,
|
|
1689
|
+
JSON.stringify(value) ?? 'null',
|
|
1690
|
+
meta.seen,
|
|
1691
|
+
atMs,
|
|
1692
|
+
expiresAtMs,
|
|
1693
|
+
nowMs,
|
|
1694
|
+
)
|
|
1303
1695
|
}
|
|
1304
1696
|
}),
|
|
1305
1697
|
)
|
|
@@ -1349,15 +1741,7 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1349
1741
|
tx(() => {
|
|
1350
1742
|
const clearIndex = clearIndexOf(sessionId)
|
|
1351
1743
|
if (cause) assertCurrentCause({ sessionId, cause, clearIndex })
|
|
1352
|
-
const head =
|
|
1353
|
-
(
|
|
1354
|
-
appendStatus.get(
|
|
1355
|
-
sessionId,
|
|
1356
|
-
sessionId,
|
|
1357
|
-
clearIndex,
|
|
1358
|
-
) as AppendStatusRow
|
|
1359
|
-
).max,
|
|
1360
|
-
)
|
|
1744
|
+
const head = headOf(sessionId)
|
|
1361
1745
|
if (ifLastIndex !== undefined && clearIndex === ifLastIndex + 1) {
|
|
1362
1746
|
return { cleared: true, index: head, clearIndex }
|
|
1363
1747
|
}
|
|
@@ -1368,26 +1752,32 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1368
1752
|
return { cleared: true, index: head, clearIndex }
|
|
1369
1753
|
}
|
|
1370
1754
|
const marker = head + 1
|
|
1371
|
-
const now = clock.now().getTime()
|
|
1372
1755
|
insertEvent.run(
|
|
1373
1756
|
sessionId,
|
|
1374
1757
|
marker,
|
|
1375
1758
|
CLEAR_EVENT_TYPE,
|
|
1376
1759
|
'null',
|
|
1377
1760
|
generateId(),
|
|
1378
|
-
now,
|
|
1379
|
-
null,
|
|
1380
|
-
null,
|
|
1761
|
+
clock.now().getTime(),
|
|
1381
1762
|
null,
|
|
1382
|
-
sessionId,
|
|
1383
|
-
null,
|
|
1384
|
-
clearIndex,
|
|
1385
|
-
now,
|
|
1386
1763
|
)
|
|
1387
1764
|
db.prepare(
|
|
1388
1765
|
`insert into a2_clears (session_id, clear_index) values (?, ?)
|
|
1389
1766
|
on conflict (session_id) do update set clear_index = excluded.clear_index`,
|
|
1390
1767
|
).run(sessionId, marker)
|
|
1768
|
+
// One row per log consumer: they count from the marker. Lane and
|
|
1769
|
+
// concurrent cursors at or below it are superseded by the marker
|
|
1770
|
+
// alone, and reclaim deletes them.
|
|
1771
|
+
db.prepare(
|
|
1772
|
+
`update a2_cursors
|
|
1773
|
+
set position = ?, head = null, ${FRESH_DISPATCH},
|
|
1774
|
+
completed_idx = null, completed_attempt = null,
|
|
1775
|
+
returned_event_ids = null
|
|
1776
|
+
where session_id = ? and kind = 'log'`,
|
|
1777
|
+
).run(marker, sessionId)
|
|
1778
|
+
db.prepare(
|
|
1779
|
+
'delete from a2_snapshot_pins where session_id = ? and event_index < ?',
|
|
1780
|
+
).run(sessionId, marker)
|
|
1391
1781
|
return { cleared: true, index: marker, clearIndex: marker }
|
|
1392
1782
|
}),
|
|
1393
1783
|
)
|
|
@@ -1420,7 +1810,11 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1420
1810
|
.run(sessionId, through).changes,
|
|
1421
1811
|
)
|
|
1422
1812
|
db.prepare(
|
|
1423
|
-
'delete from
|
|
1813
|
+
'delete from a2_lane_events where session_id = ? and idx <= ?',
|
|
1814
|
+
).run(sessionId, through)
|
|
1815
|
+
db.prepare(
|
|
1816
|
+
`delete from a2_cursors
|
|
1817
|
+
where session_id = ? and kind = 'event' and position < ?`,
|
|
1424
1818
|
).run(sessionId, through)
|
|
1425
1819
|
}
|
|
1426
1820
|
const done =
|
|
@@ -1431,6 +1825,11 @@ export function sqlite(options: SqliteStoreOptions = {}): SqliteStore {
|
|
|
1431
1825
|
.get(sessionId, clearIndex) === undefined
|
|
1432
1826
|
// Writes serialize with the clear, so none lands below the marker later.
|
|
1433
1827
|
if (done) {
|
|
1828
|
+
db.prepare(
|
|
1829
|
+
`delete from a2_cursors
|
|
1830
|
+
where session_id = ? and kind != 'log'
|
|
1831
|
+
and (head is null or head <= ?) and position <= ?`,
|
|
1832
|
+
).run(sessionId, clearIndex, clearIndex)
|
|
1434
1833
|
for (const table of [
|
|
1435
1834
|
'a2_snapshot_pins',
|
|
1436
1835
|
'a2_snapshot_history',
|