experimental-a2 0.19.0 → 0.21.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +1 -1
- package/dist/{actor-C7b-1arn.d.ts → actor-HnGmrOZZ.d.ts} +5 -5
- package/dist/{actor-C7b-1arn.d.ts.map → actor-HnGmrOZZ.d.ts.map} +1 -1
- package/dist/actor-client.d.ts +1 -1
- package/dist/actor-client.js +2 -2
- package/dist/actor-react.d.ts +3 -3
- package/dist/actor-react.js +5 -5
- package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
- package/dist/{actor-shared-t7w8xIKP.js.map → actor-shared-DFET5q16.js.map} +1 -1
- package/dist/actor.d.ts +1 -1
- package/dist/actor.js +54 -43
- package/dist/actor.js.map +1 -1
- package/dist/{ai-Cz3hqVhk.d.ts → ai-CvEnqVfo.d.ts} +8 -8
- package/dist/{ai-Cz3hqVhk.d.ts.map → ai-CvEnqVfo.d.ts.map} +1 -1
- package/dist/{ai-mMiWul1T.js → ai-CzE672Q3.js} +9 -9
- package/dist/ai-CzE672Q3.js.map +1 -0
- package/dist/ai-client.d.ts +2 -2
- package/dist/ai-client.js +2 -2
- package/dist/ai-fx.d.ts +1 -1
- package/dist/ai-fx.d.ts.map +1 -1
- package/dist/ai-fx.js +36 -9
- package/dist/ai-fx.js.map +1 -1
- package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-Bfk-Lycn.d.ts} +2 -2
- package/dist/ai-harness-Bfk-Lycn.d.ts.map +1 -0
- package/dist/{ai-harness-BuuC-gJ3.js → ai-harness-dQeJaR1t.js} +17 -4
- package/dist/ai-harness-dQeJaR1t.js.map +1 -0
- package/dist/ai-pi.d.ts +1 -1
- package/dist/ai-pi.js +4 -4
- package/dist/ai-pi.js.map +1 -1
- package/dist/ai-react.d.ts +5 -5
- package/dist/ai-react.js +2 -2
- package/dist/ai-server.d.ts +14 -17
- package/dist/ai-server.d.ts.map +1 -1
- package/dist/ai-server.js +84 -75
- package/dist/ai-server.js.map +1 -1
- package/dist/ai.d.ts +2 -2
- package/dist/ai.js +2 -2
- package/dist/{client-BhHLoNKF.js → client-9IeTsiAs.js} +5 -5
- package/dist/{client-BhHLoNKF.js.map → client-9IeTsiAs.js.map} +1 -1
- package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
- package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
- package/dist/client.d.ts +3 -3
- package/dist/client.js +1 -1
- package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
- package/dist/contract-CWukt01d.js.map +1 -0
- package/dist/index.d.ts +5 -5
- package/dist/index.js +3 -3
- package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
- package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
- package/dist/{internal-CscSY7K_.js → internal-Bc4PvfHk.js} +10 -3
- package/dist/internal-Bc4PvfHk.js.map +1 -0
- package/dist/otel.d.ts +1 -1
- package/dist/otel.js +1 -1
- package/dist/otel.js.map +1 -1
- package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
- package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
- package/dist/react.d.ts +1 -1
- package/dist/react.js +1 -1
- package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
- package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
- package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
- package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
- package/dist/runtime-consumer-BWmht60z.js +12 -0
- package/dist/runtime-consumer-BWmht60z.js.map +1 -0
- package/dist/scheduler-qstash.d.ts +2 -2
- package/dist/scheduler-qstash.d.ts.map +1 -1
- package/dist/scheduler-qstash.js +7 -7
- package/dist/scheduler-qstash.js.map +1 -1
- package/dist/scheduler-task-X1SToDjP.js.map +1 -1
- package/dist/scheduler-vercel.d.ts +2 -2
- package/dist/scheduler-vercel.d.ts.map +1 -1
- package/dist/scheduler-vercel.js +5 -4
- package/dist/scheduler-vercel.js.map +1 -1
- package/dist/{server-DYAATFuT.d.ts → server-BshFZ0eL.d.ts} +108 -41
- package/dist/server-BshFZ0eL.d.ts.map +1 -0
- package/dist/{server-D-Y4pqwu.js → server-DvIB8kj5.js} +423 -135
- package/dist/server-DvIB8kj5.js.map +1 -0
- package/dist/server.browser.js +1 -1
- package/dist/server.browser.js.map +1 -1
- package/dist/server.d.ts +4 -4
- package/dist/server.js +2 -2
- package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-Bk_29eEw.d.ts} +2 -2
- package/dist/session-clear-Bk_29eEw.d.ts.map +1 -0
- package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
- package/dist/session-clear-wHd5GCpZ.js.map +1 -0
- package/dist/store-N8PXxDAS.js.map +1 -1
- package/dist/store-Z2qjw50n.d.ts +523 -0
- package/dist/store-Z2qjw50n.d.ts.map +1 -0
- package/dist/store-codec-DwrWlgQx.js +65 -0
- package/dist/store-codec-DwrWlgQx.js.map +1 -0
- package/dist/store-memory.d.ts +1 -1
- package/dist/store-memory.d.ts.map +1 -1
- package/dist/store-memory.js +393 -210
- package/dist/store-memory.js.map +1 -1
- package/dist/{store-polling-7WJV0YWP.js → store-polling-DYNyxCev.js} +2 -2
- package/dist/{store-polling-7WJV0YWP.js.map → store-polling-DYNyxCev.js.map} +1 -1
- package/dist/store-postgres.d.ts +1 -1
- package/dist/store-postgres.d.ts.map +1 -1
- package/dist/store-postgres.js +1595 -673
- package/dist/store-postgres.js.map +1 -1
- package/dist/{store-presence-polling-B1ixeqIS.js → store-presence-polling-CzQ0wdtM.js} +3 -3
- package/dist/{store-presence-polling-B1ixeqIS.js.map → store-presence-polling-CzQ0wdtM.js.map} +1 -1
- package/dist/store-redis-http.d.ts +1 -1
- package/dist/store-redis-http.js +4 -4
- package/dist/store-redis-notify-IiVdlYHc.js +2919 -0
- package/dist/store-redis-notify-IiVdlYHc.js.map +1 -0
- package/dist/store-redis.d.ts +1 -1
- package/dist/store-redis.js +2 -2
- package/dist/store-sqlite.d.ts +1 -1
- package/dist/store-sqlite.d.ts.map +1 -1
- package/dist/store-sqlite.js +517 -287
- package/dist/store-sqlite.js.map +1 -1
- package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
- package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
- package/dist/{wire-BKxH1vbP.js → wire-CZGqNZRx.js} +5 -4
- package/dist/wire-CZGqNZRx.js.map +1 -0
- package/docs/01-quickstart.mdx +28 -19
- package/docs/02-examples.mdx +1 -1
- package/docs/actors/01-introduction.mdx +9 -1
- package/docs/actors/02-concurrency.mdx +1 -1
- package/docs/actors/meta.ts +1 -1
- package/docs/agents/01-introduction.mdx +1 -1
- package/docs/agents/02-tools.mdx +13 -12
- package/docs/agents/03-conversations.mdx +31 -21
- package/docs/agents/04-harnesses.mdx +7 -3
- package/docs/agents/meta.ts +1 -1
- package/docs/client/03-presence.mdx +18 -14
- package/docs/client/meta.ts +1 -1
- package/docs/contract/01-events.mdx +2 -2
- package/docs/contract/02-state.mdx +18 -14
- package/docs/contract/03-sessions.mdx +32 -25
- package/docs/index.mdx +31 -26
- package/docs/reference/01-core.mdx +12 -12
- package/docs/reference/02-server.mdx +247 -192
- package/docs/reference/04-ai.mdx +29 -20
- package/docs/reference/05-telemetry.mdx +146 -0
- package/docs/reference/06-errors.mdx +24 -18
- package/docs/reference/meta.ts +1 -1
- package/docs/schedulers/01-overview.mdx +112 -0
- package/docs/schedulers/02-vercel-queues.mdx +87 -0
- package/docs/schedulers/03-qstash.mdx +123 -0
- package/docs/schedulers/meta.ts +1 -0
- package/docs/server/01-consumers.mdx +415 -0
- package/docs/server/02-timers.mdx +33 -23
- package/docs/server/03-cancellation.mdx +52 -44
- package/docs/server/04-routes.mdx +4 -1
- package/docs/server/05-sql-projections.mdx +391 -0
- package/docs/server/06-guarantees.mdx +60 -50
- package/docs/stores/01-overview.mdx +37 -0
- package/docs/stores/02-redis.mdx +57 -0
- package/docs/stores/03-redis-http.mdx +56 -0
- package/docs/stores/04-postgres.mdx +172 -0
- package/docs/stores/05-sqlite.mdx +36 -0
- package/docs/stores/06-memory.mdx +22 -0
- package/docs/stores/meta.ts +1 -0
- package/docs/upgrades/01-overview.mdx +30 -0
- package/docs/upgrades/79-0-21.mdx +165 -0
- package/docs/upgrades/80-0-20.mdx +43 -0
- package/docs/upgrades/81-0-19.mdx +33 -0
- package/docs/upgrades/82-0-18.mdx +33 -0
- package/docs/upgrades/83-0-17.mdx +69 -0
- package/docs/upgrades/meta.ts +3 -0
- package/examples/playground/DEPLOY.md +48 -11
- package/examples/playground/app/a0/apps/[appId]/route.ts +6 -10
- package/examples/playground/app/a0/chat.test.tsx +421 -330
- package/examples/playground/app/a0/sandbox-log.ts +17 -15
- package/examples/playground/app/a0/server.ts +4 -4
- package/examples/playground/app/a0/shell.tsx +1 -1
- package/examples/playground/app/a0/uploads/route.test.ts +59 -0
- package/examples/playground/app/a2slack/a2slack.css +1067 -0
- package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
- package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
- package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
- package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
- package/examples/playground/app/a2slack/consumers/search.ts +37 -0
- package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
- package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
- package/examples/playground/app/a2slack/db/a2.ts +206 -0
- package/examples/playground/app/a2slack/db/index.ts +73 -0
- package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
- package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
- package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
- package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
- package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +34 -0
- package/examples/playground/app/a2slack/db/queries.ts +263 -0
- package/examples/playground/app/a2slack/db/schema.ts +145 -0
- package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
- package/examples/playground/app/a2slack/drills/harness.ts +244 -0
- package/examples/playground/app/a2slack/events/route.ts +36 -0
- package/examples/playground/app/a2slack/identity.ts +44 -0
- package/examples/playground/app/a2slack/inbox/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
- package/examples/playground/app/a2slack/lag/page.tsx +101 -0
- package/examples/playground/app/a2slack/lag.ts +73 -0
- package/examples/playground/app/a2slack/layout.tsx +43 -0
- package/examples/playground/app/a2slack/messages/route.ts +17 -0
- package/examples/playground/app/a2slack/model.test.ts +208 -0
- package/examples/playground/app/a2slack/model.ts +369 -0
- package/examples/playground/app/a2slack/models.test.ts +72 -0
- package/examples/playground/app/a2slack/page.tsx +10 -0
- package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
- package/examples/playground/app/a2slack/search/route.ts +40 -0
- package/examples/playground/app/a2slack/search-index.ts +108 -0
- package/examples/playground/app/a2slack/server.test.ts +315 -0
- package/examples/playground/app/a2slack/server.ts +55 -0
- package/examples/playground/app/a2slack/sessions.ts +50 -0
- package/examples/playground/app/a2slack/thread/route.ts +8 -0
- package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
- package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
- package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
- package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
- package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
- package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
- package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
- package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
- package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
- package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
- package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
- package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
- package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
- package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
- package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
- package/examples/playground/app/agent/[agentId]/agent-queue.test.tsx +116 -90
- package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +107 -76
- package/examples/playground/app/agent/ingress.test.ts +41 -17
- package/examples/playground/app/agent/model-input.test.tsx +92 -43
- package/examples/playground/app/agent/server.ts +3 -3
- package/examples/playground/app/chat/[chatId]/agent-stream-drawer.test.tsx +2 -2
- package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
- package/examples/playground/app/chat/server.test.ts +38 -65
- package/examples/playground/app/chat/server.ts +45 -35
- package/examples/playground/app/components/activity-history.test.tsx +60 -18
- package/examples/playground/app/components/demo-icons.tsx +7 -0
- package/examples/playground/app/components/demos.tsx +7 -0
- package/examples/playground/app/components/fast-link.test.tsx +3 -5
- package/examples/playground/app/initial-state.test.tsx +86 -45
- package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
- package/examples/playground/app/orders/page.tsx +1 -1
- package/examples/playground/app/orders/server.ts +49 -30
- package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
- package/examples/playground/app/recovery/page.tsx +4 -4
- package/examples/playground/app/recovery/server.ts +23 -32
- package/examples/playground/drizzle.config.ts +14 -0
- package/examples/playground/next.config.ts +6 -1
- package/examples/playground/package.json +13 -1
- package/examples/playground/scripts/migrate.mjs +31 -0
- package/examples/playground/test/agent.ts +35 -0
- package/examples/playground/test/model.ts +149 -0
- package/examples/playground/test/serve.ts +184 -0
- package/examples/playground/test/server-only.ts +1 -0
- package/examples/playground/vercel.json +9 -0
- package/examples/playground/vitest.config.ts +27 -0
- package/examples/playground/vitest.drills.config.ts +32 -0
- package/package.json +7 -2
- package/sql/postgres/drizzle-schema.ts +206 -0
- package/sql/postgres/notifications.sql +24 -0
- package/sql/postgres/schema.sql +276 -0
- package/src/actor.ts +101 -71
- package/src/ai-agent.ts +154 -115
- package/src/ai-control-server.ts +8 -8
- package/src/ai-fx.ts +41 -9
- package/src/ai-harness.ts +28 -2
- package/src/ai-pi.ts +2 -2
- package/src/ai-server.ts +2 -2
- package/src/ai.ts +7 -7
- package/src/contract.ts +1 -1
- package/src/deterministic-id.ts +26 -3
- package/src/errors.ts +9 -6
- package/src/idempotent-replay.ts +0 -1
- package/src/index.ts +1 -1
- package/src/internal.ts +17 -6
- package/src/otel.ts +1 -1
- package/src/postgres-notifications.ts +0 -22
- package/src/postgres-resources.ts +6 -9
- package/src/postgres-schema.ts +121 -0
- package/src/push-envelope.ts +20 -0
- package/src/runtime-consumer.ts +10 -0
- package/src/scheduler-qstash.ts +12 -5
- package/src/scheduler-task.ts +2 -2
- package/src/scheduler-vercel.ts +10 -4
- package/src/server.browser.ts +1 -1
- package/src/server.ts +839 -238
- package/src/session-socket.ts +1 -1
- package/src/store-codec.ts +66 -0
- package/src/store-memory.ts +581 -255
- package/src/store-postgres.ts +1984 -943
- package/src/store-redis-core.ts +1624 -1152
- package/src/store-redis-notify.ts +2 -0
- package/src/store-sqlite.ts +891 -486
- package/src/store.ts +227 -97
- package/src/telemetry.ts +2 -1
- package/src/wire.ts +1 -0
- package/dist/ai-harness-BuuC-gJ3.js.map +0 -1
- package/dist/ai-harness-CV6rXbB3.d.ts.map +0 -1
- package/dist/ai-mMiWul1T.js.map +0 -1
- package/dist/contract-BYKASlCV.js.map +0 -1
- package/dist/idempotent-replay-JdgMQRRm.js +0 -21
- package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
- package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
- package/dist/internal-CscSY7K_.js.map +0 -1
- package/dist/server-D-Y4pqwu.js.map +0 -1
- package/dist/server-DYAATFuT.d.ts.map +0 -1
- package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
- package/dist/session-clear-DGI4jTQ5.js.map +0 -1
- package/dist/store-CyLUy86v.d.ts +0 -404
- package/dist/store-CyLUy86v.d.ts.map +0 -1
- package/dist/store-codec-DTG0Ftek.js +0 -10
- package/dist/store-codec-DTG0Ftek.js.map +0 -1
- package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
- package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
- package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
- package/dist/wire-BKxH1vbP.js.map +0 -1
- package/docs/reference/05-adapters.mdx +0 -351
- package/docs/server/01-handlers.mdx +0 -200
- package/docs/server/05-production.mdx +0 -323
package/dist/store-postgres.js
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
|
-
import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-
|
|
2
|
-
import { _ as nullProtoRecord, r as NOTIFY_TIMINGS } from "./internal-
|
|
3
|
-
import { n as pollingStream, t as defaultSleep } from "./store-polling-
|
|
1
|
+
import { i as A2Error, r as sessionClearedError, s as supersededAttemptError, t as CLEAR_EVENT_TYPE } from "./session-clear-wHd5GCpZ.js";
|
|
2
|
+
import { _ as nullProtoRecord, r as NOTIFY_TIMINGS } from "./internal-Bc4PvfHk.js";
|
|
3
|
+
import { n as pollingStream, t as defaultSleep } from "./store-polling-DYNyxCev.js";
|
|
4
4
|
import { r as platformWaitUntil } from "./platform-B4TnJtWu.js";
|
|
5
5
|
import { t as retryableLazy } from "./retryable-lazy-DZWmHpii.js";
|
|
6
|
-
import {
|
|
6
|
+
import { a as idempotentReplay, i as idleLogCursor, n as decodeCause, r as decodeReturnedEventIds, t as cursorKeyOf } from "./store-codec-DwrWlgQx.js";
|
|
7
7
|
import { n as SYSTEM_CLOCK, t as RANDOM_IDS } from "./store-N8PXxDAS.js";
|
|
8
|
-
import { t as
|
|
9
|
-
import { t as pollPresence } from "./store-presence-polling-B1ixeqIS.js";
|
|
8
|
+
import { t as pollPresence } from "./store-presence-polling-CzQ0wdtM.js";
|
|
10
9
|
import { createHash } from "node:crypto";
|
|
11
10
|
//#region src/postgres-notifications.ts
|
|
12
11
|
const postgresNotificationChannel = ({ kind, sessionId }) => `a2_${kind}_${createHash("md5").update(sessionId).digest("hex")}`;
|
|
@@ -171,27 +170,73 @@ function postgresNotifications(createConnection) {
|
|
|
171
170
|
}
|
|
172
171
|
};
|
|
173
172
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
173
|
+
//#endregion
|
|
174
|
+
//#region src/postgres-schema.ts
|
|
175
|
+
/**
|
|
176
|
+
* The Postgres schema contract. A2 never runs DDL in a Postgres database:
|
|
177
|
+
* `sql/postgres/schema.sql`, or the tables in `drizzle-schema.ts` next to it,
|
|
178
|
+
* create
|
|
179
|
+
* the tables, and `notifications.sql` the triggers `listenConnectionString`
|
|
180
|
+
* needs. A store checks nothing up front. A query that finds a table or column
|
|
181
|
+
* missing fails anyway, so the store only turns that failure into one that
|
|
182
|
+
* says what to apply. Missing triggers fail no query, so a listening store
|
|
183
|
+
* looks for them when its listener starts.
|
|
184
|
+
*/
|
|
185
|
+
const PACKAGE = "node_modules/experimental-a2";
|
|
186
|
+
const SEE_DOCS = `See ${PACKAGE}/docs/stores/04-postgres.mdx.`;
|
|
187
|
+
/** Postgres error codes a store that hasn't been set up raises. */
|
|
188
|
+
const UNDEFINED_TABLE = "42P01";
|
|
189
|
+
const UNDEFINED_COLUMN = "42703";
|
|
190
|
+
const UNDEFINED_FUNCTION = "42883";
|
|
191
|
+
/** `a2_batch` refuses to run outside read committed. */
|
|
192
|
+
const BATCH_ISOLATION = "A2002";
|
|
193
|
+
const INSUFFICIENT_PRIVILEGE = "42501";
|
|
194
|
+
/** NUL, which Postgres can't store: in jsonb (`\u0000`), or in text. */
|
|
195
|
+
const UNTRANSLATABLE_CHARACTER = "22P05";
|
|
196
|
+
const CHARACTER_NOT_IN_REPERTOIRE = "22021";
|
|
197
|
+
/** The Postgres error in `error`'s cause chain, for clients that wrap theirs. */
|
|
198
|
+
const postgresError = (error) => {
|
|
199
|
+
for (let current = error, depth = 0; depth < 5; depth += 1) {
|
|
200
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
201
|
+
const { code, message, cause } = current;
|
|
202
|
+
if (typeof code === "string" && /^[0-9A-Z]{5}$/.test(code)) return {
|
|
203
|
+
code,
|
|
204
|
+
message: typeof message === "string" ? message : code
|
|
205
|
+
};
|
|
206
|
+
current = cause;
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
/** Explains a query that failed because A2's tables aren't set up, or on a NUL, or returns `error`. */
|
|
210
|
+
const explainPostgresError = (error) => {
|
|
211
|
+
const found = postgresError(error);
|
|
212
|
+
if (found?.code === UNDEFINED_TABLE || found?.code === UNDEFINED_COLUMN || found?.code === UNDEFINED_FUNCTION) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: A2's tables or its function are missing, or from an earlier release, and A2 doesn't create or change them at runtime. Apply ${PACKAGE}/sql/postgres/schema.sql to this database, or migrate it to the tables in drizzle-schema.ts next to it, with schema.sql's a2_batch function. ${SEE_DOCS}`, { cause: error });
|
|
213
|
+
if (found?.code === INSUFFICIENT_PRIVILEGE) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: the role this store connects as needs select, insert, update, and delete on A2's tables, and execute on a2_batch. Create them as that role, or grant it those privileges. ${SEE_DOCS}`, { cause: error });
|
|
214
|
+
if (found?.code === UNTRANSLATABLE_CHARACTER || found?.code === CHARACTER_NOT_IN_REPERTOIRE) return new A2Error("INVALID_PAYLOAD", `${found.message}: Postgres can't store a NUL character (\\u0000) in text or jsonb, so nothing was written. Remove it before appending.`, { cause: error });
|
|
215
|
+
if (found?.code === BATCH_ISOLATION) return new A2Error("STORE_NOT_CONFIGURED", `${found.message}: A2 reads what it locks in read committed, Postgres's default, and its connections here default to a stricter level. Set default_transaction_isolation to 'read committed' for the role or database this store connects to. ${SEE_DOCS}`, { cause: error });
|
|
216
|
+
return error;
|
|
217
|
+
};
|
|
218
|
+
const explained = (promise) => promise.catch((error) => {
|
|
219
|
+
throw explainPostgresError(error);
|
|
220
|
+
});
|
|
221
|
+
/** `client`, with every query's setup failures explained. */
|
|
222
|
+
const explainingPostgresClient = (client) => ({
|
|
223
|
+
query: (text, values) => explained(client.query(text, values)),
|
|
224
|
+
...client.connect && { connect: async () => {
|
|
225
|
+
const connection = await client.connect();
|
|
226
|
+
return {
|
|
227
|
+
query: (text, values) => explained(connection.query(text, values)),
|
|
228
|
+
release: () => connection.release()
|
|
229
|
+
};
|
|
230
|
+
} },
|
|
231
|
+
...client.end && { end: () => client.end() }
|
|
232
|
+
});
|
|
233
|
+
/** Fails when the triggers `listenConnectionString` listens to are missing. */
|
|
234
|
+
const checkPostgresTriggers = async (client) => {
|
|
235
|
+
const { rows } = await client.query(`select count(*) as triggers from pg_trigger
|
|
236
|
+
where (tgname = 'a2_notify_events' and tgrelid = to_regclass('a2_events'))
|
|
237
|
+
or (tgname = 'a2_notify_presence' and tgrelid = to_regclass('a2_presence'))`);
|
|
238
|
+
if (Number(rows[0]?.["triggers"]) < 2) throw new A2Error("STORE_NOT_CONFIGURED", `listenConnectionString needs A2's notification triggers, which aren't in this Postgres database. After schema.sql, apply ${PACKAGE}/sql/postgres/notifications.sql to this database. ${SEE_DOCS}`);
|
|
239
|
+
};
|
|
195
240
|
//#endregion
|
|
196
241
|
//#region src/postgres-notification-scope.ts
|
|
197
242
|
const failure = (error) => error instanceof A2Error ? error : new A2Error("STORE_UNAVAILABLE", "Postgres subscription failed", { cause: error });
|
|
@@ -446,7 +491,7 @@ function postgresConnectionKey({ connectionString, defaults }) {
|
|
|
446
491
|
}
|
|
447
492
|
const queries = (raw) => {
|
|
448
493
|
let pending = Promise.resolve();
|
|
449
|
-
const
|
|
494
|
+
const initialized = /* @__PURE__ */ new Map();
|
|
450
495
|
const exclusive = (fn) => {
|
|
451
496
|
const result = pending.then(fn);
|
|
452
497
|
pending = result.catch(() => {});
|
|
@@ -456,13 +501,13 @@ const queries = (raw) => {
|
|
|
456
501
|
raw,
|
|
457
502
|
client: raw.connect ? raw : { query: (text, values) => exclusive(() => raw.query(text, values)) },
|
|
458
503
|
exclusive,
|
|
459
|
-
initialize({
|
|
460
|
-
const existing =
|
|
504
|
+
initialize({ key, run }) {
|
|
505
|
+
const existing = initialized.get(key);
|
|
461
506
|
if (existing) return existing;
|
|
462
507
|
const attempt = run();
|
|
463
|
-
|
|
508
|
+
initialized.set(key, attempt);
|
|
464
509
|
attempt.catch(() => {
|
|
465
|
-
if (
|
|
510
|
+
if (initialized.get(key) === attempt) initialized.delete(key);
|
|
466
511
|
});
|
|
467
512
|
return attempt;
|
|
468
513
|
},
|
|
@@ -501,96 +546,26 @@ const sharePostgresListener = ({ key, create }) => acquire({
|
|
|
501
546
|
*
|
|
502
547
|
* Semantics mirror the sqlite backend: `index` is stored as `idx`,
|
|
503
548
|
* timestamps are epoch milliseconds written from the injected clock —
|
|
504
|
-
* never SQL `now()
|
|
505
|
-
*
|
|
549
|
+
* never SQL `now()`, and dispatch state lives on consumer cursors, not on
|
|
550
|
+
* events. Streams poll by default; an optional direct connection enables
|
|
551
|
+
* LISTEN/NOTIFY for events and presence. Appends serialize per session via
|
|
506
552
|
* `pg_advisory_xact_lock`, so concurrent writers can't race the index
|
|
507
|
-
* assignment
|
|
553
|
+
* assignment, and each runs in one round trip (`a2_batch`, below), so the
|
|
554
|
+
* lock is held only while the server runs it.
|
|
555
|
+
*
|
|
556
|
+
* Lock discipline. Completions lock their cursor first, then the session lock
|
|
557
|
+
* when they append, or when their cursor goes idle (`completeAttempt`);
|
|
558
|
+
* everything else that takes the session lock takes it first. Every
|
|
559
|
+
* operation that locks a cursor row locks it only in the exact state it acts
|
|
560
|
+
* on (a head, an attempt). Under the session lock, an append never writes a
|
|
561
|
+
* cursor row that exists: a lane or log cursor's runs are rows keyed by the
|
|
562
|
+
* head each run started at (its epoch), so starting an idle cursor inserts a
|
|
563
|
+
* new run and retires the idle row with `skip locked`. An append therefore
|
|
564
|
+
* never waits on a cursor row, in particular not on one a completion waiting
|
|
565
|
+
* for the session lock holds, or one a clear left behind.
|
|
508
566
|
*/
|
|
509
|
-
const SCHEMA = `
|
|
510
|
-
create table if not exists a2_events (
|
|
511
|
-
session_id text not null,
|
|
512
|
-
idx bigint not null,
|
|
513
|
-
event_type text not null,
|
|
514
|
-
payload jsonb not null,
|
|
515
|
-
event_id text not null,
|
|
516
|
-
created_at bigint not null,
|
|
517
|
-
cause jsonb,
|
|
518
|
-
lane text,
|
|
519
|
-
lane_ready boolean not null default false,
|
|
520
|
-
processed_at bigint,
|
|
521
|
-
processed_by_attempt integer,
|
|
522
|
-
returned_event_ids jsonb,
|
|
523
|
-
first_claimed_at bigint,
|
|
524
|
-
last_claimed_at bigint,
|
|
525
|
-
attempt_count integer not null default 0,
|
|
526
|
-
failure_count integer not null default 0,
|
|
527
|
-
last_failed_at bigint,
|
|
528
|
-
last_failed_attempt integer,
|
|
529
|
-
last_error text,
|
|
530
|
-
failed_at bigint,
|
|
531
|
-
claim_holder text,
|
|
532
|
-
claim_expires_at bigint,
|
|
533
|
-
primary key (session_id, idx)
|
|
534
|
-
);
|
|
535
|
-
|
|
536
|
-
create unique index if not exists a2_events_event_id on a2_events (event_id);
|
|
537
|
-
create index if not exists a2_events_unprocessed
|
|
538
|
-
on a2_events (session_id, idx) where processed_at is null;
|
|
539
|
-
create index if not exists a2_events_lane_pending
|
|
540
|
-
on a2_events (session_id, lane, idx) where processed_at is null;
|
|
541
|
-
create index if not exists a2_events_dispatch_ready
|
|
542
|
-
on a2_events (session_id, idx)
|
|
543
|
-
where lane_ready and processed_at is null and failed_at is null;
|
|
544
|
-
create index if not exists a2_events_claim_expiry
|
|
545
|
-
on a2_events (session_id, claim_expires_at)
|
|
546
|
-
where lane_ready and processed_at is null and failed_at is null;
|
|
547
|
-
|
|
548
|
-
create table if not exists a2_snapshots (
|
|
549
|
-
session_id text not null,
|
|
550
|
-
reducer_name text not null,
|
|
551
|
-
up_to_index bigint not null,
|
|
552
|
-
state jsonb not null,
|
|
553
|
-
updated_at bigint not null,
|
|
554
|
-
primary key (session_id, reducer_name)
|
|
555
|
-
);
|
|
556
|
-
|
|
557
|
-
create table if not exists a2_snapshot_history (
|
|
558
|
-
session_id text not null,
|
|
559
|
-
reducer_name text not null,
|
|
560
|
-
up_to_index bigint not null,
|
|
561
|
-
state jsonb not null,
|
|
562
|
-
updated_at bigint not null,
|
|
563
|
-
primary key (session_id, reducer_name, up_to_index)
|
|
564
|
-
);
|
|
565
|
-
|
|
566
|
-
create table if not exists a2_snapshot_pins (
|
|
567
|
-
session_id text not null,
|
|
568
|
-
event_index bigint not null,
|
|
569
|
-
reducer_name text not null,
|
|
570
|
-
up_to_index bigint not null,
|
|
571
|
-
primary key (session_id, event_index, reducer_name, up_to_index)
|
|
572
|
-
);
|
|
573
|
-
|
|
574
|
-
create index if not exists a2_snapshot_pins_checkpoint
|
|
575
|
-
on a2_snapshot_pins (session_id, reducer_name, up_to_index);
|
|
576
|
-
|
|
577
|
-
create table if not exists a2_clears (
|
|
578
|
-
session_id text primary key,
|
|
579
|
-
clear_index bigint not null
|
|
580
|
-
);
|
|
581
|
-
|
|
582
|
-
create table if not exists a2_presence (
|
|
583
|
-
ns text not null,
|
|
584
|
-
participant text not null,
|
|
585
|
-
field text not null,
|
|
586
|
-
value jsonb not null,
|
|
587
|
-
seen bigint not null,
|
|
588
|
-
at bigint not null,
|
|
589
|
-
expires_at bigint not null,
|
|
590
|
-
primary key (ns, participant, field)
|
|
591
|
-
);
|
|
592
|
-
`;
|
|
593
567
|
const asNumber = (value) => Number(value);
|
|
568
|
+
const asNullableNumber = (value) => value === null || value === void 0 ? null : asNumber(value);
|
|
594
569
|
/**
|
|
595
570
|
* The session's latest clear marker, or 0, as a primary-key subquery. Read
|
|
596
571
|
* it in a statement that runs after the operation's lock: a statement's
|
|
@@ -598,50 +573,178 @@ const asNumber = (value) => Number(value);
|
|
|
598
573
|
* miss a clear that committed meanwhile.
|
|
599
574
|
*/
|
|
600
575
|
const clearIndexOf = (session) => `coalesce((select clear_index from a2_clears where session_id = ${session}), 0)`;
|
|
576
|
+
/**
|
|
577
|
+
* Whether a served consumer has a cursor with a head in the session. A null
|
|
578
|
+
* `served` array serves every consumer.
|
|
579
|
+
*/
|
|
580
|
+
const pendingIn = (session, served) => `exists(
|
|
581
|
+
select 1 from a2_cursors pending
|
|
582
|
+
where pending.session_id = ${session}
|
|
583
|
+
and pending.head > ${clearIndexOf(session)}
|
|
584
|
+
and pending.failed_at is null
|
|
585
|
+
and (${served}::text[] is null or pending.consumer = any(${served}::text[]))
|
|
586
|
+
)`;
|
|
587
|
+
/** A fresh head's dispatch columns, for `set` clauses. */
|
|
588
|
+
const FRESH_DISPATCH = `
|
|
589
|
+
attempt_count = 0, first_claimed_at = null, last_claimed_at = null,
|
|
590
|
+
claim_holder = null, claim_expires_at = null, failure_count = 0,
|
|
591
|
+
last_failed_at = null, last_failed_attempt = null, last_error = null,
|
|
592
|
+
failed_at = null`;
|
|
593
|
+
/** The head event's columns a claim, dead letter, or listing joins in, prefixed `e_`. */
|
|
594
|
+
const HEAD_COLUMNS = `
|
|
595
|
+
event.idx as e_idx, event.event_type as e_event_type,
|
|
596
|
+
event.payload as e_payload, event.event_id as e_event_id,
|
|
597
|
+
event.created_at as e_created_at, event.cause as e_cause`;
|
|
601
598
|
const RECLAIM_BATCH = 256;
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
599
|
+
/**
|
|
600
|
+
* A cursor run reclaim deletes once the session is cleared through `$2`:
|
|
601
|
+
* one from before the clear, or one a later run of its cursor replaced. A
|
|
602
|
+
* log cursor's last run stays: it records that the consumer counts from the
|
|
603
|
+
* marker.
|
|
604
|
+
*/
|
|
605
|
+
const SUPERSEDED_CURSOR = `
|
|
606
|
+
cur.session_id = $1 and (
|
|
607
|
+
(cur.epoch < $2 and cur.kind <> 'log')
|
|
608
|
+
or ((cur.head is null or cur.head <= $2) and exists (
|
|
609
|
+
select 1 from a2_cursors later
|
|
610
|
+
where later.session_id = cur.session_id
|
|
611
|
+
and later.cursor_key = cur.cursor_key
|
|
612
|
+
and later.consumer = cur.consumer
|
|
613
|
+
and later.epoch > cur.epoch
|
|
614
|
+
))
|
|
615
|
+
)`;
|
|
616
|
+
/**
|
|
617
|
+
* A value of `a2_batch`'s one parameter, `$1`: a JSON object the store builds
|
|
618
|
+
* for each call. JSON null reads as SQL null.
|
|
619
|
+
*/
|
|
620
|
+
const param = {
|
|
621
|
+
text: (key) => `($1->>'${key}')`,
|
|
622
|
+
bigint: (key) => `($1->>'${key}')::bigint`,
|
|
623
|
+
int: (key) => `($1->>'${key}')::int`,
|
|
624
|
+
boolean: (key) => `($1->>'${key}')::boolean`,
|
|
625
|
+
jsonb: (key) => `nullif($1->'${key}', 'null'::jsonb)`,
|
|
626
|
+
/** A JSON array of strings as text[], or null when the value isn't an array. */
|
|
627
|
+
texts: (key) => `(case when jsonb_typeof($1->'${key}') = 'array'
|
|
628
|
+
then array(select jsonb_array_elements_text($1->'${key}')) end)`
|
|
613
629
|
};
|
|
614
|
-
|
|
630
|
+
/** Every batch names its session. */
|
|
631
|
+
const SESSION = param.text("session");
|
|
632
|
+
/**
|
|
633
|
+
* The next event after `after` a log cursor takes, as a scalar subquery:
|
|
634
|
+
* `types` is its JSON array, or null for every type but the clear marker
|
|
635
|
+
* (`clearType`). Each listed type is one probe of `a2_events_types`, so a
|
|
636
|
+
* consumer of rare types never scans the events it skips.
|
|
637
|
+
*/
|
|
638
|
+
const nextLogHead = ({ types, after, clearType = param.text("clearType") }) => `
|
|
639
|
+
case when ${types} is null then (
|
|
640
|
+
select event.idx from a2_events event
|
|
641
|
+
where event.session_id = ${SESSION} and event.idx > ${after}
|
|
642
|
+
and event.event_type <> ${clearType}
|
|
643
|
+
order by event.idx limit 1
|
|
644
|
+
) else (
|
|
645
|
+
select min(next.idx)
|
|
646
|
+
from jsonb_array_elements_text(${types}) as wanted(event_type)
|
|
647
|
+
cross join lateral (
|
|
648
|
+
select event.idx from a2_events event
|
|
649
|
+
where event.session_id = ${SESSION} and event.event_type = wanted.event_type
|
|
650
|
+
and event.idx > ${after}
|
|
651
|
+
order by event.idx limit 1
|
|
652
|
+
) next
|
|
653
|
+
where wanted.event_type <> ${clearType}
|
|
654
|
+
) end`;
|
|
655
|
+
/** The latest run of `target`'s cursor, as a lateral subquery. */
|
|
656
|
+
const LATEST_RUN = (target) => `
|
|
657
|
+
select cur.epoch, cur.position, cur.head from a2_cursors cur
|
|
658
|
+
where cur.session_id = ${SESSION} and cur.cursor_key = ${target}.cursor_key
|
|
659
|
+
and cur.consumer = ${target}.consumer
|
|
660
|
+
order by cur.epoch desc limit 1`;
|
|
661
|
+
/**
|
|
662
|
+
* Retires the runs `target` replaces, those idle or superseded by the clear
|
|
663
|
+
* marker `clearIndex`, skipping any another operation holds: a later reclaim
|
|
664
|
+
* deletes those.
|
|
665
|
+
*/
|
|
666
|
+
const RETIRE_RUNS = (target, clearIndex) => `
|
|
667
|
+
idle as materialized (
|
|
668
|
+
select cur.ctid from a2_cursors cur
|
|
669
|
+
join ${target} on cur.cursor_key = ${target}.cursor_key
|
|
670
|
+
and cur.consumer = ${target}.consumer
|
|
671
|
+
where cur.session_id = ${SESSION}
|
|
672
|
+
and (cur.head is null or cur.head <= ${clearIndex})
|
|
673
|
+
for update of cur skip locked
|
|
674
|
+
), retired as (
|
|
675
|
+
delete from a2_cursors old using idle where old.ctid = idle.ctid
|
|
676
|
+
)`;
|
|
677
|
+
const toDate = (ms) => ms === null || ms === void 0 ? null : new Date(asNumber(ms));
|
|
678
|
+
const toEvent = (row) => ({
|
|
615
679
|
id: row["event_id"],
|
|
616
680
|
type: row["event_type"],
|
|
617
681
|
payload: row["payload"],
|
|
618
682
|
index: asNumber(row["idx"]),
|
|
619
683
|
sessionId: row["session_id"],
|
|
620
|
-
createdAt: new Date(asNumber(row["created_at"]))
|
|
621
|
-
|
|
684
|
+
createdAt: new Date(asNumber(row["created_at"]))
|
|
685
|
+
});
|
|
686
|
+
const toStored = (row) => ({
|
|
687
|
+
...toEvent(row),
|
|
688
|
+
cause: decodeCause(row["cause"])
|
|
689
|
+
});
|
|
690
|
+
const toDispatch = (row) => ({
|
|
691
|
+
consumer: row["consumer"],
|
|
622
692
|
lane: row["lane"] ?? null,
|
|
623
|
-
processedAt: toDate(row["processed_at"]),
|
|
624
|
-
processedByAttempt: row["processed_by_attempt"] === null || row["processed_by_attempt"] === void 0 ? null : asNumber(row["processed_by_attempt"]),
|
|
625
|
-
returnedEventIds: row["returned_event_ids"] === null || row["returned_event_ids"] === void 0 ? null : decodeReturnedEventIds(row["returned_event_ids"]),
|
|
626
693
|
firstClaimedAt: toDate(row["first_claimed_at"]),
|
|
627
694
|
lastClaimedAt: toDate(row["last_claimed_at"]),
|
|
628
695
|
attemptCount: asNumber(row["attempt_count"]),
|
|
696
|
+
claimHolder: row["claim_holder"] ?? null,
|
|
697
|
+
claimExpiresAt: toDate(row["claim_expires_at"]),
|
|
629
698
|
failureCount: asNumber(row["failure_count"]),
|
|
630
699
|
lastFailedAt: toDate(row["last_failed_at"]),
|
|
631
|
-
lastFailedAttempt:
|
|
700
|
+
lastFailedAttempt: asNullableNumber(row["last_failed_attempt"]),
|
|
632
701
|
lastError: row["last_error"] ?? null,
|
|
633
|
-
failedAt: toDate(row["failed_at"])
|
|
634
|
-
claimHolder: row["claim_holder"] ?? null,
|
|
635
|
-
claimExpiresAt: toDate(row["claim_expires_at"])
|
|
702
|
+
failedAt: toDate(row["failed_at"])
|
|
636
703
|
});
|
|
637
|
-
const
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
704
|
+
const freshDispatch = (consumer, lane) => ({
|
|
705
|
+
consumer,
|
|
706
|
+
lane,
|
|
707
|
+
firstClaimedAt: null,
|
|
708
|
+
lastClaimedAt: null,
|
|
709
|
+
attemptCount: 0,
|
|
710
|
+
claimHolder: null,
|
|
711
|
+
claimExpiresAt: null,
|
|
712
|
+
failureCount: 0,
|
|
713
|
+
lastFailedAt: null,
|
|
714
|
+
lastFailedAttempt: null,
|
|
715
|
+
lastError: null,
|
|
716
|
+
failedAt: null
|
|
717
|
+
});
|
|
718
|
+
/** A cursor row joined with its head event's `e_` columns. */
|
|
719
|
+
const toHead = (row) => ({
|
|
720
|
+
...toStored({
|
|
721
|
+
session_id: row["session_id"],
|
|
722
|
+
idx: row["e_idx"],
|
|
723
|
+
event_type: row["e_event_type"],
|
|
724
|
+
payload: row["e_payload"],
|
|
725
|
+
event_id: row["e_event_id"],
|
|
726
|
+
created_at: row["e_created_at"],
|
|
727
|
+
cause: row["e_cause"]
|
|
728
|
+
}),
|
|
729
|
+
...toDispatch(row)
|
|
644
730
|
});
|
|
731
|
+
/** A cursor run as `cursors()` reports it, given the session's clear marker. */
|
|
732
|
+
const toCursor = (row, clearIndex) => {
|
|
733
|
+
const position = asNumber(row["position"]);
|
|
734
|
+
if (row["kind"] !== "log" || asNumber(row["epoch"]) > clearIndex) return Object.assign(toDispatch(row), {
|
|
735
|
+
position,
|
|
736
|
+
head: asNullableNumber(row["head"])
|
|
737
|
+
});
|
|
738
|
+
return Object.assign(freshDispatch(row["consumer"], row["lane"]), {
|
|
739
|
+
position: Math.max(position, clearIndex),
|
|
740
|
+
head: null
|
|
741
|
+
});
|
|
742
|
+
};
|
|
743
|
+
const compareText = (a, b) => a < b ? -1 : a > b ? 1 : 0;
|
|
744
|
+
const byHead = (a, b) => a.index - b.index || compareText(a.consumer, b.consumer);
|
|
745
|
+
/** Consumer, then lane, with concurrent cursors last by head. */
|
|
746
|
+
const byCursor = (a, b) => compareText(a.consumer, b.consumer) || (a.lane === null ? b.lane === null ? (a.head ?? 0) - (b.head ?? 0) : 1 : b.lane === null ? -1 : compareText(a.lane, b.lane));
|
|
747
|
+
const servedNames = (consumers) => consumers === void 0 ? null : consumers.map((consumer) => consumer.name);
|
|
645
748
|
const wrap = async (fn) => {
|
|
646
749
|
try {
|
|
647
750
|
return await fn();
|
|
@@ -651,25 +754,6 @@ const wrap = async (fn) => {
|
|
|
651
754
|
}
|
|
652
755
|
};
|
|
653
756
|
const jsonb = (value) => JSON.stringify(value) ?? "null";
|
|
654
|
-
const initializeSchema = async ({ client, listen }) => {
|
|
655
|
-
const schema = SCHEMA + (listen ? POSTGRES_NOTIFICATIONS_SQL : "");
|
|
656
|
-
if (!client.connect) {
|
|
657
|
-
await client.query(schema);
|
|
658
|
-
return;
|
|
659
|
-
}
|
|
660
|
-
const conn = await client.connect();
|
|
661
|
-
try {
|
|
662
|
-
await conn.query("begin");
|
|
663
|
-
await conn.query("select pg_advisory_xact_lock(hashtext('a2:postgres:schema'))");
|
|
664
|
-
await conn.query(schema);
|
|
665
|
-
await conn.query("commit");
|
|
666
|
-
} catch (err) {
|
|
667
|
-
await conn.query("rollback").catch(() => {});
|
|
668
|
-
throw err;
|
|
669
|
-
} finally {
|
|
670
|
-
conn.release();
|
|
671
|
-
}
|
|
672
|
-
};
|
|
673
757
|
const loadPg = async () => import("pg").catch(() => {
|
|
674
758
|
throw new A2Error("STORE_NOT_CONFIGURED", "Postgres connections created by A2 require the optional 'pg' package");
|
|
675
759
|
});
|
|
@@ -694,10 +778,16 @@ const createListener = ({ pg, connectionString }) => postgresNotifications(async
|
|
|
694
778
|
}
|
|
695
779
|
};
|
|
696
780
|
});
|
|
697
|
-
/** A dead
|
|
698
|
-
const releasePins = async ({ query, sessionId, index }) => {
|
|
781
|
+
/** A finished or dead head's snapshot pins, and history no pin keeps. */
|
|
782
|
+
const releasePins = async ({ query, sessionId, consumer, cursorKey, index }) => {
|
|
699
783
|
await query(`delete from a2_snapshot_pins
|
|
700
|
-
where session_id = $1 and event_index = $2
|
|
784
|
+
where session_id = $1 and event_index = $2
|
|
785
|
+
and consumer = $3 and cursor_key = $4`, [
|
|
786
|
+
sessionId,
|
|
787
|
+
index,
|
|
788
|
+
consumer,
|
|
789
|
+
cursorKey
|
|
790
|
+
]);
|
|
701
791
|
await query(`delete from a2_snapshot_history history
|
|
702
792
|
where history.session_id = $1
|
|
703
793
|
and not exists (
|
|
@@ -707,6 +797,781 @@ const releasePins = async ({ query, sessionId, index }) => {
|
|
|
707
797
|
and pin.up_to_index = history.up_to_index
|
|
708
798
|
)`, [sessionId]);
|
|
709
799
|
};
|
|
800
|
+
const lockSession = (query, sessionId) => query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
|
|
801
|
+
/** Whether the session has an event at `index`. */
|
|
802
|
+
const hasEvent = async (query, sessionId, index) => (await query("select 1 from a2_events where session_id = $1 and idx = $2", [sessionId, index])).rows.length > 0;
|
|
803
|
+
/**
|
|
804
|
+
* The attempt-currency fence shared by consumer appends and clears, under
|
|
805
|
+
* the session lock. The cursor is read locked (`for share` by an append,
|
|
806
|
+
* `for update` by a clear, which then releases the head): it serializes
|
|
807
|
+
* with every writer of the cursor (claims, completions, failures), so a
|
|
808
|
+
* stale read cannot pass the fence against a takeover committing beside
|
|
809
|
+
* it. The lock is only taken while the head and attempt still match.
|
|
810
|
+
*/
|
|
811
|
+
const assertCurrentCause = async ({ query, sessionId, cause, clearIndex, lock }) => {
|
|
812
|
+
if (cause.index <= clearIndex) throw supersededAttemptError(sessionId, cause);
|
|
813
|
+
const { rows } = await query(`select 1 from a2_cursors
|
|
814
|
+
where session_id = $1 and cursor_key = $2 and consumer = $3
|
|
815
|
+
and head = $4 and attempt_count = $5 and failed_at is null
|
|
816
|
+
for ${lock}`, [
|
|
817
|
+
sessionId,
|
|
818
|
+
cursorKeyOf(cause.lane, cause.index),
|
|
819
|
+
cause.consumer,
|
|
820
|
+
cause.index,
|
|
821
|
+
cause.attempt
|
|
822
|
+
]);
|
|
823
|
+
if (rows.length === 0) throw supersededAttemptError(sessionId, cause);
|
|
824
|
+
};
|
|
825
|
+
const BATCH = "select r from a2_batch($1::text[], $2::jsonb) as r";
|
|
826
|
+
/**
|
|
827
|
+
* Each statement as a batch sends it, whitespace collapsed: a batch sends all
|
|
828
|
+
* its statements on every call. They hold no comments or spaced literals, and
|
|
829
|
+
* they're this module's constants, so the cache stays this size.
|
|
830
|
+
*/
|
|
831
|
+
const compacted = /* @__PURE__ */ new Map();
|
|
832
|
+
const compact = (statement) => {
|
|
833
|
+
let sent = compacted.get(statement);
|
|
834
|
+
if (sent === void 0) {
|
|
835
|
+
if (statement.includes("--")) throw new TypeError("a batch statement holds a comment");
|
|
836
|
+
sent = statement.replace(/\s+/g, " ").trim();
|
|
837
|
+
compacted.set(statement, sent);
|
|
838
|
+
}
|
|
839
|
+
return sent;
|
|
840
|
+
};
|
|
841
|
+
/** The SQLSTATE `a2_batch` raises an `abort` row with, as its detail. */
|
|
842
|
+
const BATCH_ABORTED = "A2001";
|
|
843
|
+
/** The row an aborted `a2_batch` call raised, from `error` or its cause chain. */
|
|
844
|
+
const abortedBatch = (error) => {
|
|
845
|
+
for (let current = error, depth = 0; depth < 5; depth += 1) {
|
|
846
|
+
if (typeof current !== "object" || current === null) return void 0;
|
|
847
|
+
const { code, detail, cause } = current;
|
|
848
|
+
if (code === BATCH_ABORTED && typeof detail === "string") return JSON.parse(detail);
|
|
849
|
+
current = cause;
|
|
850
|
+
}
|
|
851
|
+
};
|
|
852
|
+
const LOCK_SESSION = `
|
|
853
|
+
select jsonb_build_object('locked', true)
|
|
854
|
+
from pg_advisory_xact_lock(hashtext(${SESSION}))`;
|
|
855
|
+
const INDEX = param.bigint("index");
|
|
856
|
+
const CONSUMER = param.text("consumer");
|
|
857
|
+
/**
|
|
858
|
+
* The index before the events this call inserted, the last `count` of the
|
|
859
|
+
* session's: read under the session lock, after the insert.
|
|
860
|
+
*/
|
|
861
|
+
const INSERTED_AFTER = `(
|
|
862
|
+
select coalesce(max(idx), 0) - ${param.bigint("count")}
|
|
863
|
+
from a2_events where session_id = ${SESSION})`;
|
|
864
|
+
/**
|
|
865
|
+
* An append's checks, under the session lock: the session's head and pending
|
|
866
|
+
* state, and what its supplied `ids` say. When every event's id belongs to
|
|
867
|
+
* this session already, the batch is a retry: it stops with the committed
|
|
868
|
+
* rows. Some owned, or one in another session, aborts, as does scheduled work
|
|
869
|
+
* from at or below the clear marker (`origin`). Rows below the marker own no
|
|
870
|
+
* id.
|
|
871
|
+
*/
|
|
872
|
+
const APPEND_CHECKS = `
|
|
873
|
+
with boundary as materialized (
|
|
874
|
+
select coalesce(max(idx), 0) as base,
|
|
875
|
+
${clearIndexOf(SESSION)} as clear_index,
|
|
876
|
+
${pendingIn(SESSION, param.texts("served"))} as has_pending
|
|
877
|
+
from a2_events where session_id = ${SESSION}
|
|
878
|
+
), owned as materialized (
|
|
879
|
+
select event.session_id, event.idx, event.event_type, event.payload,
|
|
880
|
+
event.event_id, event.created_at, event.cause
|
|
881
|
+
from a2_events event, boundary
|
|
882
|
+
where event.event_id = any(${param.texts("ids")})
|
|
883
|
+
and not (event.session_id = ${SESSION}
|
|
884
|
+
and event.idx < boundary.clear_index)
|
|
885
|
+
)
|
|
886
|
+
select case
|
|
887
|
+
when exists (select 1 from owned where session_id <> ${SESSION}) then
|
|
888
|
+
jsonb_build_object('abort', 'foreign', 'event_id', (
|
|
889
|
+
select event_id from owned where session_id <> ${SESSION}
|
|
890
|
+
order by idx limit 1))
|
|
891
|
+
when (select count(*) from owned) = ${param.bigint("count")} then
|
|
892
|
+
jsonb_build_object('stop', 'replay',
|
|
893
|
+
'has_pending', boundary.has_pending,
|
|
894
|
+
'events', (select jsonb_agg(to_jsonb(owned) order by owned.idx)
|
|
895
|
+
from owned))
|
|
896
|
+
when exists (select 1 from owned) then
|
|
897
|
+
jsonb_build_object('abort', 'mixed', 'owned',
|
|
898
|
+
(select count(*) from owned))
|
|
899
|
+
when ${param.bigint("origin")} <= boundary.clear_index then
|
|
900
|
+
jsonb_build_object('abort', 'cleared')
|
|
901
|
+
else jsonb_build_object('base', boundary.base,
|
|
902
|
+
'has_pending', boundary.has_pending)
|
|
903
|
+
end
|
|
904
|
+
from boundary`;
|
|
905
|
+
/**
|
|
906
|
+
* The attempt-currency fence of a consumer append, under the session lock.
|
|
907
|
+
* Each cause's cursor is read `for share`: that serializes with every writer
|
|
908
|
+
* of the cursor (claims, completions, failures), so a stale read cannot pass
|
|
909
|
+
* the fence against a takeover committing beside it. A row is locked only
|
|
910
|
+
* while its head and attempt still match. The first cause that fails aborts.
|
|
911
|
+
*/
|
|
912
|
+
const FENCE = `
|
|
913
|
+
with cause as (
|
|
914
|
+
select * from jsonb_to_recordset($1->'causes')
|
|
915
|
+
as cause(ord int, consumer text, cursor_key text, idx bigint, attempt int)
|
|
916
|
+
), held as materialized (
|
|
917
|
+
select cause.ord from cause
|
|
918
|
+
join a2_cursors cur
|
|
919
|
+
on cur.session_id = ${SESSION} and cur.cursor_key = cause.cursor_key
|
|
920
|
+
and cur.consumer = cause.consumer and cur.head = cause.idx
|
|
921
|
+
and cur.attempt_count = cause.attempt and cur.failed_at is null
|
|
922
|
+
where cause.idx > ${clearIndexOf(SESSION)}
|
|
923
|
+
for share of cur
|
|
924
|
+
)
|
|
925
|
+
select jsonb_build_object('abort', 'superseded', 'ord', cause.ord)
|
|
926
|
+
from cause where cause.ord not in (select ord from held)
|
|
927
|
+
order by cause.ord limit 1`;
|
|
928
|
+
/**
|
|
929
|
+
* Frees the `ids` this session's rows below its clear marker hold, so the
|
|
930
|
+
* batch can take them. It never waits on a row: a reclaim deleting one may
|
|
931
|
+
* hold it, and one it skips aborts.
|
|
932
|
+
*/
|
|
933
|
+
const REUSE_IDS = `
|
|
934
|
+
with stale as materialized (
|
|
935
|
+
select event.idx from a2_events event
|
|
936
|
+
where event.session_id = ${SESSION}
|
|
937
|
+
and event.event_id = any(${param.texts("ids")})
|
|
938
|
+
and event.idx < ${clearIndexOf(SESSION)}
|
|
939
|
+
order by event.idx
|
|
940
|
+
for update skip locked
|
|
941
|
+
), deleted as (
|
|
942
|
+
delete from a2_events event using stale
|
|
943
|
+
where event.session_id = ${SESSION} and event.idx = stale.idx
|
|
944
|
+
returning event.idx
|
|
945
|
+
)
|
|
946
|
+
select jsonb_build_object('abort', 'held')
|
|
947
|
+
where (select count(*) from deleted) < (
|
|
948
|
+
select count(*) from a2_events event
|
|
949
|
+
where event.session_id = ${SESSION}
|
|
950
|
+
and event.event_id = any(${param.texts("ids")})
|
|
951
|
+
and event.idx < ${clearIndexOf(SESSION)})`;
|
|
952
|
+
/** Inserts `events` after the session's head, under the session lock. */
|
|
953
|
+
const INSERT_EVENTS = `
|
|
954
|
+
with inserted as (
|
|
955
|
+
insert into a2_events
|
|
956
|
+
(session_id, idx, event_type, payload, event_id, created_at, cause)
|
|
957
|
+
select ${SESSION}, base.base + event.ord, event.type,
|
|
958
|
+
coalesce(event.payload, 'null'::jsonb), event.id,
|
|
959
|
+
${param.bigint("now")}, event.cause
|
|
960
|
+
from (select coalesce(max(idx), 0) as base from a2_events
|
|
961
|
+
where session_id = ${SESSION}) base,
|
|
962
|
+
jsonb_to_recordset($1->'events')
|
|
963
|
+
as event(ord bigint, type text, payload jsonb, id text, cause jsonb)
|
|
964
|
+
returning 1
|
|
965
|
+
)
|
|
966
|
+
select jsonb_build_object('inserted', count(*)) from inserted`;
|
|
967
|
+
/** A cursor for each concurrent target: the event alone. */
|
|
968
|
+
const START_EVENTS = `
|
|
969
|
+
with started as (
|
|
970
|
+
insert into a2_cursors
|
|
971
|
+
(session_id, consumer, cursor_key, epoch, lane, kind, position, head)
|
|
972
|
+
select ${SESSION}, target.consumer, '#' || target.idx, target.idx, null,
|
|
973
|
+
'event', target.idx - 1, target.idx
|
|
974
|
+
from (
|
|
975
|
+
select single.consumer, ${INSERTED_AFTER} + single.ord as idx
|
|
976
|
+
from jsonb_to_recordset($1->'singles')
|
|
977
|
+
as single(consumer text, ord bigint)
|
|
978
|
+
) target
|
|
979
|
+
on conflict do nothing
|
|
980
|
+
returning 1
|
|
981
|
+
)
|
|
982
|
+
select jsonb_build_object('started', count(*)) from started`;
|
|
983
|
+
/** Queues each lane target's event in its lane. */
|
|
984
|
+
const QUEUE_LANES = `
|
|
985
|
+
with queued as (
|
|
986
|
+
insert into a2_lane_events (session_id, consumer, lane, idx)
|
|
987
|
+
select ${SESSION}, queued.consumer, queued.lane,
|
|
988
|
+
${INSERTED_AFTER} + queued.ord
|
|
989
|
+
from jsonb_to_recordset($1->'queued')
|
|
990
|
+
as queued(consumer text, lane text, ord bigint)
|
|
991
|
+
on conflict do nothing
|
|
992
|
+
returning 1
|
|
993
|
+
)
|
|
994
|
+
select jsonb_build_object('queued', count(*)) from queued`;
|
|
995
|
+
/**
|
|
996
|
+
* Starts a run for each lane cursor the batch queues work for that has none
|
|
997
|
+
* running: one that is idle, superseded by the clear marker, or new. It starts
|
|
998
|
+
* at the lane's first event in the batch and counts from the marker.
|
|
999
|
+
*/
|
|
1000
|
+
const START_LANES = `
|
|
1001
|
+
with boundary as materialized (
|
|
1002
|
+
select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
|
|
1003
|
+
), target as (
|
|
1004
|
+
select first.consumer, first.cursor_key, first.lane,
|
|
1005
|
+
boundary.base + first.ord as idx
|
|
1006
|
+
from boundary, jsonb_to_recordset($1->'lanes')
|
|
1007
|
+
as first(consumer text, cursor_key text, lane text, ord bigint)
|
|
1008
|
+
), started as materialized (
|
|
1009
|
+
select target.*, latest.position
|
|
1010
|
+
from target
|
|
1011
|
+
left join lateral (${LATEST_RUN("target")}) latest on true
|
|
1012
|
+
where latest.head is null
|
|
1013
|
+
or latest.head <= (select clear_index from boundary)
|
|
1014
|
+
), ${RETIRE_RUNS("started", "(select clear_index from boundary)")},
|
|
1015
|
+
inserted as (
|
|
1016
|
+
insert into a2_cursors
|
|
1017
|
+
(session_id, consumer, cursor_key, epoch, lane, kind, position, head)
|
|
1018
|
+
select ${SESSION}, started.consumer, started.cursor_key, started.idx,
|
|
1019
|
+
started.lane, 'lane',
|
|
1020
|
+
greatest(coalesce(started.position, 0),
|
|
1021
|
+
(select clear_index from boundary)),
|
|
1022
|
+
started.idx
|
|
1023
|
+
from started
|
|
1024
|
+
on conflict do nothing
|
|
1025
|
+
returning 1
|
|
1026
|
+
)
|
|
1027
|
+
select jsonb_build_object('lanes', count(*)) from inserted`;
|
|
1028
|
+
/**
|
|
1029
|
+
* Starts a run for each served log cursor the batch carries types for that
|
|
1030
|
+
* has none running. A missing cursor starts at the batch's first event of its
|
|
1031
|
+
* types, or at the beginning of the log with backfill. One that exists starts
|
|
1032
|
+
* at the next event it takes after its position: a deployment that does not
|
|
1033
|
+
* serve the consumer may have appended some while it was idle. A cursor from
|
|
1034
|
+
* before the clear marker counts from it.
|
|
1035
|
+
*/
|
|
1036
|
+
const WAKE_LOGS = `
|
|
1037
|
+
with boundary as materialized (
|
|
1038
|
+
select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
|
|
1039
|
+
), target as (
|
|
1040
|
+
select log.consumer, log.cursor_key, log.lane,
|
|
1041
|
+
boundary.base + log.ord as first, log.types, log.backfill
|
|
1042
|
+
from boundary, jsonb_to_recordset($1->'logs')
|
|
1043
|
+
as log(consumer text, cursor_key text, lane text, ord bigint,
|
|
1044
|
+
types jsonb, backfill boolean)
|
|
1045
|
+
), woken as materialized (
|
|
1046
|
+
select target.*,
|
|
1047
|
+
case when latest.epoch is not null
|
|
1048
|
+
then greatest(latest.position,
|
|
1049
|
+
(select clear_index from boundary))
|
|
1050
|
+
when target.backfill then (select clear_index from boundary)
|
|
1051
|
+
else (select base from boundary) end as position,
|
|
1052
|
+
latest.epoch is not null or target.backfill as resumes
|
|
1053
|
+
from target
|
|
1054
|
+
left join lateral (${LATEST_RUN("target")}) latest on true
|
|
1055
|
+
where latest.head is null
|
|
1056
|
+
or latest.head <= (select clear_index from boundary)
|
|
1057
|
+
), ${RETIRE_RUNS("woken", "(select clear_index from boundary)")}, run as (
|
|
1058
|
+
select woken.*,
|
|
1059
|
+
case when woken.resumes then coalesce(${nextLogHead({
|
|
1060
|
+
types: "woken.types",
|
|
1061
|
+
after: "woken.position"
|
|
1062
|
+
})}, woken.first) else woken.first end as head
|
|
1063
|
+
from woken
|
|
1064
|
+
), inserted as (
|
|
1065
|
+
insert into a2_cursors
|
|
1066
|
+
(session_id, consumer, cursor_key, epoch, lane, kind, position, head,
|
|
1067
|
+
types)
|
|
1068
|
+
select ${SESSION}, run.consumer, run.cursor_key, run.head, run.lane, 'log',
|
|
1069
|
+
run.position, run.head, run.types
|
|
1070
|
+
from run
|
|
1071
|
+
on conflict do nothing
|
|
1072
|
+
returning 1
|
|
1073
|
+
)
|
|
1074
|
+
select jsonb_build_object('logs', count(*)) from inserted`;
|
|
1075
|
+
/**
|
|
1076
|
+
* The heads a claim may take, up to `through` when there is one, as
|
|
1077
|
+
* `from ... where` for `cur`. `for update` makes a claim racing another wait
|
|
1078
|
+
* and then re-check the row it waited for.
|
|
1079
|
+
*/
|
|
1080
|
+
const ELIGIBLE = `
|
|
1081
|
+
from a2_cursors cur
|
|
1082
|
+
where cur.session_id = ${SESSION}
|
|
1083
|
+
and cur.head > ${clearIndexOf(SESSION)}
|
|
1084
|
+
and (${param.bigint("through")} is null
|
|
1085
|
+
or cur.head <= ${param.bigint("through")})
|
|
1086
|
+
and cur.failed_at is null
|
|
1087
|
+
and (${param.texts("served")} is null
|
|
1088
|
+
or cur.consumer = any(${param.texts("served")}))
|
|
1089
|
+
and not exists (
|
|
1090
|
+
select 1 from jsonb_to_recordset($1->'excluded')
|
|
1091
|
+
as skip(consumer text, cursor_key text)
|
|
1092
|
+
where skip.consumer = cur.consumer and skip.cursor_key = cur.cursor_key
|
|
1093
|
+
)`;
|
|
1094
|
+
/** A lapsed claim's takeover that spends the last failed attempt dead-letters the head instead. */
|
|
1095
|
+
const EXHAUSTED = `(cur.claim_holder is not null
|
|
1096
|
+
and cur.failure_count + 1 >= ${param.int("maxFailures")})`;
|
|
1097
|
+
/**
|
|
1098
|
+
* Claims every eligible head, returning each with its event. A head the claim
|
|
1099
|
+
* waited on may have moved to an event its snapshot predates: that row comes
|
|
1100
|
+
* back without one (`e_idx` null), for `claimAvailable` to read. It stops
|
|
1101
|
+
* there unless one dead-lettered, whose pins `RELEASE_DEAD` then releases, or
|
|
1102
|
+
* none was claimed, for `RETRY_AT`.
|
|
1103
|
+
*/
|
|
1104
|
+
const CLAIM = `
|
|
1105
|
+
with eligible as materialized (
|
|
1106
|
+
select cur.consumer, cur.cursor_key, cur.epoch
|
|
1107
|
+
${ELIGIBLE}
|
|
1108
|
+
and (cur.claim_expires_at is null
|
|
1109
|
+
or cur.claim_expires_at <= ${param.bigint("now")})
|
|
1110
|
+
order by cur.head, cur.consumer
|
|
1111
|
+
for update of cur
|
|
1112
|
+
), claimed as (
|
|
1113
|
+
update a2_cursors cur set
|
|
1114
|
+
attempt_count = cur.attempt_count + 1,
|
|
1115
|
+
failure_count = cur.failure_count
|
|
1116
|
+
+ case when cur.claim_holder is null then 0 else 1 end,
|
|
1117
|
+
failed_at = case when ${EXHAUSTED} then ${param.bigint("now")}
|
|
1118
|
+
else cur.failed_at end,
|
|
1119
|
+
first_claimed_at = case when ${EXHAUSTED} then cur.first_claimed_at
|
|
1120
|
+
else coalesce(cur.first_claimed_at, ${param.bigint("now")}) end,
|
|
1121
|
+
last_claimed_at = case when ${EXHAUSTED} then cur.last_claimed_at
|
|
1122
|
+
else ${param.bigint("now")} end,
|
|
1123
|
+
claim_holder = case when ${EXHAUSTED} then null
|
|
1124
|
+
else ${param.text("holder")} end,
|
|
1125
|
+
claim_expires_at = case when ${EXHAUSTED} then null
|
|
1126
|
+
else ${param.bigint("expiresAt")} end
|
|
1127
|
+
from eligible
|
|
1128
|
+
where cur.session_id = ${SESSION}
|
|
1129
|
+
and cur.cursor_key = eligible.cursor_key
|
|
1130
|
+
and cur.consumer = eligible.consumer
|
|
1131
|
+
and cur.epoch = eligible.epoch
|
|
1132
|
+
returning cur.*
|
|
1133
|
+
), heads as (
|
|
1134
|
+
select claimed.*, ${HEAD_COLUMNS},
|
|
1135
|
+
${clearIndexOf(SESSION)} as clear_index,
|
|
1136
|
+
(select coalesce(max(idx), 0) from a2_events
|
|
1137
|
+
where session_id = ${SESSION}) as session_head
|
|
1138
|
+
from claimed
|
|
1139
|
+
left join a2_events event
|
|
1140
|
+
on event.session_id = ${SESSION} and event.idx = claimed.head
|
|
1141
|
+
)
|
|
1142
|
+
select r from (
|
|
1143
|
+
select 0 as ord, to_jsonb(heads) as r from heads
|
|
1144
|
+
union all
|
|
1145
|
+
select 1, jsonb_build_object('stop', 'claimed')
|
|
1146
|
+
where exists (select 1 from heads)
|
|
1147
|
+
and not exists (select 1 from heads where failed_at is not null)
|
|
1148
|
+
) claim
|
|
1149
|
+
order by ord`;
|
|
1150
|
+
/**
|
|
1151
|
+
* Releases the pins of the heads dead-lettered at `now`, the claim's among
|
|
1152
|
+
* them, in a snapshot after the claim's locks, with the history no other pin
|
|
1153
|
+
* keeps.
|
|
1154
|
+
*/
|
|
1155
|
+
const RELEASE_DEAD = `
|
|
1156
|
+
with dead as materialized (
|
|
1157
|
+
select cur.consumer, cur.cursor_key, cur.head from a2_cursors cur
|
|
1158
|
+
where cur.session_id = ${SESSION} and cur.head is not null
|
|
1159
|
+
and cur.failed_at = ${param.bigint("now")} and cur.claim_holder is null
|
|
1160
|
+
), unpinned as (
|
|
1161
|
+
delete from a2_snapshot_pins pin using dead
|
|
1162
|
+
where pin.session_id = ${SESSION} and pin.event_index = dead.head
|
|
1163
|
+
and pin.consumer = dead.consumer and pin.cursor_key = dead.cursor_key
|
|
1164
|
+
returning 1
|
|
1165
|
+
), unkept as (
|
|
1166
|
+
delete from a2_snapshot_history history
|
|
1167
|
+
where history.session_id = ${SESSION}
|
|
1168
|
+
and exists (select 1 from dead)
|
|
1169
|
+
and not exists (
|
|
1170
|
+
select 1 from a2_snapshot_pins pin
|
|
1171
|
+
where pin.session_id = history.session_id
|
|
1172
|
+
and pin.reducer_name = history.reducer_name
|
|
1173
|
+
and pin.up_to_index = history.up_to_index
|
|
1174
|
+
and not exists (
|
|
1175
|
+
select 1 from dead
|
|
1176
|
+
where pin.event_index = dead.head
|
|
1177
|
+
and pin.consumer = dead.consumer
|
|
1178
|
+
and pin.cursor_key = dead.cursor_key)
|
|
1179
|
+
)
|
|
1180
|
+
returning 1
|
|
1181
|
+
)
|
|
1182
|
+
select jsonb_build_object('released', (select count(*) from unpinned))`;
|
|
1183
|
+
/** When nothing was claimed: the earliest live claim or retry hold that blocks a head. */
|
|
1184
|
+
const RETRY_AT = `
|
|
1185
|
+
select jsonb_build_object('retry_at', min(cur.claim_expires_at))
|
|
1186
|
+
${ELIGIBLE}
|
|
1187
|
+
and cur.claim_expires_at > ${param.bigint("now")}`;
|
|
1188
|
+
/** The cursor row `alias` whose head this attempt holds. */
|
|
1189
|
+
const heldBy = (alias) => `
|
|
1190
|
+
${alias}.session_id = ${SESSION}
|
|
1191
|
+
and ${alias}.cursor_key = ${param.text("cursorKey")}
|
|
1192
|
+
and ${alias}.consumer = ${CONSUMER} and ${alias}.head = ${INDEX}
|
|
1193
|
+
and ${alias}.attempt_count = ${param.int("attempt")}
|
|
1194
|
+
and ${alias}.failed_at is null and ${alias}.claim_holder is not null`;
|
|
1195
|
+
/**
|
|
1196
|
+
* Locks the cursor while this attempt holds its head, and stops unless it
|
|
1197
|
+
* does: a completion locks its cursor first. A cleared head is never locked.
|
|
1198
|
+
*/
|
|
1199
|
+
const HOLD = `
|
|
1200
|
+
with boundary as materialized (
|
|
1201
|
+
select ${clearIndexOf(SESSION)} as clear_index
|
|
1202
|
+
), held as materialized (
|
|
1203
|
+
select cur.kind from a2_cursors cur, boundary
|
|
1204
|
+
where ${heldBy("cur")} and ${INDEX} > boundary.clear_index
|
|
1205
|
+
for update of cur
|
|
1206
|
+
)
|
|
1207
|
+
select case
|
|
1208
|
+
when ${INDEX} <= boundary.clear_index then
|
|
1209
|
+
jsonb_build_object('stop', 'superseded')
|
|
1210
|
+
when not exists (select 1 from held) then
|
|
1211
|
+
jsonb_build_object('stop', 'unheld')
|
|
1212
|
+
else jsonb_build_object('held', (select kind from held limit 1))
|
|
1213
|
+
end
|
|
1214
|
+
from boundary`;
|
|
1215
|
+
/** A log cursor's types once it moves: the served consumer's (`types`, when `retype`), else its own. */
|
|
1216
|
+
const logTypesOf = (cur) => `(case when ${param.boolean("retype")} then ${param.jsonb("types")}
|
|
1217
|
+
else ${cur}.types end)`;
|
|
1218
|
+
/**
|
|
1219
|
+
* The head cursor `cur` moves to from its head: its lane's next unfinished
|
|
1220
|
+
* event, the next event of a log cursor's types, or none for a concurrent
|
|
1221
|
+
* cursor. The lane's lookup is ordered by its whole key, so the lane's
|
|
1222
|
+
* primary key serves it.
|
|
1223
|
+
*/
|
|
1224
|
+
const nextHeadOf = (cur) => `
|
|
1225
|
+
case ${cur}.kind
|
|
1226
|
+
when 'lane' then (
|
|
1227
|
+
select lane.idx from a2_lane_events lane
|
|
1228
|
+
where lane.session_id = ${SESSION} and lane.consumer = ${cur}.consumer
|
|
1229
|
+
and lane.lane = ${cur}.lane and not lane.done
|
|
1230
|
+
and lane.idx > ${cur}.head
|
|
1231
|
+
order by lane.consumer, lane.lane, lane.idx limit 1)
|
|
1232
|
+
when 'log' then (${nextLogHead({
|
|
1233
|
+
types: logTypesOf(cur),
|
|
1234
|
+
after: `${cur}.head`
|
|
1235
|
+
})})
|
|
1236
|
+
end`;
|
|
1237
|
+
/**
|
|
1238
|
+
* Takes the session lock when the held cursor would go idle: a lane or log
|
|
1239
|
+
* cursor with no next head. See `completeAttempt` for why a completion that
|
|
1240
|
+
* moves to a next head needs no lock.
|
|
1241
|
+
*/
|
|
1242
|
+
const LOCK_IF_IDLE = `
|
|
1243
|
+
select jsonb_build_object('locked',
|
|
1244
|
+
pg_advisory_xact_lock(hashtext(${SESSION}))::text is not null)
|
|
1245
|
+
from a2_cursors cur
|
|
1246
|
+
where ${heldBy("cur")} and cur.kind <> 'event'
|
|
1247
|
+
and (${nextHeadOf("cur")}) is null`;
|
|
1248
|
+
/**
|
|
1249
|
+
* Moves the cursor `target` selects past its head `index`, if its head lies
|
|
1250
|
+
* above the clear marker: marks the lane's event done (with this attempt's
|
|
1251
|
+
* receipt, `receipt`), releases the head's snapshot pins with the history no
|
|
1252
|
+
* other pin keeps, and gives the cursor its next head with fresh dispatch
|
|
1253
|
+
* columns (a log or concurrent cursor keeps the receipt on the run). The
|
|
1254
|
+
* marker and the next head are read in one snapshot, after the cursor's lock.
|
|
1255
|
+
* Without a row it writes nothing, and stops if `stop`.
|
|
1256
|
+
*/
|
|
1257
|
+
const advance = ({ target, receipt, missing }) => `
|
|
1258
|
+
with target as materialized (
|
|
1259
|
+
select cur.* from a2_cursors cur
|
|
1260
|
+
where ${target} and ${INDEX} > ${clearIndexOf(SESSION)}
|
|
1261
|
+
order by cur.cursor_key limit 1
|
|
1262
|
+
for update of cur
|
|
1263
|
+
), done as (
|
|
1264
|
+
update a2_lane_events lane set done = true${receipt ? `, completed_attempt = ${param.int("attempt")},
|
|
1265
|
+
returned_event_ids = ${param.jsonb("ids")}` : ""}
|
|
1266
|
+
from target
|
|
1267
|
+
where target.kind = 'lane' and lane.session_id = ${SESSION}
|
|
1268
|
+
and lane.consumer = target.consumer and lane.lane = target.lane
|
|
1269
|
+
and lane.idx = target.head
|
|
1270
|
+
returning 1
|
|
1271
|
+
), unpinned as (
|
|
1272
|
+
delete from a2_snapshot_pins pin using target
|
|
1273
|
+
where pin.session_id = ${SESSION} and pin.event_index = target.head
|
|
1274
|
+
and pin.consumer = target.consumer and pin.cursor_key = target.cursor_key
|
|
1275
|
+
returning 1
|
|
1276
|
+
), unkept as (
|
|
1277
|
+
delete from a2_snapshot_history history using target
|
|
1278
|
+
where history.session_id = ${SESSION}
|
|
1279
|
+
and not exists (
|
|
1280
|
+
select 1 from a2_snapshot_pins pin
|
|
1281
|
+
where pin.session_id = history.session_id
|
|
1282
|
+
and pin.reducer_name = history.reducer_name
|
|
1283
|
+
and pin.up_to_index = history.up_to_index
|
|
1284
|
+
and not (pin.event_index = target.head
|
|
1285
|
+
and pin.consumer = target.consumer
|
|
1286
|
+
and pin.cursor_key = target.cursor_key)
|
|
1287
|
+
)
|
|
1288
|
+
returning 1
|
|
1289
|
+
), moved as (
|
|
1290
|
+
update a2_cursors cur
|
|
1291
|
+
set position = target.head, head = ${nextHeadOf("target")},
|
|
1292
|
+
${FRESH_DISPATCH},
|
|
1293
|
+
types = case when target.kind = 'log'
|
|
1294
|
+
then ${logTypesOf("target")} else cur.types end${receipt ? `,
|
|
1295
|
+
completed_idx = case when target.kind = 'lane'
|
|
1296
|
+
then cur.completed_idx else target.head end,
|
|
1297
|
+
completed_attempt = case when target.kind = 'lane'
|
|
1298
|
+
then cur.completed_attempt
|
|
1299
|
+
else ${param.int("attempt")} end,
|
|
1300
|
+
returned_event_ids = case when target.kind = 'lane'
|
|
1301
|
+
then cur.returned_event_ids
|
|
1302
|
+
else ${param.jsonb("ids")} end` : ""}
|
|
1303
|
+
from target
|
|
1304
|
+
where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
|
|
1305
|
+
and cur.consumer = target.consumer and cur.epoch = target.epoch
|
|
1306
|
+
returning cur.head
|
|
1307
|
+
)
|
|
1308
|
+
select case when exists (select 1 from moved)
|
|
1309
|
+
then jsonb_build_object('advanced', true)
|
|
1310
|
+
else jsonb_build_object(${missing === "continue" ? `'advanced', false` : `'${missing}', 'superseded'`}) end`;
|
|
1311
|
+
/** Completing a held head, once the cursor's lock is taken. */
|
|
1312
|
+
const COMPLETE = advance({
|
|
1313
|
+
target: heldBy("cur"),
|
|
1314
|
+
receipt: true,
|
|
1315
|
+
missing: "stop"
|
|
1316
|
+
});
|
|
1317
|
+
/**
|
|
1318
|
+
* Completing a held head after appending its events, under the session lock:
|
|
1319
|
+
* the head can't have moved, but if it had, the events must not stand.
|
|
1320
|
+
*/
|
|
1321
|
+
const COMPLETE_APPENDED = advance({
|
|
1322
|
+
target: heldBy("cur"),
|
|
1323
|
+
receipt: true,
|
|
1324
|
+
missing: "abort"
|
|
1325
|
+
});
|
|
1326
|
+
/**
|
|
1327
|
+
* A completion's checks before it appends, under the session lock, which the
|
|
1328
|
+
* cursor's lock preceded: a head the clear marker passed meanwhile stops, and
|
|
1329
|
+
* a returned id a live row owns aborts.
|
|
1330
|
+
*/
|
|
1331
|
+
const RETURN_CHECKS = `
|
|
1332
|
+
select case
|
|
1333
|
+
when ${INDEX} <= ${clearIndexOf(SESSION)} then
|
|
1334
|
+
jsonb_build_object('stop', 'superseded')
|
|
1335
|
+
when exists (
|
|
1336
|
+
select 1 from a2_events event
|
|
1337
|
+
where event.event_id = any(${param.texts("ids")})
|
|
1338
|
+
and not (event.session_id = ${SESSION}
|
|
1339
|
+
and event.idx < ${clearIndexOf(SESSION)})
|
|
1340
|
+
) then jsonb_build_object('abort', 'owned')
|
|
1341
|
+
else jsonb_build_object('base', (
|
|
1342
|
+
select coalesce(max(idx), 0) from a2_events
|
|
1343
|
+
where session_id = ${SESSION}))
|
|
1344
|
+
end`;
|
|
1345
|
+
/** The consumer's dead-lettered head at `index`, locked. */
|
|
1346
|
+
const deadAt = (alias) => `
|
|
1347
|
+
${alias}.session_id = ${SESSION} and ${alias}.consumer = ${CONSUMER}
|
|
1348
|
+
and ${alias}.head = ${INDEX} and ${alias}.failed_at is not null`;
|
|
1349
|
+
/**
|
|
1350
|
+
* Locks the dead letter, or stops: the statements after it act only on the
|
|
1351
|
+
* row this one locked.
|
|
1352
|
+
*/
|
|
1353
|
+
const LOCK_DEAD = `
|
|
1354
|
+
with dead as materialized (
|
|
1355
|
+
select cur.cursor_key from a2_cursors cur
|
|
1356
|
+
where ${deadAt("cur")} and ${INDEX} > ${clearIndexOf(SESSION)}
|
|
1357
|
+
order by cur.cursor_key limit 1
|
|
1358
|
+
for update
|
|
1359
|
+
)
|
|
1360
|
+
select case when exists (select 1 from dead)
|
|
1361
|
+
then jsonb_build_object('redriven', true)
|
|
1362
|
+
else jsonb_build_object('stop', 'alive', 'redriven', false,
|
|
1363
|
+
'has_pending',
|
|
1364
|
+
${pendingIn(SESSION, param.texts("served"))}) end`;
|
|
1365
|
+
/** Clears a dead letter, keeping its claim history and last failure. */
|
|
1366
|
+
const RETRY_DEAD = `
|
|
1367
|
+
with target as materialized (
|
|
1368
|
+
select cur.cursor_key, cur.epoch from a2_cursors cur
|
|
1369
|
+
where ${deadAt("cur")} and ${INDEX} > ${clearIndexOf(SESSION)}
|
|
1370
|
+
order by cur.cursor_key limit 1
|
|
1371
|
+
for update of cur
|
|
1372
|
+
), retried as (
|
|
1373
|
+
update a2_cursors cur set failed_at = null, failure_count = 0
|
|
1374
|
+
from target
|
|
1375
|
+
where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
|
|
1376
|
+
and cur.consumer = ${CONSUMER} and cur.epoch = target.epoch
|
|
1377
|
+
returning 1
|
|
1378
|
+
)
|
|
1379
|
+
select jsonb_build_object('retried', count(*)) from retried`;
|
|
1380
|
+
const SKIP_DEAD = advance({
|
|
1381
|
+
target: deadAt("cur"),
|
|
1382
|
+
receipt: false,
|
|
1383
|
+
missing: "continue"
|
|
1384
|
+
});
|
|
1385
|
+
const PENDING = `
|
|
1386
|
+
select jsonb_build_object('has_pending',
|
|
1387
|
+
${pendingIn(SESSION, param.texts("served"))})`;
|
|
1388
|
+
/**
|
|
1389
|
+
* One log cursor as `wakeLog` finds it: the session's head and clear marker,
|
|
1390
|
+
* the cursor's latest run, where a run would start (`start`), and the next
|
|
1391
|
+
* event it takes from there unless the run has a head. `retyped` says whether
|
|
1392
|
+
* the run's types are stale. CTEs, for a `with`.
|
|
1393
|
+
*/
|
|
1394
|
+
const LOG_CURSOR = `
|
|
1395
|
+
boundary as materialized (
|
|
1396
|
+
select coalesce(max(idx), 0) as session_head,
|
|
1397
|
+
${clearIndexOf(SESSION)} as clear_index
|
|
1398
|
+
from a2_events where session_id = ${SESSION}
|
|
1399
|
+
), latest as materialized (
|
|
1400
|
+
select boundary.*, cur.*,
|
|
1401
|
+
case when cur.epoch is not null
|
|
1402
|
+
then greatest(cur.position, boundary.clear_index)
|
|
1403
|
+
when ${param.boolean("backfill")} then boundary.clear_index
|
|
1404
|
+
else boundary.session_head end as start
|
|
1405
|
+
from boundary
|
|
1406
|
+
left join lateral (
|
|
1407
|
+
select * from a2_cursors cur
|
|
1408
|
+
where cur.session_id = ${SESSION}
|
|
1409
|
+
and cur.cursor_key = ${param.text("cursorKey")}
|
|
1410
|
+
and cur.consumer = ${CONSUMER}
|
|
1411
|
+
order by cur.epoch desc limit 1
|
|
1412
|
+
) cur on true
|
|
1413
|
+
), found as materialized (
|
|
1414
|
+
select latest.*,
|
|
1415
|
+
latest.types is distinct from ${param.jsonb("types")} as retyped,
|
|
1416
|
+
case when latest.head > latest.clear_index then null
|
|
1417
|
+
else ${nextLogHead({
|
|
1418
|
+
types: param.jsonb("types"),
|
|
1419
|
+
after: "latest.start"
|
|
1420
|
+
})} end as next_head
|
|
1421
|
+
from latest
|
|
1422
|
+
)`;
|
|
1423
|
+
/**
|
|
1424
|
+
* A run with a head gets no next one: it is left as it is, claim and all,
|
|
1425
|
+
* and so is an idle run with nothing new to take. Only their types change.
|
|
1426
|
+
*/
|
|
1427
|
+
const KEEPS_RUN = "found.epoch is not null and found.next_head is null";
|
|
1428
|
+
/** No run yet, and nothing after the latest clear: none is started. */
|
|
1429
|
+
const NO_RUN_NEEDED = "found.epoch is null and found.session_head = found.clear_index";
|
|
1430
|
+
/** `wakeLog` without the lock: stops when there is nothing to write. */
|
|
1431
|
+
const READ_LOG = `
|
|
1432
|
+
with ${LOG_CURSOR}
|
|
1433
|
+
select case when ${KEEPS_RUN} and not found.retyped
|
|
1434
|
+
then jsonb_build_object('stop', 'unchanged',
|
|
1435
|
+
'cursor', to_jsonb(found))
|
|
1436
|
+
when ${NO_RUN_NEEDED}
|
|
1437
|
+
then jsonb_build_object('stop', 'empty', 'empty', true,
|
|
1438
|
+
'head', found.session_head)
|
|
1439
|
+
else jsonb_build_object('stale', true) end
|
|
1440
|
+
from found`;
|
|
1441
|
+
/**
|
|
1442
|
+
* `wakeLog` under the session lock. A kept run takes the consumer's types,
|
|
1443
|
+
* unless another operation holds its row: the session lock is held, so this
|
|
1444
|
+
* waits for none. Otherwise a run starts at `start`, retiring the runs it
|
|
1445
|
+
* replaces. One with a head is keyed by it; one with nothing to run is idle
|
|
1446
|
+
* at its position, as a run that finished its last head is, keyed by that
|
|
1447
|
+
* position so the next run, which starts after it, sorts later.
|
|
1448
|
+
*/
|
|
1449
|
+
const WAKE_LOG = `
|
|
1450
|
+
with ${LOG_CURSOR}, held as materialized (
|
|
1451
|
+
select cur.ctid from a2_cursors cur, found
|
|
1452
|
+
where ${KEEPS_RUN} and found.retyped
|
|
1453
|
+
and cur.session_id = ${SESSION}
|
|
1454
|
+
and cur.cursor_key = ${param.text("cursorKey")}
|
|
1455
|
+
and cur.consumer = ${CONSUMER} and cur.epoch = found.epoch
|
|
1456
|
+
and cur.types is distinct from ${param.jsonb("types")}
|
|
1457
|
+
for update of cur skip locked
|
|
1458
|
+
), retype as (
|
|
1459
|
+
update a2_cursors cur set types = ${param.jsonb("types")} from held
|
|
1460
|
+
where cur.ctid = held.ctid
|
|
1461
|
+
), target as (
|
|
1462
|
+
select ${param.text("cursorKey")} as cursor_key, ${CONSUMER} as consumer
|
|
1463
|
+
from found where not (${KEEPS_RUN}) and not (${NO_RUN_NEEDED})
|
|
1464
|
+
), ${RETIRE_RUNS("target", "(select clear_index from found)")}, started as (
|
|
1465
|
+
insert into a2_cursors
|
|
1466
|
+
(session_id, consumer, cursor_key, epoch, lane, kind, position, head,
|
|
1467
|
+
types)
|
|
1468
|
+
select ${SESSION}, target.consumer, target.cursor_key,
|
|
1469
|
+
coalesce(found.next_head, found.start), ${param.text("lane")}, 'log',
|
|
1470
|
+
found.start, found.next_head, ${param.jsonb("types")}
|
|
1471
|
+
from found, target
|
|
1472
|
+
returning *
|
|
1473
|
+
)
|
|
1474
|
+
select jsonb_build_object(
|
|
1475
|
+
'cursor', coalesce((select to_jsonb(started) from started),
|
|
1476
|
+
to_jsonb(found)),
|
|
1477
|
+
'head', found.session_head, 'clear_index', found.clear_index,
|
|
1478
|
+
'empty', ${NO_RUN_NEEDED})
|
|
1479
|
+
from found`;
|
|
1480
|
+
/**
|
|
1481
|
+
* The statements that start the cursor work a batch of `events` carries,
|
|
1482
|
+
* with their parameters: each event by its position in the batch, from 1,
|
|
1483
|
+
* since its index is assigned under the lock. `started` says whether it
|
|
1484
|
+
* starts work for a served consumer.
|
|
1485
|
+
*/
|
|
1486
|
+
const enqueue = (events, consumers) => {
|
|
1487
|
+
const served = consumers && new Set(consumers.map(({ name }) => name));
|
|
1488
|
+
let started = false;
|
|
1489
|
+
const singles = [];
|
|
1490
|
+
const queued = [];
|
|
1491
|
+
const lanes = /* @__PURE__ */ new Map();
|
|
1492
|
+
events.forEach((event, offset) => {
|
|
1493
|
+
const ord = offset + 1;
|
|
1494
|
+
for (const target of event.targets ?? []) {
|
|
1495
|
+
if (!served || served.has(target.consumer)) started = true;
|
|
1496
|
+
if (target.lane === null) {
|
|
1497
|
+
singles.push({
|
|
1498
|
+
consumer: target.consumer,
|
|
1499
|
+
ord
|
|
1500
|
+
});
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
queued.push({
|
|
1504
|
+
consumer: target.consumer,
|
|
1505
|
+
lane: target.lane,
|
|
1506
|
+
ord
|
|
1507
|
+
});
|
|
1508
|
+
const key = cursorKeyOf(target.lane, ord);
|
|
1509
|
+
const first = `${target.consumer}\u0000${key}`;
|
|
1510
|
+
if (!lanes.has(first)) lanes.set(first, {
|
|
1511
|
+
consumer: target.consumer,
|
|
1512
|
+
cursor_key: key,
|
|
1513
|
+
lane: target.lane,
|
|
1514
|
+
ord
|
|
1515
|
+
});
|
|
1516
|
+
}
|
|
1517
|
+
});
|
|
1518
|
+
const logs = [];
|
|
1519
|
+
for (const consumer of consumers ?? []) {
|
|
1520
|
+
const log = consumer.log;
|
|
1521
|
+
if (!log) continue;
|
|
1522
|
+
const offset = events.findIndex((event) => event.type !== "a2.cleared" && (log.types === null || log.types.includes(event.type)));
|
|
1523
|
+
if (offset === -1) continue;
|
|
1524
|
+
logs.push({
|
|
1525
|
+
consumer: consumer.name,
|
|
1526
|
+
cursor_key: cursorKeyOf(log.lane, 0),
|
|
1527
|
+
lane: log.lane,
|
|
1528
|
+
ord: offset + 1,
|
|
1529
|
+
types: log.types,
|
|
1530
|
+
backfill: log.backfill
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
if (logs.length > 0) started = true;
|
|
1534
|
+
return {
|
|
1535
|
+
statements: [
|
|
1536
|
+
singles.length > 0 && START_EVENTS,
|
|
1537
|
+
queued.length > 0 && QUEUE_LANES,
|
|
1538
|
+
queued.length > 0 && START_LANES,
|
|
1539
|
+
logs.length > 0 && WAKE_LOGS
|
|
1540
|
+
],
|
|
1541
|
+
params: {
|
|
1542
|
+
singles,
|
|
1543
|
+
queued,
|
|
1544
|
+
lanes: [...lanes.values()],
|
|
1545
|
+
logs,
|
|
1546
|
+
clearType: CLEAR_EVENT_TYPE
|
|
1547
|
+
},
|
|
1548
|
+
started
|
|
1549
|
+
};
|
|
1550
|
+
};
|
|
1551
|
+
/**
|
|
1552
|
+
* The rows a batch inserted at `base + 1`, as the store returns them.
|
|
1553
|
+
* `events` are what the batch sent.
|
|
1554
|
+
*/
|
|
1555
|
+
const insertedRows = ({ sessionId, events, base, now }) => events.map((event, offset) => ({
|
|
1556
|
+
id: event.id,
|
|
1557
|
+
type: event.type,
|
|
1558
|
+
payload: structuredClone(event.payload),
|
|
1559
|
+
index: base + 1 + offset,
|
|
1560
|
+
sessionId,
|
|
1561
|
+
createdAt: new Date(now),
|
|
1562
|
+
cause: event.cause ? { ...event.cause } : null
|
|
1563
|
+
}));
|
|
1564
|
+
/** A served log consumer's types, for a cursor that moves past its head. */
|
|
1565
|
+
const retypeOf = (consumer, consumers) => {
|
|
1566
|
+
const log = consumers?.find(({ name }) => name === consumer)?.log;
|
|
1567
|
+
return log ? {
|
|
1568
|
+
retype: true,
|
|
1569
|
+
types: log.types
|
|
1570
|
+
} : {
|
|
1571
|
+
retype: false,
|
|
1572
|
+
types: null
|
|
1573
|
+
};
|
|
1574
|
+
};
|
|
710
1575
|
function postgres(options = {}) {
|
|
711
1576
|
const clock = options.clock ?? SYSTEM_CLOCK;
|
|
712
1577
|
const generateId = options.ids ?? RANDOM_IDS;
|
|
@@ -733,34 +1598,23 @@ function postgres(options = {}) {
|
|
|
733
1598
|
}
|
|
734
1599
|
});
|
|
735
1600
|
}
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
client: resource.client,
|
|
741
|
-
listen
|
|
742
|
-
});
|
|
743
|
-
else await resource.initialize({
|
|
744
|
-
schema: SCHEMA + (listen ? POSTGRES_NOTIFICATIONS_SQL : ""),
|
|
745
|
-
run: () => initializeSchema({
|
|
746
|
-
client: resource.client,
|
|
747
|
-
listen
|
|
748
|
-
})
|
|
749
|
-
});
|
|
750
|
-
return lease;
|
|
751
|
-
} catch (error) {
|
|
752
|
-
if (!injected) await lease.release().catch(() => {});
|
|
753
|
-
throw error;
|
|
754
|
-
}
|
|
1601
|
+
return {
|
|
1602
|
+
lease,
|
|
1603
|
+
client: explainingPostgresClient(lease.value.client)
|
|
1604
|
+
};
|
|
755
1605
|
});
|
|
756
1606
|
const client = async () => {
|
|
757
1607
|
if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
758
|
-
const
|
|
1608
|
+
const { client: explaining } = await connection.get();
|
|
759
1609
|
if (closed) throw new A2Error("STORE_UNAVAILABLE", "the store was closed");
|
|
760
|
-
return
|
|
1610
|
+
return explaining;
|
|
761
1611
|
};
|
|
762
1612
|
const notifications = options.listenConnectionString ? postgresNotificationScope(async () => {
|
|
763
|
-
await client();
|
|
1613
|
+
const c = await client();
|
|
1614
|
+
await (await connection.get()).lease.value.initialize({
|
|
1615
|
+
key: "triggers",
|
|
1616
|
+
run: () => checkPostgresTriggers(c)
|
|
1617
|
+
});
|
|
764
1618
|
const pg = await loadPg();
|
|
765
1619
|
const connectionString = options.listenConnectionString;
|
|
766
1620
|
const key = postgresConnectionKey({
|
|
@@ -889,12 +1743,14 @@ function postgres(options = {}) {
|
|
|
889
1743
|
conn.release();
|
|
890
1744
|
}
|
|
891
1745
|
}
|
|
892
|
-
const resource = (await connection.get()).value;
|
|
1746
|
+
const resource = (await connection.get()).lease.value;
|
|
893
1747
|
return resource.exclusive(async () => {
|
|
894
1748
|
const raw = resource.raw;
|
|
895
1749
|
await raw.query(begin);
|
|
896
1750
|
try {
|
|
897
|
-
const result = await fn((text, values) => raw.query(text, values))
|
|
1751
|
+
const result = await fn((text, values) => raw.query(text, values).catch((error) => {
|
|
1752
|
+
throw explainPostgresError(error);
|
|
1753
|
+
}));
|
|
898
1754
|
await raw.query("commit");
|
|
899
1755
|
return result;
|
|
900
1756
|
} catch (err) {
|
|
@@ -904,171 +1760,247 @@ function postgres(options = {}) {
|
|
|
904
1760
|
});
|
|
905
1761
|
};
|
|
906
1762
|
/**
|
|
907
|
-
*
|
|
908
|
-
*
|
|
1763
|
+
* Runs `statements` (the falsy ones dropped) as one `a2_batch` call with
|
|
1764
|
+
* `params`, and the session's id as `session`. Its rows come back in order;
|
|
1765
|
+
* an aborted call throws what `abort` makes of the aborting row.
|
|
909
1766
|
*/
|
|
910
|
-
const
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
returning event.idx`, [sessionId, stale.map((row) => row["event_id"])]);
|
|
924
|
-
if (deleted.length < stale.length) throw new A2Error("STORE_UNAVAILABLE", `an event id being reused is still held by cleared work in session '${sessionId}'`);
|
|
925
|
-
return rows.filter((row) => !stale.includes(row));
|
|
1767
|
+
const run = async ({ sessionId, statements, params, abort = () => new A2Error("STORE_UNAVAILABLE", "postgres store operation failed") }) => {
|
|
1768
|
+
const c = await client();
|
|
1769
|
+
try {
|
|
1770
|
+
const { rows } = await c.query(BATCH, [statements.flatMap((statement) => statement === false ? [] : [compact(statement)]), jsonb({
|
|
1771
|
+
...params,
|
|
1772
|
+
session: sessionId
|
|
1773
|
+
})]);
|
|
1774
|
+
return rows.map((row) => row["r"]);
|
|
1775
|
+
} catch (error) {
|
|
1776
|
+
const reason = abortedBatch(error);
|
|
1777
|
+
if (reason) throw abort(reason);
|
|
1778
|
+
throw error;
|
|
1779
|
+
}
|
|
926
1780
|
};
|
|
927
1781
|
/** Whether `index` is at or below the boundary, read in a fresh statement. */
|
|
928
1782
|
const clearedAt = async ({ query, sessionId, index }) => {
|
|
929
1783
|
const { rows } = await query(`select ${clearIndexOf("$1")} as clear_index`, [sessionId]);
|
|
930
1784
|
return index <= asNumber(rows[0]?.["clear_index"]);
|
|
931
1785
|
};
|
|
932
|
-
/**
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
1786
|
+
/**
|
|
1787
|
+
* A completion that does not hold its head: a stale attempt, or a retry
|
|
1788
|
+
* after an unknown acknowledgement, which replays the committed children.
|
|
1789
|
+
* Takes no lock.
|
|
1790
|
+
*/
|
|
1791
|
+
const settleUnheld = async ({ query, sessionId, consumer, lane, index, attempt, events }) => {
|
|
1792
|
+
const superseded = { outcome: "superseded" };
|
|
1793
|
+
const { rows: cursorRows } = await query(`select latest.kind, latest.head, covering.completed_idx,
|
|
1794
|
+
covering.completed_attempt, covering.returned_event_ids
|
|
1795
|
+
from (
|
|
1796
|
+
select kind, head from a2_cursors
|
|
1797
|
+
where session_id = $1 and cursor_key = $2 and consumer = $3
|
|
1798
|
+
order by epoch desc limit 1
|
|
1799
|
+
) latest
|
|
1800
|
+
left join lateral (
|
|
1801
|
+
select completed_idx, completed_attempt, returned_event_ids
|
|
1802
|
+
from a2_cursors
|
|
1803
|
+
where session_id = $1 and cursor_key = $2 and consumer = $3
|
|
1804
|
+
and epoch <= $4
|
|
1805
|
+
order by epoch desc limit 1
|
|
1806
|
+
) covering on true`, [
|
|
1807
|
+
sessionId,
|
|
1808
|
+
cursorKeyOf(lane, index),
|
|
1809
|
+
consumer,
|
|
1810
|
+
index
|
|
956
1811
|
]);
|
|
957
|
-
|
|
1812
|
+
const cursor = cursorRows[0];
|
|
1813
|
+
if (!cursor) {
|
|
1814
|
+
if (await hasEvent(query, sessionId, index)) return superseded;
|
|
1815
|
+
if (await clearedAt({
|
|
1816
|
+
query,
|
|
1817
|
+
sessionId,
|
|
1818
|
+
index
|
|
1819
|
+
})) return superseded;
|
|
1820
|
+
throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
1821
|
+
}
|
|
1822
|
+
const ids = events.map((event) => event.id);
|
|
1823
|
+
if (new Set(ids).size !== ids.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
|
|
1824
|
+
if (asNullableNumber(cursor["head"]) === index) return superseded;
|
|
1825
|
+
let completion;
|
|
1826
|
+
if (cursor["kind"] === "lane") {
|
|
1827
|
+
const { rows } = await query(`select completed_attempt, returned_event_ids from a2_lane_events
|
|
1828
|
+
where session_id = $1 and consumer = $2 and lane = $3 and idx = $4`, [
|
|
1829
|
+
sessionId,
|
|
1830
|
+
consumer,
|
|
1831
|
+
lane,
|
|
1832
|
+
index
|
|
1833
|
+
]);
|
|
1834
|
+
completion = rows[0] && {
|
|
1835
|
+
attempt: asNullableNumber(rows[0]["completed_attempt"]),
|
|
1836
|
+
ids: rows[0]["returned_event_ids"]
|
|
1837
|
+
};
|
|
1838
|
+
} else if (asNullableNumber(cursor["completed_idx"]) === index) completion = {
|
|
1839
|
+
attempt: asNullableNumber(cursor["completed_attempt"]),
|
|
1840
|
+
ids: cursor["returned_event_ids"]
|
|
1841
|
+
};
|
|
1842
|
+
if (!completion || completion.attempt !== attempt) return superseded;
|
|
1843
|
+
const returnedIds = completion.ids === null || completion.ids === void 0 ? null : decodeReturnedEventIds(completion.ids);
|
|
1844
|
+
if (returnedIds === null || returnedIds.length !== ids.length || returnedIds.some((id, offset) => id !== ids[offset])) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "completed attempt does not match the returned event batch");
|
|
1845
|
+
if (events.length === 0) return {
|
|
1846
|
+
outcome: "completed",
|
|
1847
|
+
events: []
|
|
1848
|
+
};
|
|
1849
|
+
const { rows: existing } = await query(`select session_id, idx, event_type, payload, event_id, created_at, cause
|
|
1850
|
+
from a2_events where event_id = any($1::text[])`, [ids]);
|
|
1851
|
+
const byId = new Map(existing.map((row) => [row["event_id"], row]));
|
|
1852
|
+
const ordered = ids.map((id) => byId.get(id));
|
|
1853
|
+
if (ordered.some((row) => !row) && await clearedAt({
|
|
1854
|
+
query,
|
|
1855
|
+
sessionId,
|
|
1856
|
+
index
|
|
1857
|
+
})) return superseded;
|
|
1858
|
+
if (ordered.some((row) => {
|
|
1859
|
+
const cause = row ? decodeCause(row["cause"]) : null;
|
|
1860
|
+
return !row || row["session_id"] !== sessionId || cause?.consumer !== consumer || cause.lane !== lane || cause.index !== index || cause.attempt !== attempt;
|
|
1861
|
+
})) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
|
|
1862
|
+
return {
|
|
1863
|
+
outcome: "completed",
|
|
1864
|
+
events: idempotentReplay(events, ordered.map(toStored))
|
|
1865
|
+
};
|
|
958
1866
|
};
|
|
959
1867
|
const store = {
|
|
960
1868
|
async append(sessionId, events, appendOptions) {
|
|
1869
|
+
const consumers = appendOptions?.consumers;
|
|
1870
|
+
const served = servedNames(consumers);
|
|
961
1871
|
if (events.length === 0) return wrap(async () => {
|
|
962
|
-
const { rows } = await (await client()).query(`select
|
|
963
|
-
select 1 from a2_events
|
|
964
|
-
where session_id = $1 and processed_at is null
|
|
965
|
-
and idx > ${clearIndexOf("$1")}
|
|
966
|
-
) as has_pending`, [sessionId]);
|
|
1872
|
+
const { rows } = await (await client()).query(`select ${pendingIn("$1", "$2")} as has_pending`, [sessionId, served]);
|
|
967
1873
|
return {
|
|
968
1874
|
events: [],
|
|
969
1875
|
hasPending: rows[0]?.["has_pending"] === true
|
|
970
1876
|
};
|
|
971
1877
|
});
|
|
972
|
-
return wrap(
|
|
973
|
-
await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
|
|
974
|
-
const { rows: statusRows } = await query(`select coalesce(max(idx), 0) as max,
|
|
975
|
-
${clearIndexOf("$1")} as clear_index,
|
|
976
|
-
exists(
|
|
977
|
-
select 1 from a2_events pending
|
|
978
|
-
where pending.session_id = $1
|
|
979
|
-
and pending.processed_at is null
|
|
980
|
-
and pending.idx > ${clearIndexOf("$1")}
|
|
981
|
-
) as session_has_pending
|
|
982
|
-
from a2_events
|
|
983
|
-
where session_id = $1`, [sessionId]);
|
|
984
|
-
const base = asNumber(statusRows[0]?.["max"]);
|
|
985
|
-
const clearIndex = asNumber(statusRows[0]?.["clear_index"]);
|
|
986
|
-
const sessionHasPending = statusRows[0]?.["session_has_pending"] === true;
|
|
1878
|
+
return wrap(async () => {
|
|
987
1879
|
const suppliedIds = events.filter((e) => e.id !== void 0).map((e) => e.id);
|
|
988
1880
|
if (new Set(suppliedIds).size !== suppliedIds.length) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "batch contains the same event id more than once");
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
if (existing.length === events.length) return {
|
|
1000
|
-
events: idempotentReplay(events, existing.map(toStored)),
|
|
1001
|
-
hasPending: sessionHasPending
|
|
1002
|
-
};
|
|
1003
|
-
throw new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`);
|
|
1004
|
-
}
|
|
1005
|
-
}
|
|
1881
|
+
const now = clock.now().getTime();
|
|
1882
|
+
const rows = events.map((event, offset) => ({
|
|
1883
|
+
ord: offset + 1,
|
|
1884
|
+
id: event.id ?? generateId(),
|
|
1885
|
+
type: event.type,
|
|
1886
|
+
payload: event.payload,
|
|
1887
|
+
cause: event.cause ?? null
|
|
1888
|
+
}));
|
|
1889
|
+
const causes = [...new Map(events.flatMap((event) => event.cause ? [[jsonb(event.cause), event.cause]] : [])).values()];
|
|
1890
|
+
const work = enqueue(events, consumers);
|
|
1006
1891
|
const originIndex = appendOptions?.originIndex;
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1892
|
+
const checked = (await run({
|
|
1893
|
+
sessionId,
|
|
1894
|
+
statements: [
|
|
1895
|
+
LOCK_SESSION,
|
|
1896
|
+
APPEND_CHECKS,
|
|
1897
|
+
causes.length > 0 && FENCE,
|
|
1898
|
+
suppliedIds.length > 0 && REUSE_IDS,
|
|
1899
|
+
INSERT_EVENTS,
|
|
1900
|
+
...work.statements
|
|
1901
|
+
],
|
|
1902
|
+
params: {
|
|
1903
|
+
...work.params,
|
|
1904
|
+
served,
|
|
1905
|
+
ids: suppliedIds,
|
|
1906
|
+
count: events.length,
|
|
1907
|
+
origin: originIndex ?? null,
|
|
1908
|
+
causes: causes.map((cause, offset) => ({
|
|
1909
|
+
ord: offset + 1,
|
|
1910
|
+
consumer: cause.consumer,
|
|
1911
|
+
cursor_key: cursorKeyOf(cause.lane, cause.index),
|
|
1912
|
+
idx: cause.index,
|
|
1913
|
+
attempt: cause.attempt
|
|
1914
|
+
})),
|
|
1915
|
+
events: rows,
|
|
1916
|
+
now
|
|
1917
|
+
},
|
|
1918
|
+
abort: (reason) => {
|
|
1919
|
+
switch (reason["abort"]) {
|
|
1920
|
+
case "foreign": return new A2Error("PARTIAL_DUPLICATE_BATCH", `event id '${String(reason["event_id"])}' already exists in another session`);
|
|
1921
|
+
case "mixed": {
|
|
1922
|
+
const owned = asNumber(reason["owned"]);
|
|
1923
|
+
return new A2Error("PARTIAL_DUPLICATE_BATCH", `batch mixes ${owned} already-appended and ${events.length - owned} fresh events`);
|
|
1924
|
+
}
|
|
1925
|
+
case "cleared": return sessionClearedError(`scheduled work from event ${originIndex} was cleared in session '${sessionId}'`);
|
|
1926
|
+
case "superseded": return supersededAttemptError(sessionId, causes[asNumber(reason["ord"]) - 1]);
|
|
1927
|
+
case "held": return new A2Error("STORE_UNAVAILABLE", `an event id being reused is still held by cleared work in session '${sessionId}'`);
|
|
1928
|
+
default: return new A2Error("STORE_UNAVAILABLE", "postgres store operation failed");
|
|
1929
|
+
}
|
|
1020
1930
|
}
|
|
1021
|
-
}
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
await insertEvent({
|
|
1029
|
-
query,
|
|
1030
|
-
row: {
|
|
1031
|
-
sessionId,
|
|
1032
|
-
index,
|
|
1033
|
-
type: e.type,
|
|
1034
|
-
payload: e.payload,
|
|
1035
|
-
id,
|
|
1036
|
-
createdAt: now,
|
|
1037
|
-
cause: e.cause ?? null,
|
|
1038
|
-
lane: e.lane ?? null,
|
|
1039
|
-
processedAt: e.settled ? now : null
|
|
1040
|
-
},
|
|
1041
|
-
clearIndex
|
|
1042
|
-
});
|
|
1043
|
-
inserted.push({
|
|
1044
|
-
id,
|
|
1045
|
-
type: e.type,
|
|
1046
|
-
payload: structuredClone(e.payload),
|
|
1047
|
-
index,
|
|
1931
|
+
})).find((row) => row["stop"] === "replay" || "base" in row);
|
|
1932
|
+
if (checked["stop"] === "replay") return {
|
|
1933
|
+
events: idempotentReplay(events, checked["events"].map(toStored)),
|
|
1934
|
+
hasPending: checked["has_pending"] === true
|
|
1935
|
+
};
|
|
1936
|
+
return {
|
|
1937
|
+
events: insertedRows({
|
|
1048
1938
|
sessionId,
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1939
|
+
events: rows,
|
|
1940
|
+
base: asNumber(checked["base"]),
|
|
1941
|
+
now
|
|
1942
|
+
}),
|
|
1943
|
+
hasPending: checked["has_pending"] === true || work.started
|
|
1944
|
+
};
|
|
1945
|
+
});
|
|
1946
|
+
},
|
|
1947
|
+
async wakeLog({ sessionId, consumer }) {
|
|
1948
|
+
const { log } = consumer;
|
|
1949
|
+
return wrap(async () => {
|
|
1950
|
+
const found = (await run({
|
|
1951
|
+
sessionId,
|
|
1952
|
+
statements: [
|
|
1953
|
+
READ_LOG,
|
|
1954
|
+
LOCK_SESSION,
|
|
1955
|
+
WAKE_LOG
|
|
1956
|
+
],
|
|
1957
|
+
params: {
|
|
1958
|
+
cursorKey: cursorKeyOf(log.lane, 0),
|
|
1959
|
+
consumer: consumer.name,
|
|
1960
|
+
lane: log.lane,
|
|
1961
|
+
types: log.types,
|
|
1962
|
+
backfill: log.backfill,
|
|
1963
|
+
clearType: CLEAR_EVENT_TYPE
|
|
1964
|
+
}
|
|
1965
|
+
})).at(-1);
|
|
1966
|
+
if (found["empty"] === true) {
|
|
1967
|
+
const head = asNumber(found["head"]);
|
|
1968
|
+
return {
|
|
1969
|
+
cursor: idleLogCursor(consumer.name, log.lane, head),
|
|
1970
|
+
head
|
|
1971
|
+
};
|
|
1066
1972
|
}
|
|
1973
|
+
const cursor = found["cursor"];
|
|
1974
|
+
const clearIndex = asNumber(found["stop"] === "unchanged" ? cursor["clear_index"] : found["clear_index"]);
|
|
1067
1975
|
return {
|
|
1068
|
-
|
|
1069
|
-
|
|
1976
|
+
cursor: toCursor(cursor, clearIndex),
|
|
1977
|
+
head: asNumber(found["stop"] === "unchanged" ? cursor["session_head"] : found["head"])
|
|
1070
1978
|
};
|
|
1071
|
-
})
|
|
1979
|
+
});
|
|
1980
|
+
},
|
|
1981
|
+
async cursors(sessionId) {
|
|
1982
|
+
return wrap(async () => {
|
|
1983
|
+
const { rows } = await (await client()).query(`with boundary as materialized (
|
|
1984
|
+
select ${clearIndexOf("$1")} as clear_index
|
|
1985
|
+
)
|
|
1986
|
+
select cur.*, boundary.clear_index
|
|
1987
|
+
from a2_cursors cur, boundary
|
|
1988
|
+
where cur.session_id = $1
|
|
1989
|
+
and (
|
|
1990
|
+
cur.head > boundary.clear_index
|
|
1991
|
+
or (cur.kind <> 'event' and not exists (
|
|
1992
|
+
select 1 from a2_cursors later
|
|
1993
|
+
where later.session_id = cur.session_id
|
|
1994
|
+
and later.cursor_key = cur.cursor_key
|
|
1995
|
+
and later.consumer = cur.consumer
|
|
1996
|
+
and later.epoch > cur.epoch
|
|
1997
|
+
) and (
|
|
1998
|
+
cur.kind = 'log'
|
|
1999
|
+
or (cur.head is null and cur.position > boundary.clear_index)
|
|
2000
|
+
))
|
|
2001
|
+
)`, [sessionId]);
|
|
2002
|
+
return rows.map((row) => toCursor(row, asNumber(row["clear_index"]))).toSorted(byCursor);
|
|
2003
|
+
});
|
|
1072
2004
|
},
|
|
1073
2005
|
async read(sessionId, opts) {
|
|
1074
2006
|
return wrap(async () => {
|
|
@@ -1084,7 +2016,8 @@ function postgres(options = {}) {
|
|
|
1084
2016
|
conditions.push(`idx <= $${params.length}`);
|
|
1085
2017
|
}
|
|
1086
2018
|
conditions.push(`idx >= ${clearIndexOf("$1")}`);
|
|
1087
|
-
const { rows } = await c.query(`select
|
|
2019
|
+
const { rows } = await c.query(`select session_id, idx, event_type, payload, event_id, created_at, cause
|
|
2020
|
+
from a2_events where ${conditions.join(" and ")} order by idx`, params);
|
|
1088
2021
|
return rows.map(toStored);
|
|
1089
2022
|
});
|
|
1090
2023
|
},
|
|
@@ -1108,79 +2041,57 @@ function postgres(options = {}) {
|
|
|
1108
2041
|
return rows.map(toEvent);
|
|
1109
2042
|
});
|
|
1110
2043
|
},
|
|
1111
|
-
async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs,
|
|
1112
|
-
return wrap(
|
|
2044
|
+
async claimAvailable({ sessionId, holder, ttlMs, expiresAtMs, exclude = [], consumers, maxFailures, through }) {
|
|
2045
|
+
return wrap(async () => {
|
|
1113
2046
|
const now = clock.now().getTime();
|
|
1114
|
-
const
|
|
1115
|
-
const exhausted = `(claimed.claim_holder is not null
|
|
1116
|
-
and claimed.failure_count + 1 >= $6::int)`;
|
|
1117
|
-
const { rows } = await query(`with eligible as materialized (
|
|
1118
|
-
select event.idx
|
|
1119
|
-
from a2_events event
|
|
1120
|
-
where event.session_id = $1
|
|
1121
|
-
and event.processed_at is null
|
|
1122
|
-
and event.failed_at is null
|
|
1123
|
-
and event.lane_ready
|
|
1124
|
-
and event.idx > ${clearIndexOf("$1")}
|
|
1125
|
-
and (event.claim_expires_at is null or event.claim_expires_at <= $2)
|
|
1126
|
-
and not (event.idx = any($5::bigint[]))
|
|
1127
|
-
for update of event
|
|
1128
|
-
)
|
|
1129
|
-
update a2_events claimed set
|
|
1130
|
-
attempt_count = claimed.attempt_count + 1,
|
|
1131
|
-
failure_count = claimed.failure_count
|
|
1132
|
-
+ case when claimed.claim_holder is null then 0 else 1 end,
|
|
1133
|
-
failed_at = case when ${exhausted} then $2::bigint else claimed.failed_at end,
|
|
1134
|
-
first_claimed_at = case when ${exhausted}
|
|
1135
|
-
then claimed.first_claimed_at
|
|
1136
|
-
else coalesce(claimed.first_claimed_at, $2::bigint) end,
|
|
1137
|
-
last_claimed_at = case when ${exhausted}
|
|
1138
|
-
then claimed.last_claimed_at else $2::bigint end,
|
|
1139
|
-
claim_holder = case when ${exhausted} then null else $3::text end,
|
|
1140
|
-
claim_expires_at = case when ${exhausted} then null else $4::bigint end
|
|
1141
|
-
from eligible
|
|
1142
|
-
where claimed.session_id = $1 and claimed.idx = eligible.idx
|
|
1143
|
-
returning claimed.*, ${clearIndexOf("$1")} as clear_index`, [
|
|
1144
|
-
sessionId,
|
|
1145
|
-
now,
|
|
1146
|
-
holder,
|
|
1147
|
-
expiresAt,
|
|
1148
|
-
[...excludeIndexes],
|
|
1149
|
-
maxFailures ?? null
|
|
1150
|
-
]);
|
|
1151
|
-
for (const row of rows) if (row["failed_at"] !== null) await releasePins({
|
|
1152
|
-
query,
|
|
2047
|
+
const results = await run({
|
|
1153
2048
|
sessionId,
|
|
1154
|
-
|
|
2049
|
+
statements: [
|
|
2050
|
+
CLAIM,
|
|
2051
|
+
RELEASE_DEAD,
|
|
2052
|
+
RETRY_AT
|
|
2053
|
+
],
|
|
2054
|
+
params: {
|
|
2055
|
+
now,
|
|
2056
|
+
served: servedNames(consumers),
|
|
2057
|
+
excluded: exclude.map((ref) => ({
|
|
2058
|
+
consumer: ref.consumer,
|
|
2059
|
+
cursor_key: cursorKeyOf(ref.lane, ref.index)
|
|
2060
|
+
})),
|
|
2061
|
+
holder,
|
|
2062
|
+
expiresAt: expiresAtMs ?? now + ttlMs,
|
|
2063
|
+
maxFailures: maxFailures ?? null,
|
|
2064
|
+
through: through ?? null
|
|
2065
|
+
}
|
|
1155
2066
|
});
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
2067
|
+
const rows = results.filter((row) => "e_idx" in row);
|
|
2068
|
+
const unseen = rows.filter((row) => row["e_idx"] === null);
|
|
2069
|
+
if (unseen.length > 0) {
|
|
2070
|
+
const { rows: events } = await (await client()).query(`select ${HEAD_COLUMNS} from a2_events event
|
|
2071
|
+
where event.session_id = $1 and event.idx = any($2::bigint[])`, [sessionId, unseen.map((row) => row["head"])]);
|
|
2072
|
+
const byIndex = new Map(events.map((event) => [asNumber(event["e_idx"]), event]));
|
|
2073
|
+
for (const row of unseen) Object.assign(row, byIndex.get(asNumber(row["head"])));
|
|
2074
|
+
}
|
|
2075
|
+
const heads = rows.filter((row) => row["e_idx"] !== null);
|
|
2076
|
+
if (heads.length > 0) {
|
|
2077
|
+
const events = heads.map((row) => {
|
|
2078
|
+
const head = toHead(row);
|
|
1160
2079
|
const clearIndex = asNumber(row["clear_index"]);
|
|
1161
|
-
if (clearIndex > 0)
|
|
1162
|
-
return
|
|
1163
|
-
}).toSorted(
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
and idx > ${clearIndexOf("$1")}
|
|
1172
|
-
and claim_expires_at > $2
|
|
1173
|
-
and not (idx = any($3::bigint[]))`, [
|
|
1174
|
-
sessionId,
|
|
1175
|
-
now,
|
|
1176
|
-
[...excludeIndexes]
|
|
1177
|
-
]);
|
|
1178
|
-
const retryAt = activeRows[0]?.["retry_at"];
|
|
2080
|
+
if (clearIndex > 0) head.clearIndex = clearIndex;
|
|
2081
|
+
return head;
|
|
2082
|
+
}).toSorted(byHead);
|
|
2083
|
+
return {
|
|
2084
|
+
outcome: "claimed",
|
|
2085
|
+
events,
|
|
2086
|
+
head: Math.max(...heads.map((row) => asNumber(row["session_head"])), ...events.map((event) => event.index))
|
|
2087
|
+
};
|
|
2088
|
+
}
|
|
2089
|
+
const retryAt = results.at(-1)["retry_at"];
|
|
1179
2090
|
return retryAt === null || retryAt === void 0 ? { outcome: "settled" } : {
|
|
1180
2091
|
outcome: "busy",
|
|
1181
2092
|
retryAt: new Date(asNumber(retryAt))
|
|
1182
2093
|
};
|
|
1183
|
-
})
|
|
2094
|
+
});
|
|
1184
2095
|
},
|
|
1185
2096
|
async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
|
|
1186
2097
|
if (claims.length === 0) return {
|
|
@@ -1191,233 +2102,203 @@ function postgres(options = {}) {
|
|
|
1191
2102
|
const c = await client();
|
|
1192
2103
|
const now = clock.now().getTime();
|
|
1193
2104
|
const expiresAt = expiresAtMs ?? now + ttlMs;
|
|
1194
|
-
const
|
|
1195
|
-
const attempts = new Map(claims.map((claim) => [claim.index, claim.attempt]));
|
|
2105
|
+
const ordered = claims.toSorted((a, b) => a.index - b.index || compareText(a.consumer, b.consumer));
|
|
1196
2106
|
const { rows } = await c.query(`with listed as (
|
|
1197
|
-
select
|
|
1198
|
-
|
|
1199
|
-
),
|
|
1200
|
-
|
|
1201
|
-
update a2_events event set claim_expires_at = $1
|
|
2107
|
+
select * from unnest($3::text[], $4::text[], $5::bigint[], $6::int[])
|
|
2108
|
+
with ordinality as listed(consumer, cursor_key, idx, attempt, ord)
|
|
2109
|
+
), renewed as (
|
|
2110
|
+
update a2_cursors cur set claim_expires_at = $7
|
|
1202
2111
|
from listed
|
|
1203
|
-
where
|
|
1204
|
-
and
|
|
1205
|
-
and
|
|
1206
|
-
and
|
|
1207
|
-
and
|
|
1208
|
-
and
|
|
1209
|
-
and
|
|
1210
|
-
and
|
|
1211
|
-
|
|
2112
|
+
where cur.session_id = $1
|
|
2113
|
+
and cur.cursor_key = listed.cursor_key
|
|
2114
|
+
and cur.consumer = listed.consumer
|
|
2115
|
+
and cur.head = listed.idx
|
|
2116
|
+
and listed.idx > ${clearIndexOf("$1")}
|
|
2117
|
+
and cur.attempt_count = listed.attempt
|
|
2118
|
+
and cur.failed_at is null
|
|
2119
|
+
and cur.claim_holder = $2
|
|
2120
|
+
and cur.claim_expires_at > $8
|
|
2121
|
+
returning cur.cursor_key, cur.consumer, cur.head
|
|
1212
2122
|
)
|
|
1213
|
-
select listed.
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
2123
|
+
select listed.ord, latest.head, latest.position, latest.attempt_count,
|
|
2124
|
+
latest.epoch is not null as found,
|
|
2125
|
+
exists(
|
|
2126
|
+
select 1 from renewed
|
|
2127
|
+
where renewed.cursor_key = listed.cursor_key
|
|
2128
|
+
and renewed.consumer = listed.consumer
|
|
2129
|
+
and renewed.head = listed.idx
|
|
2130
|
+
) as renewed,
|
|
2131
|
+
listed.idx <= ${clearIndexOf("$1")} as cleared
|
|
1217
2132
|
from listed
|
|
1218
|
-
left join
|
|
1219
|
-
|
|
1220
|
-
|
|
2133
|
+
left join lateral (
|
|
2134
|
+
select cur.epoch, cur.head, cur.position, cur.attempt_count
|
|
2135
|
+
from a2_cursors cur
|
|
2136
|
+
where cur.session_id = $1
|
|
2137
|
+
and cur.cursor_key = listed.cursor_key
|
|
2138
|
+
and cur.consumer = listed.consumer
|
|
2139
|
+
order by cur.epoch desc limit 1
|
|
2140
|
+
) latest on true
|
|
2141
|
+
order by listed.ord`, [
|
|
1221
2142
|
sessionId,
|
|
1222
|
-
indexes,
|
|
1223
2143
|
holder,
|
|
1224
|
-
|
|
1225
|
-
|
|
2144
|
+
ordered.map((claim) => claim.consumer),
|
|
2145
|
+
ordered.map((claim) => cursorKeyOf(claim.lane, claim.index)),
|
|
2146
|
+
ordered.map((claim) => claim.index),
|
|
2147
|
+
ordered.map((claim) => claim.attempt),
|
|
2148
|
+
expiresAt,
|
|
2149
|
+
now
|
|
1226
2150
|
]);
|
|
1227
2151
|
const renewed = [];
|
|
1228
2152
|
const superseded = [];
|
|
1229
2153
|
for (const row of rows) {
|
|
1230
|
-
const
|
|
1231
|
-
if (row["renewed"] === true)
|
|
1232
|
-
|
|
2154
|
+
const { attempt, ...ref } = ordered[asNumber(row["ord"]) - 1];
|
|
2155
|
+
if (row["renewed"] === true) {
|
|
2156
|
+
renewed.push(ref);
|
|
2157
|
+
continue;
|
|
2158
|
+
}
|
|
2159
|
+
if (row["cleared"] === true) {
|
|
2160
|
+
superseded.push(ref);
|
|
2161
|
+
continue;
|
|
2162
|
+
}
|
|
2163
|
+
if (row["found"] !== true) continue;
|
|
2164
|
+
const head = asNullableNumber(row["head"]);
|
|
2165
|
+
if (head !== ref.index && asNumber(row["position"]) < ref.index) continue;
|
|
2166
|
+
if (head !== ref.index || asNumber(row["attempt_count"]) > attempt) superseded.push(ref);
|
|
1233
2167
|
}
|
|
1234
2168
|
return {
|
|
1235
|
-
renewed
|
|
1236
|
-
superseded
|
|
2169
|
+
renewed,
|
|
2170
|
+
superseded
|
|
1237
2171
|
};
|
|
1238
2172
|
});
|
|
1239
2173
|
},
|
|
1240
|
-
async completeAttempt({ sessionId, index, attempt, events }) {
|
|
1241
|
-
return wrap(
|
|
1242
|
-
const { rows: parentRows } = await query(`select boundary.clear_index, parent.*
|
|
1243
|
-
from (select ${clearIndexOf("$1")} as clear_index) boundary
|
|
1244
|
-
left join lateral (
|
|
1245
|
-
select * from a2_events
|
|
1246
|
-
where session_id = $1 and idx = $2 and idx > boundary.clear_index
|
|
1247
|
-
for update
|
|
1248
|
-
) parent on true`, [sessionId, index]);
|
|
1249
|
-
if (index <= asNumber(parentRows[0]?.["clear_index"])) return { outcome: "superseded" };
|
|
1250
|
-
const parent = parentRows[0]?.["idx"] == null ? void 0 : parentRows[0];
|
|
1251
|
-
if (!parent) {
|
|
1252
|
-
if (await clearedAt({
|
|
1253
|
-
query,
|
|
1254
|
-
sessionId,
|
|
1255
|
-
index
|
|
1256
|
-
})) return { outcome: "superseded" };
|
|
1257
|
-
throw new TypeError(`no event at index ${index} in session '${sessionId}'`);
|
|
1258
|
-
}
|
|
2174
|
+
async completeAttempt({ sessionId, consumer, lane, index, attempt, events, consumers }) {
|
|
2175
|
+
return wrap(async () => {
|
|
1259
2176
|
const ids = events.map((event) => event.id);
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
events: []
|
|
1268
|
-
};
|
|
1269
|
-
const { rows: existing } = await query(`select * from a2_events
|
|
1270
|
-
where event_id = any($1::text[])`, [ids]);
|
|
1271
|
-
const byId = new Map(existing.map((row) => [row["event_id"], row]));
|
|
1272
|
-
const ordered = ids.map((id) => byId.get(id));
|
|
1273
|
-
if (ordered.some((row) => !row) && await clearedAt({
|
|
1274
|
-
query,
|
|
1275
|
-
sessionId,
|
|
1276
|
-
index
|
|
1277
|
-
})) return { outcome: "superseded" };
|
|
1278
|
-
if (ordered.some((row) => {
|
|
1279
|
-
const cause = row ? toCause(row["cause"]) : null;
|
|
1280
|
-
return !row || row["session_id"] !== sessionId || cause?.index !== index || cause.attempt !== attempt;
|
|
1281
|
-
})) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event retry does not match the committed batch");
|
|
1282
|
-
return {
|
|
1283
|
-
outcome: "completed",
|
|
1284
|
-
events: idempotentReplay(events, ordered.map(toStored))
|
|
1285
|
-
};
|
|
1286
|
-
}
|
|
1287
|
-
if (asNumber(parent["attempt_count"]) !== attempt || parent["claim_holder"] === null || parent["claim_holder"] === void 0 || parent["failed_at"] !== null) return { outcome: "superseded" };
|
|
1288
|
-
await query("select pg_advisory_xact_lock(hashtext($1))", [sessionId]);
|
|
1289
|
-
const { rows: maxRows } = await query(`select coalesce(max(idx), 0) as max, ${clearIndexOf("$1")} as clear_index
|
|
1290
|
-
from a2_events where session_id = $1`, [sessionId]);
|
|
1291
|
-
const clearIndex = asNumber(maxRows[0]?.["clear_index"]);
|
|
1292
|
-
if (index <= clearIndex) return { outcome: "superseded" };
|
|
1293
|
-
if (ids.length > 0) {
|
|
1294
|
-
if ((await liveOwnersOf({
|
|
1295
|
-
query,
|
|
1296
|
-
sessionId,
|
|
1297
|
-
clearIndex,
|
|
1298
|
-
ids
|
|
1299
|
-
})).length > 0) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id");
|
|
1300
|
-
}
|
|
1301
|
-
const base = asNumber(maxRows[0]?.["max"]);
|
|
2177
|
+
const duplicated = new Set(ids).size !== ids.length;
|
|
2178
|
+
const cause = {
|
|
2179
|
+
consumer,
|
|
2180
|
+
lane,
|
|
2181
|
+
index,
|
|
2182
|
+
attempt
|
|
2183
|
+
};
|
|
1302
2184
|
const now = clock.now().getTime();
|
|
1303
|
-
const
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
2185
|
+
const rows = events.map((event, offset) => ({
|
|
2186
|
+
ord: offset + 1,
|
|
2187
|
+
id: event.id,
|
|
2188
|
+
type: event.type,
|
|
2189
|
+
payload: event.payload,
|
|
2190
|
+
cause
|
|
2191
|
+
}));
|
|
2192
|
+
const work = events.length > 0 && !duplicated ? enqueue(events, consumers) : void 0;
|
|
2193
|
+
const results = await run({
|
|
2194
|
+
sessionId,
|
|
2195
|
+
statements: duplicated ? [HOLD] : work ? [
|
|
2196
|
+
HOLD,
|
|
2197
|
+
LOCK_SESSION,
|
|
2198
|
+
RETURN_CHECKS,
|
|
2199
|
+
REUSE_IDS,
|
|
2200
|
+
INSERT_EVENTS,
|
|
2201
|
+
...work.statements,
|
|
2202
|
+
COMPLETE_APPENDED
|
|
2203
|
+
] : [
|
|
2204
|
+
HOLD,
|
|
2205
|
+
LOCK_IF_IDLE,
|
|
2206
|
+
COMPLETE
|
|
2207
|
+
],
|
|
2208
|
+
params: {
|
|
2209
|
+
...work?.params,
|
|
2210
|
+
...retypeOf(consumer, consumers),
|
|
2211
|
+
cursorKey: cursorKeyOf(lane, index),
|
|
2212
|
+
consumer,
|
|
2213
|
+
index,
|
|
2214
|
+
attempt,
|
|
2215
|
+
ids,
|
|
2216
|
+
count: events.length,
|
|
2217
|
+
events: rows,
|
|
2218
|
+
now,
|
|
2219
|
+
clearType: CLEAR_EVENT_TYPE
|
|
2220
|
+
},
|
|
2221
|
+
abort: (reason) => reason["abort"] === "owned" ? new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains an already-appended event id") : new A2Error("STORE_UNAVAILABLE", reason["abort"] === "held" ? `an event id being reused is still held by cleared work in session '${sessionId}'` : "postgres store operation failed")
|
|
2222
|
+
});
|
|
2223
|
+
const held = results[0];
|
|
2224
|
+
if (held["stop"] === "superseded") return { outcome: "superseded" };
|
|
2225
|
+
if (held["stop"] === "unheld") {
|
|
2226
|
+
const c = await client();
|
|
2227
|
+
return settleUnheld({
|
|
2228
|
+
query: (text, values) => c.query(text, values),
|
|
2229
|
+
sessionId,
|
|
2230
|
+
consumer,
|
|
2231
|
+
lane,
|
|
2232
|
+
index,
|
|
2233
|
+
attempt,
|
|
2234
|
+
events
|
|
1325
2235
|
});
|
|
1326
|
-
inserted.push(toStored(row));
|
|
1327
2236
|
}
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
returned_event_ids = $5::jsonb,
|
|
1332
|
-
lane_ready = false,
|
|
1333
|
-
claim_holder = null,
|
|
1334
|
-
claim_expires_at = null
|
|
1335
|
-
where session_id = $3 and idx = $4`, [
|
|
1336
|
-
now,
|
|
1337
|
-
attempt,
|
|
1338
|
-
sessionId,
|
|
1339
|
-
index,
|
|
1340
|
-
jsonb(ids)
|
|
1341
|
-
]);
|
|
1342
|
-
await query(`delete from a2_snapshot_pins
|
|
1343
|
-
where session_id = $1 and event_index = $2`, [sessionId, index]);
|
|
1344
|
-
await query(`delete from a2_snapshot_history history
|
|
1345
|
-
where history.session_id = $1
|
|
1346
|
-
and not exists (
|
|
1347
|
-
select 1 from a2_snapshot_pins pin
|
|
1348
|
-
where pin.session_id = history.session_id
|
|
1349
|
-
and pin.reducer_name = history.reducer_name
|
|
1350
|
-
and pin.up_to_index = history.up_to_index
|
|
1351
|
-
)`, [sessionId]);
|
|
1352
|
-
const parentLane = parent["lane"] ?? null;
|
|
1353
|
-
if (parentLane !== null) await query(`update a2_events set lane_ready = true
|
|
1354
|
-
where session_id = $1
|
|
1355
|
-
and idx = (
|
|
1356
|
-
select idx from a2_events
|
|
1357
|
-
where session_id = $1 and lane = $2 and processed_at is null
|
|
1358
|
-
and idx > $3
|
|
1359
|
-
order by idx limit 1
|
|
1360
|
-
)`, [
|
|
1361
|
-
sessionId,
|
|
1362
|
-
parentLane,
|
|
1363
|
-
clearIndex
|
|
1364
|
-
]);
|
|
2237
|
+
if (duplicated) throw new A2Error("PARTIAL_DUPLICATE_BATCH", "returned event batch contains the same event id more than once");
|
|
2238
|
+
if (results.at(-1)["stop"] === "superseded") return { outcome: "superseded" };
|
|
2239
|
+
const checked = results.find((row) => "base" in row);
|
|
1365
2240
|
return {
|
|
1366
2241
|
outcome: "completed",
|
|
1367
|
-
events:
|
|
2242
|
+
events: checked ? insertedRows({
|
|
2243
|
+
sessionId,
|
|
2244
|
+
events: rows,
|
|
2245
|
+
base: asNumber(checked["base"]),
|
|
2246
|
+
now
|
|
2247
|
+
}) : []
|
|
1368
2248
|
};
|
|
1369
|
-
})
|
|
2249
|
+
});
|
|
1370
2250
|
},
|
|
1371
|
-
async failAttempt({ sessionId, index, attempt, error, maxFailures, retryAfterMs }) {
|
|
1372
|
-
const now = clock.now().getTime();
|
|
2251
|
+
async failAttempt({ sessionId, consumer, lane, index, attempt, error, maxFailures, retryAfterMs }) {
|
|
1373
2252
|
return wrap(() => withTx(async (query) => {
|
|
2253
|
+
const now = clock.now().getTime();
|
|
2254
|
+
const cursorKey = cursorKeyOf(lane, index);
|
|
1374
2255
|
const { rows } = await query(`with current as materialized (
|
|
1375
|
-
select
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
2256
|
+
select cur.* from a2_cursors cur
|
|
2257
|
+
where cur.session_id = $1 and cur.cursor_key = $2
|
|
2258
|
+
and cur.consumer = $3 and cur.head = $4
|
|
2259
|
+
and cur.head > ${clearIndexOf("$1")}
|
|
2260
|
+
for update of cur
|
|
1379
2261
|
), updated as (
|
|
1380
|
-
update
|
|
1381
|
-
failure_count =
|
|
1382
|
-
last_error = $
|
|
1383
|
-
last_failed_at = $
|
|
1384
|
-
last_failed_attempt = $
|
|
2262
|
+
update a2_cursors cur set
|
|
2263
|
+
failure_count = cur.failure_count + 1,
|
|
2264
|
+
last_error = $6,
|
|
2265
|
+
last_failed_at = $8,
|
|
2266
|
+
last_failed_attempt = $5,
|
|
1385
2267
|
failed_at = case
|
|
1386
|
-
when
|
|
1387
|
-
else
|
|
2268
|
+
when cur.failure_count + 1 >= $7 then $8
|
|
2269
|
+
else cur.failed_at
|
|
1388
2270
|
end,
|
|
1389
2271
|
claim_holder = null,
|
|
1390
2272
|
claim_expires_at = case
|
|
1391
|
-
when
|
|
1392
|
-
else $
|
|
2273
|
+
when cur.failure_count + 1 >= $7 then null
|
|
2274
|
+
else $9::bigint
|
|
1393
2275
|
end
|
|
1394
2276
|
from current
|
|
1395
|
-
where
|
|
1396
|
-
and
|
|
1397
|
-
and current.
|
|
2277
|
+
where cur.session_id = $1 and cur.cursor_key = $2
|
|
2278
|
+
and cur.consumer = $3 and cur.epoch = current.epoch
|
|
2279
|
+
and current.attempt_count = $5
|
|
1398
2280
|
and current.failed_at is null
|
|
1399
|
-
and current.attempt_count = $3
|
|
1400
2281
|
and current.claim_holder is not null
|
|
1401
|
-
returning
|
|
2282
|
+
returning cur.failure_count, cur.failed_at
|
|
1402
2283
|
)
|
|
1403
2284
|
select case
|
|
1404
|
-
when $
|
|
2285
|
+
when $4 <= ${clearIndexOf("$1")} then 'cleared'
|
|
1405
2286
|
when not exists (select 1 from current) then 'missing'
|
|
1406
2287
|
when exists (
|
|
1407
2288
|
select 1 from updated where failed_at is not null
|
|
1408
2289
|
) then 'dead_lettered'
|
|
1409
2290
|
when exists (select 1 from updated) then 'failed'
|
|
1410
2291
|
when exists (
|
|
1411
|
-
select 1 from current
|
|
1412
|
-
where processed_at is not null or attempt_count <> $3
|
|
2292
|
+
select 1 from current where attempt_count <> $5
|
|
1413
2293
|
) then 'superseded'
|
|
1414
|
-
when exists (
|
|
1415
|
-
select 1 from current where failed_at is not null
|
|
1416
|
-
) then 'dead_lettered'
|
|
1417
2294
|
when exists (
|
|
1418
2295
|
select 1 from current
|
|
1419
|
-
where claim_holder is null and last_failed_attempt = $
|
|
2296
|
+
where claim_holder is null and last_failed_attempt = $5
|
|
2297
|
+
and failed_at is null
|
|
1420
2298
|
) then 'failed'
|
|
2299
|
+
when exists (
|
|
2300
|
+
select 1 from current where failed_at is not null
|
|
2301
|
+
) then 'dead_lettered'
|
|
1421
2302
|
else 'superseded'
|
|
1422
2303
|
end as outcome,
|
|
1423
2304
|
coalesce(
|
|
@@ -1425,6 +2306,8 @@ function postgres(options = {}) {
|
|
|
1425
2306
|
(select failure_count from current)
|
|
1426
2307
|
) as failure_count`, [
|
|
1427
2308
|
sessionId,
|
|
2309
|
+
cursorKey,
|
|
2310
|
+
consumer,
|
|
1428
2311
|
index,
|
|
1429
2312
|
attempt,
|
|
1430
2313
|
error,
|
|
@@ -1438,7 +2321,13 @@ function postgres(options = {}) {
|
|
|
1438
2321
|
failureCount: 0
|
|
1439
2322
|
};
|
|
1440
2323
|
if (!row || row["outcome"] === "missing") {
|
|
1441
|
-
|
|
2324
|
+
const { rows: cursorRows } = await query(`select 1 from a2_cursors
|
|
2325
|
+
where session_id = $1 and cursor_key = $2 and consumer = $3`, [
|
|
2326
|
+
sessionId,
|
|
2327
|
+
cursorKey,
|
|
2328
|
+
consumer
|
|
2329
|
+
]);
|
|
2330
|
+
if (cursorRows.length > 0 || await hasEvent(query, sessionId, index) || await clearedAt({
|
|
1442
2331
|
query,
|
|
1443
2332
|
sessionId,
|
|
1444
2333
|
index
|
|
@@ -1451,6 +2340,8 @@ function postgres(options = {}) {
|
|
|
1451
2340
|
if (row["outcome"] === "dead_lettered") await releasePins({
|
|
1452
2341
|
query,
|
|
1453
2342
|
sessionId,
|
|
2343
|
+
consumer,
|
|
2344
|
+
cursorKey,
|
|
1454
2345
|
index
|
|
1455
2346
|
});
|
|
1456
2347
|
return {
|
|
@@ -1461,54 +2352,39 @@ function postgres(options = {}) {
|
|
|
1461
2352
|
},
|
|
1462
2353
|
async deadLetters(sessionId) {
|
|
1463
2354
|
return wrap(async () => {
|
|
1464
|
-
const { rows } = await (await client()).query(`select
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
2355
|
+
const { rows } = await (await client()).query(`select cur.*, ${HEAD_COLUMNS}
|
|
2356
|
+
from a2_cursors cur
|
|
2357
|
+
join a2_events event
|
|
2358
|
+
on event.session_id = cur.session_id and event.idx = cur.head
|
|
2359
|
+
where cur.session_id = $1
|
|
2360
|
+
and cur.failed_at is not null
|
|
2361
|
+
and cur.head > ${clearIndexOf("$1")}`, [sessionId]);
|
|
2362
|
+
return rows.map(toHead).toSorted(byHead);
|
|
1471
2363
|
});
|
|
1472
2364
|
},
|
|
1473
|
-
async redrive({ sessionId, index, action }) {
|
|
1474
|
-
return wrap(
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
1479
|
-
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
await query(`update a2_events set processed_at = $3, lane_ready = false
|
|
1487
|
-
where session_id = $1 and idx = $2`, [
|
|
1488
|
-
sessionId,
|
|
2365
|
+
async redrive({ sessionId, consumer, index, action, consumers }) {
|
|
2366
|
+
return wrap(async () => {
|
|
2367
|
+
const results = await run({
|
|
2368
|
+
sessionId,
|
|
2369
|
+
statements: [
|
|
2370
|
+
action === "skip" && LOCK_SESSION,
|
|
2371
|
+
LOCK_DEAD,
|
|
2372
|
+
action === "skip" ? SKIP_DEAD : RETRY_DEAD,
|
|
2373
|
+
PENDING
|
|
2374
|
+
],
|
|
2375
|
+
params: {
|
|
2376
|
+
...retypeOf(consumer, consumers),
|
|
2377
|
+
consumer,
|
|
1489
2378
|
index,
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
where session_id = $1
|
|
1495
|
-
and idx = (
|
|
1496
|
-
select idx from a2_events
|
|
1497
|
-
where session_id = $1 and lane = $2 and processed_at is null
|
|
1498
|
-
and idx > ${clearIndexOf("$1")}
|
|
1499
|
-
order by idx limit 1
|
|
1500
|
-
)`, [sessionId, lane]);
|
|
1501
|
-
}
|
|
1502
|
-
const { rows: status } = await query(`select exists(
|
|
1503
|
-
select 1 from a2_events
|
|
1504
|
-
where session_id = $1 and processed_at is null
|
|
1505
|
-
and idx > ${clearIndexOf("$1")}
|
|
1506
|
-
) as has_pending`, [sessionId]);
|
|
2379
|
+
served: servedNames(consumers),
|
|
2380
|
+
clearType: CLEAR_EVENT_TYPE
|
|
2381
|
+
}
|
|
2382
|
+
});
|
|
1507
2383
|
return {
|
|
1508
|
-
redriven: row
|
|
1509
|
-
hasPending:
|
|
2384
|
+
redriven: results.find((row) => "redriven" in row)?.["redriven"] === true,
|
|
2385
|
+
hasPending: results.at(-1)["has_pending"] === true
|
|
1510
2386
|
};
|
|
1511
|
-
})
|
|
2387
|
+
});
|
|
1512
2388
|
},
|
|
1513
2389
|
async readState(sessionId, reducerName, stateOptions) {
|
|
1514
2390
|
return wrap(async () => (await readStateBatch([{
|
|
@@ -1536,25 +2412,41 @@ function postgres(options = {}) {
|
|
|
1536
2412
|
const normalized = requested.filter((write) => write.index >= clearIndex);
|
|
1537
2413
|
if (normalized.length === 0) return;
|
|
1538
2414
|
const currentHead = headRows[0]?.["up_to_index"] == null ? void 0 : headRows[0];
|
|
1539
|
-
const requestedPins = normalized.flatMap((write) => (write.
|
|
1540
|
-
eventIndex,
|
|
2415
|
+
const requestedPins = normalized.flatMap((write) => (write.pins ?? []).map((pin) => ({
|
|
2416
|
+
eventIndex: pin.index,
|
|
2417
|
+
consumer: pin.consumer,
|
|
2418
|
+
cursorKey: cursorKeyOf(pin.lane, pin.index),
|
|
1541
2419
|
snapshotIndex: write.index
|
|
1542
2420
|
})));
|
|
1543
2421
|
if (requestedPins.length > 0) {
|
|
1544
|
-
const
|
|
1545
|
-
|
|
1546
|
-
|
|
2422
|
+
const heads = [...new Map(requestedPins.map((pin) => [`${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`, pin])).values()];
|
|
2423
|
+
const { rows: live } = await query(`select cur.consumer, cur.cursor_key, cur.head
|
|
2424
|
+
from a2_cursors cur
|
|
2425
|
+
join unnest($2::text[], $3::text[], $4::bigint[])
|
|
2426
|
+
as pin(consumer, cursor_key, idx)
|
|
2427
|
+
on cur.consumer = pin.consumer
|
|
2428
|
+
and cur.cursor_key = pin.cursor_key
|
|
2429
|
+
and cur.head = pin.idx
|
|
2430
|
+
where cur.session_id = $1 and cur.head > $5
|
|
2431
|
+
and cur.failed_at is null
|
|
2432
|
+
order by cur.cursor_key, cur.consumer
|
|
2433
|
+
for share of cur`, [
|
|
1547
2434
|
sessionId,
|
|
1548
|
-
|
|
2435
|
+
heads.map((pin) => pin.consumer),
|
|
2436
|
+
heads.map((pin) => pin.cursorKey),
|
|
2437
|
+
heads.map((pin) => pin.eventIndex),
|
|
1549
2438
|
clearIndex
|
|
1550
2439
|
]);
|
|
1551
|
-
const unfinished = new Set(
|
|
1552
|
-
const accepted = requestedPins.filter((pin) => unfinished.has(pin.eventIndex));
|
|
2440
|
+
const unfinished = new Set(live.map((row) => `${String(row["consumer"])}\u0000${String(row["cursor_key"])}\u0000${asNumber(row["head"])}`));
|
|
2441
|
+
const accepted = requestedPins.filter((pin) => unfinished.has(`${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`));
|
|
1553
2442
|
if (accepted.length > 0) await query(`insert into a2_snapshot_pins
|
|
1554
|
-
(session_id, event_index, reducer_name, up_to_index)
|
|
1555
|
-
select $1, pin."eventIndex",
|
|
2443
|
+
(session_id, event_index, consumer, cursor_key, reducer_name, up_to_index)
|
|
2444
|
+
select $1, pin."eventIndex", pin.consumer, pin."cursorKey",
|
|
2445
|
+
$2, pin."snapshotIndex"
|
|
1556
2446
|
from jsonb_to_recordset($3::jsonb) as pin(
|
|
1557
2447
|
"eventIndex" bigint,
|
|
2448
|
+
consumer text,
|
|
2449
|
+
"cursorKey" text,
|
|
1558
2450
|
"snapshotIndex" bigint
|
|
1559
2451
|
)
|
|
1560
2452
|
on conflict do nothing`, [
|
|
@@ -1725,21 +2617,19 @@ function postgres(options = {}) {
|
|
|
1725
2617
|
},
|
|
1726
2618
|
async clear({ sessionId, ifLastIndex, cause }) {
|
|
1727
2619
|
return wrap(() => withTx(async (query) => {
|
|
1728
|
-
await query
|
|
2620
|
+
await lockSession(query, sessionId);
|
|
1729
2621
|
const { rows: headRows } = await query(`select coalesce(max(idx), 0) as head,
|
|
1730
2622
|
${clearIndexOf("$1")} as clear_index
|
|
1731
2623
|
from a2_events where session_id = $1`, [sessionId]);
|
|
1732
2624
|
const head = asNumber(headRows[0]?.["head"]);
|
|
1733
2625
|
const clearIndex = asNumber(headRows[0]?.["clear_index"]);
|
|
1734
|
-
if (cause) {
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
if (asNumber(parent["attempt_count"]) !== cause.attempt || parent["failed_at"] !== null) throw supersededAttemptError(sessionId, cause);
|
|
1742
|
-
}
|
|
2626
|
+
if (cause) await assertCurrentCause({
|
|
2627
|
+
query,
|
|
2628
|
+
sessionId,
|
|
2629
|
+
cause,
|
|
2630
|
+
clearIndex,
|
|
2631
|
+
lock: "update"
|
|
2632
|
+
});
|
|
1743
2633
|
if (ifLastIndex !== void 0 && clearIndex === ifLastIndex + 1) return {
|
|
1744
2634
|
cleared: true,
|
|
1745
2635
|
index: head,
|
|
@@ -1756,31 +2646,27 @@ function postgres(options = {}) {
|
|
|
1756
2646
|
clearIndex
|
|
1757
2647
|
};
|
|
1758
2648
|
const marker = head + 1;
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
createdAt: now,
|
|
1769
|
-
cause: null,
|
|
1770
|
-
lane: null,
|
|
1771
|
-
processedAt: now
|
|
1772
|
-
},
|
|
1773
|
-
clearIndex
|
|
1774
|
-
});
|
|
2649
|
+
await query(`insert into a2_events
|
|
2650
|
+
(session_id, idx, event_type, payload, event_id, created_at, cause)
|
|
2651
|
+
values ($1, $2, $3, 'null'::jsonb, $4, $5, null)`, [
|
|
2652
|
+
sessionId,
|
|
2653
|
+
marker,
|
|
2654
|
+
CLEAR_EVENT_TYPE,
|
|
2655
|
+
generateId(),
|
|
2656
|
+
clock.now().getTime()
|
|
2657
|
+
]);
|
|
1775
2658
|
await query(`with released as (
|
|
1776
|
-
update
|
|
1777
|
-
set
|
|
1778
|
-
where session_id = $1 and
|
|
2659
|
+
update a2_cursors
|
|
2660
|
+
set head = null, ${FRESH_DISPATCH}
|
|
2661
|
+
where session_id = $1 and cursor_key = $3 and consumer = $4
|
|
2662
|
+
and head = $5
|
|
1779
2663
|
)
|
|
1780
2664
|
insert into a2_clears (session_id, clear_index) values ($1, $2)
|
|
1781
2665
|
on conflict (session_id) do update set clear_index = excluded.clear_index`, [
|
|
1782
2666
|
sessionId,
|
|
1783
2667
|
marker,
|
|
2668
|
+
cause ? cursorKeyOf(cause.lane, cause.index) : null,
|
|
2669
|
+
cause?.consumer ?? null,
|
|
1784
2670
|
cause?.index ?? null
|
|
1785
2671
|
]);
|
|
1786
2672
|
return {
|
|
@@ -1808,6 +2694,26 @@ function postgres(options = {}) {
|
|
|
1808
2694
|
), pins as (
|
|
1809
2695
|
delete from a2_snapshot_pins pin using deleted
|
|
1810
2696
|
where pin.session_id = $1 and pin.event_index = deleted.idx
|
|
2697
|
+
), lanes as materialized (
|
|
2698
|
+
-- Each lane's queue through the batch, by the lane's own key.
|
|
2699
|
+
select lane.ctid from a2_cursors cur
|
|
2700
|
+
join a2_lane_events lane
|
|
2701
|
+
on lane.session_id = cur.session_id
|
|
2702
|
+
and lane.consumer = cur.consumer and lane.lane = cur.lane
|
|
2703
|
+
where cur.session_id = $1 and cur.kind = 'lane'
|
|
2704
|
+
and lane.idx <= (select max(idx) from batch)
|
|
2705
|
+
for update of lane skip locked
|
|
2706
|
+
), gone_lanes as (
|
|
2707
|
+
delete from a2_lane_events target using lanes
|
|
2708
|
+
where target.ctid = lanes.ctid
|
|
2709
|
+
), singles as materialized (
|
|
2710
|
+
select cur.ctid from a2_cursors cur
|
|
2711
|
+
join batch on cur.cursor_key = '#' || batch.idx
|
|
2712
|
+
where cur.session_id = $1 and cur.kind = 'event'
|
|
2713
|
+
for update of cur skip locked
|
|
2714
|
+
), gone_singles as (
|
|
2715
|
+
delete from a2_cursors target using singles
|
|
2716
|
+
where target.ctid = singles.ctid
|
|
1811
2717
|
)
|
|
1812
2718
|
select (select count(*) from deleted) as deleted,
|
|
1813
2719
|
(select clear_index from boundary) as clear_index`, [sessionId, RECLAIM_BATCH]);
|
|
@@ -1817,16 +2723,20 @@ function postgres(options = {}) {
|
|
|
1817
2723
|
deleted: 0,
|
|
1818
2724
|
done: true
|
|
1819
2725
|
};
|
|
1820
|
-
if (deleted > 0) return {
|
|
1821
|
-
deleted,
|
|
1822
|
-
done: false
|
|
1823
|
-
};
|
|
1824
2726
|
const { rows: remaining } = await query("select 1 from a2_events where session_id = $1 and idx < $2 limit 1", [sessionId, clearIndex]);
|
|
1825
2727
|
if (remaining.length > 0) return {
|
|
1826
|
-
deleted
|
|
2728
|
+
deleted,
|
|
1827
2729
|
done: false
|
|
1828
2730
|
};
|
|
1829
|
-
const { rows: swept } = await query(`with
|
|
2731
|
+
const { rows: swept } = await query(`with lanes as materialized (
|
|
2732
|
+
select ctid from a2_lane_events
|
|
2733
|
+
where session_id = $1 and idx < $2
|
|
2734
|
+
for update skip locked
|
|
2735
|
+
), cursors as materialized (
|
|
2736
|
+
select cur.ctid from a2_cursors cur
|
|
2737
|
+
where ${SUPERSEDED_CURSOR}
|
|
2738
|
+
for update skip locked
|
|
2739
|
+
), pins as materialized (
|
|
1830
2740
|
select ctid from a2_snapshot_pins
|
|
1831
2741
|
where session_id = $1 and up_to_index < $2
|
|
1832
2742
|
for update skip locked
|
|
@@ -1838,6 +2748,12 @@ function postgres(options = {}) {
|
|
|
1838
2748
|
select ctid from a2_snapshots
|
|
1839
2749
|
where session_id = $1 and up_to_index < $2
|
|
1840
2750
|
for update skip locked
|
|
2751
|
+
), gone_lanes as (
|
|
2752
|
+
delete from a2_lane_events target using lanes
|
|
2753
|
+
where target.ctid = lanes.ctid
|
|
2754
|
+
), gone_cursors as (
|
|
2755
|
+
delete from a2_cursors target using cursors
|
|
2756
|
+
where target.ctid = cursors.ctid
|
|
1841
2757
|
), gone_pins as (
|
|
1842
2758
|
delete from a2_snapshot_pins target using pins where target.ctid = pins.ctid
|
|
1843
2759
|
), gone_history as (
|
|
@@ -1846,17 +2762,23 @@ function postgres(options = {}) {
|
|
|
1846
2762
|
), gone_heads as (
|
|
1847
2763
|
delete from a2_snapshots target using heads where target.ctid = heads.ctid
|
|
1848
2764
|
)
|
|
1849
|
-
select (select count(*) from
|
|
2765
|
+
select (select count(*) from a2_lane_events
|
|
2766
|
+
where session_id = $1 and idx < $2)
|
|
2767
|
+
+ (select count(*) from a2_cursors cur
|
|
2768
|
+
where ${SUPERSEDED_CURSOR})
|
|
2769
|
+
+ (select count(*) from a2_snapshot_pins
|
|
1850
2770
|
where session_id = $1 and up_to_index < $2)
|
|
1851
2771
|
+ (select count(*) from a2_snapshot_history
|
|
1852
2772
|
where session_id = $1 and up_to_index < $2)
|
|
1853
2773
|
+ (select count(*) from a2_snapshots
|
|
1854
2774
|
where session_id = $1 and up_to_index < $2)
|
|
2775
|
+
- (select count(*) from lanes)
|
|
2776
|
+
- (select count(*) from cursors)
|
|
1855
2777
|
- (select count(*) from pins)
|
|
1856
2778
|
- (select count(*) from history)
|
|
1857
2779
|
- (select count(*) from heads) as skipped`, [sessionId, clearIndex]);
|
|
1858
2780
|
return {
|
|
1859
|
-
deleted
|
|
2781
|
+
deleted,
|
|
1860
2782
|
done: asNumber(swept[0]?.["skipped"]) === 0
|
|
1861
2783
|
};
|
|
1862
2784
|
}));
|
|
@@ -1867,8 +2789,8 @@ function postgres(options = {}) {
|
|
|
1867
2789
|
for (const subscription of presenceSubscriptions.values()) subscription.stop();
|
|
1868
2790
|
presenceSubscriptions.clear();
|
|
1869
2791
|
await notifications?.close();
|
|
1870
|
-
const
|
|
1871
|
-
await (injected ?? lease)?.release();
|
|
2792
|
+
const opened = await connection.peek()?.catch(() => void 0);
|
|
2793
|
+
await (injected ?? opened?.lease)?.release();
|
|
1872
2794
|
}
|
|
1873
2795
|
};
|
|
1874
2796
|
if (notifications) {
|