experimental-a2 0.20.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (309) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-C7b-1arn.d.ts → actor-CqibLgjQ.d.ts} +15 -7
  4. package/dist/actor-CqibLgjQ.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +1 -1
  6. package/dist/actor-client.js +2 -2
  7. package/dist/actor-react.d.ts +3 -3
  8. package/dist/actor-react.js +5 -5
  9. package/dist/{actor-shared-t7w8xIKP.js → actor-shared-DFET5q16.js} +3 -3
  10. package/dist/actor-shared-DFET5q16.js.map +1 -0
  11. package/dist/actor.d.ts +1 -1
  12. package/dist/actor.js +54 -43
  13. package/dist/actor.js.map +1 -1
  14. package/dist/{ai-Cz3hqVhk.d.ts → ai-DAu77CDr.d.ts} +8 -8
  15. package/dist/{ai-Cz3hqVhk.d.ts.map → ai-DAu77CDr.d.ts.map} +1 -1
  16. package/dist/{ai-mMiWul1T.js → ai-EEfKyc-z.js} +9 -9
  17. package/dist/ai-EEfKyc-z.js.map +1 -0
  18. package/dist/ai-client.d.ts +2 -2
  19. package/dist/ai-client.js +2 -2
  20. package/dist/ai-fx.d.ts +4 -2
  21. package/dist/ai-fx.d.ts.map +1 -1
  22. package/dist/ai-fx.js +214 -67
  23. package/dist/ai-fx.js.map +1 -1
  24. package/dist/{ai-harness-CV6rXbB3.d.ts → ai-harness-DTjkAr2T.d.ts} +2 -2
  25. package/dist/{ai-harness-CV6rXbB3.d.ts.map → ai-harness-DTjkAr2T.d.ts.map} +1 -1
  26. package/dist/{ai-harness-Clbg5ei7.js → ai-harness-dQeJaR1t.js} +7 -3
  27. package/dist/ai-harness-dQeJaR1t.js.map +1 -0
  28. package/dist/ai-pi.d.ts +1 -1
  29. package/dist/ai-pi.js +3 -3
  30. package/dist/ai-pi.js.map +1 -1
  31. package/dist/ai-react.d.ts +5 -5
  32. package/dist/ai-react.js +2 -2
  33. package/dist/ai-server.d.ts +14 -17
  34. package/dist/ai-server.d.ts.map +1 -1
  35. package/dist/ai-server.js +86 -73
  36. package/dist/ai-server.js.map +1 -1
  37. package/dist/ai.d.ts +2 -2
  38. package/dist/ai.js +2 -2
  39. package/dist/{client-BhHLoNKF.js → client-BQS4i_V9.js} +29 -6
  40. package/dist/client-BQS4i_V9.js.map +1 -0
  41. package/dist/{client-h_DKFAjj.d.ts → client-CUMbS_38.d.ts} +3 -3
  42. package/dist/{client-h_DKFAjj.d.ts.map → client-CUMbS_38.d.ts.map} +1 -1
  43. package/dist/client.d.ts +3 -3
  44. package/dist/client.js +1 -1
  45. package/dist/{contract-BYKASlCV.js → contract-CWukt01d.js} +2 -2
  46. package/dist/contract-CWukt01d.js.map +1 -0
  47. package/dist/index.d.ts +5 -5
  48. package/dist/index.js +3 -3
  49. package/dist/{initial-snapshot-Bx-c0pDP.d.ts → initial-snapshot-D3Lve43o.d.ts} +4 -4
  50. package/dist/initial-snapshot-D3Lve43o.d.ts.map +1 -0
  51. package/dist/{internal-CscSY7K_.js → internal-CvsrprnU.js} +40 -16
  52. package/dist/internal-CvsrprnU.js.map +1 -0
  53. package/dist/otel.d.ts +1 -1
  54. package/dist/otel.js +1 -1
  55. package/dist/otel.js.map +1 -1
  56. package/dist/{react-BBXgHWFc.d.ts → react-BoHvC3v9.d.ts} +4 -4
  57. package/dist/{react-BBXgHWFc.d.ts.map → react-BoHvC3v9.d.ts.map} +1 -1
  58. package/dist/react.d.ts +1 -1
  59. package/dist/react.js +1 -1
  60. package/dist/{reducer-CqA59Q6U.d.ts → reducer-CYPHsSmd.d.ts} +2 -2
  61. package/dist/{reducer-CqA59Q6U.d.ts.map → reducer-CYPHsSmd.d.ts.map} +1 -1
  62. package/dist/{reducer-BAXc8JyF.js → reducer-CjmvZSAb.js} +2 -2
  63. package/dist/{reducer-BAXc8JyF.js.map → reducer-CjmvZSAb.js.map} +1 -1
  64. package/dist/runtime-consumer-CIeL33wj.js +12 -0
  65. package/dist/runtime-consumer-CIeL33wj.js.map +1 -0
  66. package/dist/scheduler-qstash.d.ts +2 -2
  67. package/dist/scheduler-qstash.js +3 -3
  68. package/dist/scheduler-task-X1SToDjP.js.map +1 -1
  69. package/dist/scheduler-vercel.d.ts +2 -2
  70. package/dist/scheduler-vercel.js +3 -3
  71. package/dist/scheduler-vercel.js.map +1 -1
  72. package/dist/{server-qyb5aJJG.js → server-DtFrWxi3.js} +412 -116
  73. package/dist/server-DtFrWxi3.js.map +1 -0
  74. package/dist/{server-DYAATFuT.d.ts → server-qgez0Xny.d.ts} +108 -41
  75. package/dist/server-qgez0Xny.d.ts.map +1 -0
  76. package/dist/server.browser.js +1 -1
  77. package/dist/server.browser.js.map +1 -1
  78. package/dist/server.d.ts +4 -4
  79. package/dist/server.js +2 -2
  80. package/dist/{session-clear-C-QwTXV1.d.ts → session-clear-D1PFe7xl.d.ts} +2 -2
  81. package/dist/session-clear-D1PFe7xl.d.ts.map +1 -0
  82. package/dist/{session-clear-DGI4jTQ5.js → session-clear-wHd5GCpZ.js} +5 -4
  83. package/dist/session-clear-wHd5GCpZ.js.map +1 -0
  84. package/dist/store-D0MhBvg6.d.ts +532 -0
  85. package/dist/store-D0MhBvg6.d.ts.map +1 -0
  86. package/dist/store-N8PXxDAS.js.map +1 -1
  87. package/dist/store-codec-DwrWlgQx.js +65 -0
  88. package/dist/store-codec-DwrWlgQx.js.map +1 -0
  89. package/dist/store-memory.d.ts +1 -1
  90. package/dist/store-memory.d.ts.map +1 -1
  91. package/dist/store-memory.js +406 -225
  92. package/dist/store-memory.js.map +1 -1
  93. package/dist/{store-polling-7WJV0YWP.js → store-polling-BZt2Rf3D.js} +2 -2
  94. package/dist/{store-polling-7WJV0YWP.js.map → store-polling-BZt2Rf3D.js.map} +1 -1
  95. package/dist/store-postgres.d.ts +1 -1
  96. package/dist/store-postgres.d.ts.map +1 -1
  97. package/dist/store-postgres.js +1598 -587
  98. package/dist/store-postgres.js.map +1 -1
  99. package/dist/store-presence-polling-JuAy2Ck2.js +111 -0
  100. package/dist/store-presence-polling-JuAy2Ck2.js.map +1 -0
  101. package/dist/store-redis-http.d.ts +1 -1
  102. package/dist/store-redis-http.js +4 -4
  103. package/dist/store-redis-notify-CsJw_hfD.js +3060 -0
  104. package/dist/store-redis-notify-CsJw_hfD.js.map +1 -0
  105. package/dist/store-redis.d.ts +1 -1
  106. package/dist/store-redis.d.ts.map +1 -1
  107. package/dist/store-redis.js +4 -5
  108. package/dist/store-redis.js.map +1 -1
  109. package/dist/store-sqlite.d.ts +1 -1
  110. package/dist/store-sqlite.d.ts.map +1 -1
  111. package/dist/store-sqlite.js +527 -299
  112. package/dist/store-sqlite.js.map +1 -1
  113. package/dist/stream-buffer-DrTyiIuo.js +57 -0
  114. package/dist/stream-buffer-DrTyiIuo.js.map +1 -0
  115. package/dist/{telemetry-Cyfqo6Qt.d.ts → telemetry-CWwsh-Gx.d.ts} +3 -3
  116. package/dist/telemetry-CWwsh-Gx.d.ts.map +1 -0
  117. package/dist/{wire-BKxH1vbP.js → wire-CV-Z7wz-.js} +6 -5
  118. package/dist/wire-CV-Z7wz-.js.map +1 -0
  119. package/docs/01-quickstart.mdx +21 -15
  120. package/docs/02-examples.mdx +1 -1
  121. package/docs/actors/01-introduction.mdx +13 -2
  122. package/docs/actors/02-concurrency.mdx +1 -1
  123. package/docs/agents/01-introduction.mdx +1 -1
  124. package/docs/agents/02-tools.mdx +12 -11
  125. package/docs/agents/03-conversations.mdx +36 -25
  126. package/docs/agents/04-harnesses.mdx +14 -7
  127. package/docs/client/03-presence.mdx +18 -14
  128. package/docs/contract/01-events.mdx +2 -2
  129. package/docs/contract/02-state.mdx +18 -14
  130. package/docs/contract/03-sessions.mdx +32 -25
  131. package/docs/index.mdx +29 -24
  132. package/docs/reference/01-core.mdx +7 -6
  133. package/docs/reference/02-server.mdx +250 -191
  134. package/docs/reference/04-ai.mdx +33 -23
  135. package/docs/reference/05-telemetry.mdx +27 -13
  136. package/docs/reference/06-errors.mdx +25 -19
  137. package/docs/reference/meta.ts +1 -1
  138. package/docs/schedulers/01-overview.mdx +8 -8
  139. package/docs/schedulers/02-vercel-queues.mdx +2 -2
  140. package/docs/schedulers/03-qstash.mdx +4 -4
  141. package/docs/server/01-consumers.mdx +418 -0
  142. package/docs/server/02-timers.mdx +31 -21
  143. package/docs/server/03-cancellation.mdx +52 -44
  144. package/docs/server/04-routes.mdx +4 -1
  145. package/docs/server/05-sql-projections.mdx +391 -0
  146. package/docs/server/06-guarantees.mdx +61 -51
  147. package/docs/stores/01-overview.mdx +3 -3
  148. package/docs/stores/02-redis.mdx +24 -10
  149. package/docs/stores/03-redis-http.mdx +8 -6
  150. package/docs/stores/04-postgres.mdx +65 -14
  151. package/docs/stores/05-sqlite.mdx +5 -3
  152. package/docs/upgrades/01-overview.mdx +31 -0
  153. package/docs/upgrades/78-0-22.mdx +115 -0
  154. package/docs/upgrades/79-0-21.mdx +165 -0
  155. package/docs/upgrades/80-0-20.mdx +43 -0
  156. package/docs/upgrades/81-0-19.mdx +33 -0
  157. package/docs/upgrades/82-0-18.mdx +33 -0
  158. package/docs/upgrades/83-0-17.mdx +69 -0
  159. package/docs/upgrades/meta.ts +3 -0
  160. package/examples/playground/DEPLOY.md +39 -7
  161. package/examples/playground/app/a0/chat.test.tsx +2 -2
  162. package/examples/playground/app/a0/sandbox-log.ts +17 -15
  163. package/examples/playground/app/a0/sandbox.ts +58 -13
  164. package/examples/playground/app/a0/server.ts +4 -4
  165. package/examples/playground/app/a2slack/a2slack.css +1067 -0
  166. package/examples/playground/app/a2slack/c/[channel]/page.tsx +20 -0
  167. package/examples/playground/app/a2slack/consumers/agent.ts +63 -0
  168. package/examples/playground/app/a2slack/consumers/feed.ts +164 -0
  169. package/examples/playground/app/a2slack/consumers/notify.ts +116 -0
  170. package/examples/playground/app/a2slack/consumers/search.ts +37 -0
  171. package/examples/playground/app/a2slack/consumers/triage.ts +31 -0
  172. package/examples/playground/app/a2slack/consumers/welcome.ts +25 -0
  173. package/examples/playground/app/a2slack/db/a2.ts +209 -0
  174. package/examples/playground/app/a2slack/db/index.ts +73 -0
  175. package/examples/playground/app/a2slack/db/migrations/0000_extensions.sql +4 -0
  176. package/examples/playground/app/a2slack/db/migrations/0001_workspace.sql +74 -0
  177. package/examples/playground/app/a2slack/db/migrations/0002_a2.sql +215 -0
  178. package/examples/playground/app/a2slack/db/migrations/0003_a2_batch.sql +67 -0
  179. package/examples/playground/app/a2slack/db/migrations/0004_a2_session_event_ids.sql +2 -0
  180. package/examples/playground/app/a2slack/db/migrations/meta/0000_snapshot.json +18 -0
  181. package/examples/playground/app/a2slack/db/migrations/meta/0001_snapshot.json +500 -0
  182. package/examples/playground/app/a2slack/db/migrations/meta/0002_snapshot.json +1159 -0
  183. package/examples/playground/app/a2slack/db/migrations/meta/0003_snapshot.json +1159 -0
  184. package/examples/playground/app/a2slack/db/migrations/meta/0004_snapshot.json +1165 -0
  185. package/examples/playground/app/a2slack/db/migrations/meta/_journal.json +41 -0
  186. package/examples/playground/app/a2slack/db/queries.ts +263 -0
  187. package/examples/playground/app/a2slack/db/schema.ts +145 -0
  188. package/examples/playground/app/a2slack/drills/a2slack.drill.ts +708 -0
  189. package/examples/playground/app/a2slack/drills/harness.ts +244 -0
  190. package/examples/playground/app/a2slack/events/route.ts +36 -0
  191. package/examples/playground/app/a2slack/identity.ts +44 -0
  192. package/examples/playground/app/a2slack/inbox/route.ts +8 -0
  193. package/examples/playground/app/a2slack/lag/data/route.ts +8 -0
  194. package/examples/playground/app/a2slack/lag/page.tsx +101 -0
  195. package/examples/playground/app/a2slack/lag.ts +73 -0
  196. package/examples/playground/app/a2slack/layout.tsx +43 -0
  197. package/examples/playground/app/a2slack/messages/route.ts +17 -0
  198. package/examples/playground/app/a2slack/model.test.ts +208 -0
  199. package/examples/playground/app/a2slack/model.ts +369 -0
  200. package/examples/playground/app/a2slack/models.test.ts +72 -0
  201. package/examples/playground/app/a2slack/page.tsx +10 -0
  202. package/examples/playground/app/a2slack/scheduler/route.ts +5 -0
  203. package/examples/playground/app/a2slack/search/route.ts +40 -0
  204. package/examples/playground/app/a2slack/search-index.ts +108 -0
  205. package/examples/playground/app/a2slack/server.test.ts +315 -0
  206. package/examples/playground/app/a2slack/server.ts +55 -0
  207. package/examples/playground/app/a2slack/sessions.ts +50 -0
  208. package/examples/playground/app/a2slack/thread/route.ts +8 -0
  209. package/examples/playground/app/a2slack/ui/ChannelView.tsx +87 -0
  210. package/examples/playground/app/a2slack/ui/Composer.tsx +117 -0
  211. package/examples/playground/app/a2slack/ui/DotsShadow.tsx +42 -0
  212. package/examples/playground/app/a2slack/ui/Feed.tsx +157 -0
  213. package/examples/playground/app/a2slack/ui/InboxContext.tsx +63 -0
  214. package/examples/playground/app/a2slack/ui/InboxView.tsx +82 -0
  215. package/examples/playground/app/a2slack/ui/Menu.tsx +128 -0
  216. package/examples/playground/app/a2slack/ui/MessageRow.tsx +314 -0
  217. package/examples/playground/app/a2slack/ui/NameGate.tsx +35 -0
  218. package/examples/playground/app/a2slack/ui/SearchContext.tsx +146 -0
  219. package/examples/playground/app/a2slack/ui/SearchResults.tsx +97 -0
  220. package/examples/playground/app/a2slack/ui/Shell.tsx +34 -0
  221. package/examples/playground/app/a2slack/ui/Sidebar.tsx +199 -0
  222. package/examples/playground/app/a2slack/ui/ThreadPanel.tsx +134 -0
  223. package/examples/playground/app/a2slack/ui/useInbox.ts +71 -0
  224. package/examples/playground/app/agent/server.ts +3 -3
  225. package/examples/playground/app/chat/[chatId]/chat-client.tsx +1 -1
  226. package/examples/playground/app/chat/server.ts +45 -35
  227. package/examples/playground/app/components/demo-icons.tsx +7 -0
  228. package/examples/playground/app/components/demos.tsx +7 -0
  229. package/examples/playground/app/orders/[orderId]/order-client.tsx +2 -2
  230. package/examples/playground/app/orders/page.tsx +1 -1
  231. package/examples/playground/app/orders/server.ts +49 -30
  232. package/examples/playground/app/recovery/[recoveryId]/recovery-client.tsx +14 -15
  233. package/examples/playground/app/recovery/page.tsx +4 -4
  234. package/examples/playground/app/recovery/server.ts +23 -32
  235. package/examples/playground/drizzle.config.ts +14 -0
  236. package/examples/playground/lib/store.ts +1 -1
  237. package/examples/playground/next.config.ts +6 -1
  238. package/examples/playground/package.json +12 -1
  239. package/examples/playground/scripts/migrate.mjs +31 -0
  240. package/examples/playground/vercel.json +9 -0
  241. package/examples/playground/vitest.drills.config.ts +32 -0
  242. package/package.json +5 -2
  243. package/sql/postgres/drizzle-schema.ts +209 -0
  244. package/sql/postgres/schema.sql +227 -67
  245. package/src/actor-shared.ts +10 -2
  246. package/src/actor.ts +101 -71
  247. package/src/ai-agent.ts +162 -111
  248. package/src/ai-control-server.ts +8 -8
  249. package/src/ai-fx.ts +324 -82
  250. package/src/ai-harness.ts +17 -2
  251. package/src/ai-pi.ts +2 -2
  252. package/src/ai-server.ts +2 -2
  253. package/src/ai.ts +7 -7
  254. package/src/client.ts +19 -2
  255. package/src/contract.ts +1 -1
  256. package/src/deterministic-id.ts +27 -4
  257. package/src/errors.ts +8 -5
  258. package/src/idempotent-replay.ts +0 -1
  259. package/src/index.ts +1 -1
  260. package/src/internal.ts +57 -16
  261. package/src/otel.ts +1 -1
  262. package/src/postgres-schema.ts +98 -57
  263. package/src/presence-recovery.ts +18 -11
  264. package/src/presence-tombstones.ts +52 -0
  265. package/src/push-envelope.ts +20 -0
  266. package/src/runtime-consumer.ts +10 -0
  267. package/src/scheduler-task.ts +2 -2
  268. package/src/scheduler-vercel.ts +1 -1
  269. package/src/server.browser.ts +1 -1
  270. package/src/server.ts +819 -218
  271. package/src/session-socket.ts +1 -1
  272. package/src/store-codec.ts +66 -0
  273. package/src/store-memory.ts +604 -286
  274. package/src/store-postgres.ts +2006 -857
  275. package/src/store-presence-polling.ts +59 -34
  276. package/src/store-redis-core.ts +1961 -1333
  277. package/src/store-redis-notify.ts +76 -26
  278. package/src/store-redis.ts +2 -3
  279. package/src/store-sqlite.ts +915 -516
  280. package/src/store.ts +241 -102
  281. package/src/telemetry.ts +2 -1
  282. package/src/wire.ts +1 -0
  283. package/dist/actor-C7b-1arn.d.ts.map +0 -1
  284. package/dist/actor-shared-t7w8xIKP.js.map +0 -1
  285. package/dist/ai-harness-Clbg5ei7.js.map +0 -1
  286. package/dist/ai-mMiWul1T.js.map +0 -1
  287. package/dist/client-BhHLoNKF.js.map +0 -1
  288. package/dist/contract-BYKASlCV.js.map +0 -1
  289. package/dist/idempotent-replay-JdgMQRRm.js +0 -21
  290. package/dist/idempotent-replay-JdgMQRRm.js.map +0 -1
  291. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +0 -1
  292. package/dist/internal-CscSY7K_.js.map +0 -1
  293. package/dist/server-DYAATFuT.d.ts.map +0 -1
  294. package/dist/server-qyb5aJJG.js.map +0 -1
  295. package/dist/session-clear-C-QwTXV1.d.ts.map +0 -1
  296. package/dist/session-clear-DGI4jTQ5.js.map +0 -1
  297. package/dist/store-CyLUy86v.d.ts +0 -404
  298. package/dist/store-CyLUy86v.d.ts.map +0 -1
  299. package/dist/store-codec-DTG0Ftek.js +0 -10
  300. package/dist/store-codec-DTG0Ftek.js.map +0 -1
  301. package/dist/store-presence-polling-B1ixeqIS.js +0 -94
  302. package/dist/store-presence-polling-B1ixeqIS.js.map +0 -1
  303. package/dist/store-redis-notify-CM0AfLY0.js +0 -2544
  304. package/dist/store-redis-notify-CM0AfLY0.js.map +0 -1
  305. package/dist/stream-buffer-Cx7AAPGq.js +0 -23
  306. package/dist/stream-buffer-Cx7AAPGq.js.map +0 -1
  307. package/dist/telemetry-Cyfqo6Qt.d.ts.map +0 -1
  308. package/dist/wire-BKxH1vbP.js.map +0 -1
  309. package/docs/server/01-handlers.mdx +0 -227
@@ -10,16 +10,34 @@
10
10
  *
11
11
  * Semantics mirror the sqlite backend: `index` is stored as `idx`,
12
12
  * timestamps are epoch milliseconds written from the injected clock —
13
- * never SQL `now()`. Streams poll by default; an optional direct
14
- * connection enables LISTEN/NOTIFY for events and presence. Appends serialize per session via
13
+ * never SQL `now()`, and dispatch state lives on consumer cursors, not on
14
+ * events. Streams poll by default; an optional direct connection enables
15
+ * LISTEN/NOTIFY for events and presence. Appends serialize per session via
15
16
  * `pg_advisory_xact_lock`, so concurrent writers can't race the index
16
- * assignment.
17
+ * assignment, and each runs in one round trip (`a2_batch`, below), so the
18
+ * lock is held only while the server runs it.
19
+ *
20
+ * Lock discipline. Completions lock their cursor first, then the session lock
21
+ * when they append, or when their cursor goes idle (`completeAttempt`);
22
+ * everything else that takes the session lock takes it first. Every
23
+ * operation that locks a cursor row locks it only in the exact state it acts
24
+ * on (a head, an attempt). Under the session lock, an append never writes a
25
+ * cursor row that exists: a lane or log cursor's runs are rows keyed by the
26
+ * head each run started at (its epoch), so starting an idle cursor inserts a
27
+ * new run and retires the idle row with `skip locked`. An append therefore
28
+ * never waits on a cursor row, in particular not on one a completion waiting
29
+ * for the session lock holds, or one a clear left behind.
17
30
  */
18
31
 
19
32
  import { A2Error, supersededAttemptError } from './errors.ts'
20
33
  import { idempotentReplay } from './idempotent-replay.ts'
21
34
  import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
22
- import { decodeReturnedEventIds } from './store-codec.ts'
35
+ import {
36
+ cursorKeyOf,
37
+ decodeCause,
38
+ decodeReturnedEventIds,
39
+ idleLogCursor,
40
+ } from './store-codec.ts'
23
41
  import { pollingStream, defaultSleep, type Sleeper } from './store-polling.ts'
24
42
  import { NOTIFY_TIMINGS, nullProtoRecord } from './internal.ts'
25
43
  import type { PresencePatch } from './contract.ts'
@@ -29,7 +47,11 @@ import {
29
47
  postgresNotificationChannel,
30
48
  type PostgresListenerConnection,
31
49
  } from './postgres-notifications.ts'
32
- import { checkPostgresSchema } from './postgres-schema.ts'
50
+ import {
51
+ checkPostgresTriggers,
52
+ explainingPostgresClient,
53
+ explainPostgresError,
54
+ } from './postgres-schema.ts'
33
55
  import { retryableLazy } from './retryable-lazy.ts'
34
56
  import { postgresNotificationScope } from './postgres-notification-scope.ts'
35
57
  import { postgresPoolClient } from './postgres-pool.ts'
@@ -43,10 +65,18 @@ import {
43
65
  RANDOM_IDS,
44
66
  SYSTEM_CLOCK,
45
67
  type A2Store,
68
+ type AppendEvent,
46
69
  type Clock,
70
+ type CompleteAttemptResult,
47
71
  type Event,
48
72
  type EventCause,
73
+ type HeadDispatch,
74
+ type HeadEvent,
75
+ type HeadRef,
49
76
  type IdSource,
77
+ type ReturnedEvent,
78
+ type StoreConsumer,
79
+ type StoredCursor,
50
80
  type StoredEvent,
51
81
  type StoreStateReadRequest,
52
82
  type StoreStateRead,
@@ -94,6 +124,9 @@ export type PostgresStore = A2Store & {
94
124
 
95
125
  const asNumber = (value: unknown): number => Number(value) // bigint columns arrive as strings
96
126
 
127
+ const asNullableNumber = (value: unknown): number | null =>
128
+ value === null || value === undefined ? null : asNumber(value)
129
+
97
130
  /**
98
131
  * The session's latest clear marker, or 0, as a primary-key subquery. Read
99
132
  * it in a statement that runs after the operation's lock: a statement's
@@ -103,86 +136,232 @@ const asNumber = (value: unknown): number => Number(value) // bigint columns arr
103
136
  const clearIndexOf = (session: string): string =>
104
137
  `coalesce((select clear_index from a2_clears where session_id = ${session}), 0)`
105
138
 
139
+ /**
140
+ * Whether a served consumer has a cursor with a head in the session. A null
141
+ * `served` array serves every consumer.
142
+ */
143
+ const pendingIn = (session: string, served: string): string =>
144
+ `exists(
145
+ select 1 from a2_cursors pending
146
+ where pending.session_id = ${session}
147
+ and pending.head > ${clearIndexOf(session)}
148
+ and pending.failed_at is null
149
+ and (${served}::text[] is null or pending.consumer = any(${served}::text[]))
150
+ )`
151
+
152
+ /** A fresh head's dispatch columns, for `set` clauses. */
153
+ const FRESH_DISPATCH = `
154
+ attempt_count = 0, first_claimed_at = null, last_claimed_at = null,
155
+ claim_holder = null, claim_expires_at = null, failure_count = 0,
156
+ last_failed_at = null, last_failed_attempt = null, last_error = null,
157
+ failed_at = null`
158
+
159
+ /** The head event's columns a claim, dead letter, or listing joins in, prefixed `e_`. */
160
+ const HEAD_COLUMNS = `
161
+ event.idx as e_idx, event.event_type as e_event_type,
162
+ event.payload as e_payload, event.event_id as e_event_id,
163
+ event.created_at as e_created_at, event.cause as e_cause`
164
+
106
165
  const RECLAIM_BATCH = 256
107
166
 
108
- const toDate = (ms: unknown): Date | null =>
109
- ms === null || ms === undefined ? null : new Date(asNumber(ms))
167
+ /**
168
+ * A cursor run reclaim deletes once the session is cleared through `$2`:
169
+ * one from before the clear, or one a later run of its cursor replaced. A
170
+ * log cursor's last run stays: it records that the consumer counts from the
171
+ * marker.
172
+ */
173
+ const SUPERSEDED_CURSOR = `
174
+ cur.session_id = $1 and (
175
+ (cur.epoch < $2 and cur.kind <> 'log')
176
+ or ((cur.head is null or cur.head <= $2) and exists (
177
+ select 1 from a2_cursors later
178
+ where later.session_id = cur.session_id
179
+ and later.cursor_key = cur.cursor_key
180
+ and later.consumer = cur.consumer
181
+ and later.epoch > cur.epoch
182
+ ))
183
+ )`
110
184
 
111
- const toCause = (raw: unknown): EventCause | null => {
112
- if (raw === null || raw === undefined) return null
113
- const value = (typeof raw === 'string' ? JSON.parse(raw) : raw) as {
114
- index?: unknown
115
- attempt?: unknown
116
- batchSize?: unknown
117
- }
118
- if (
119
- !Number.isInteger(value.index) ||
120
- Number(value.index) < 1 ||
121
- !Number.isInteger(value.attempt) ||
122
- Number(value.attempt) < 1
123
- ) {
124
- throw new TypeError('stored event has an invalid cause')
125
- }
126
- if (
127
- value.batchSize !== undefined &&
128
- (!Number.isInteger(value.batchSize) || Number(value.batchSize) < 1)
129
- ) {
130
- throw new TypeError('stored event has an invalid cause')
131
- }
132
- return {
133
- index: Number(value.index),
134
- attempt: Number(value.attempt),
135
- ...(value.batchSize === undefined
136
- ? {}
137
- : { batchSize: Number(value.batchSize) }),
138
- }
185
+ /**
186
+ * A value of `a2_batch`'s one parameter, `$1`: a JSON object the store builds
187
+ * for each call. JSON null reads as SQL null.
188
+ */
189
+ const param = {
190
+ text: (key: string): string => `($1->>'${key}')`,
191
+ bigint: (key: string): string => `($1->>'${key}')::bigint`,
192
+ int: (key: string): string => `($1->>'${key}')::int`,
193
+ boolean: (key: string): string => `($1->>'${key}')::boolean`,
194
+ jsonb: (key: string): string => `nullif($1->'${key}', 'null'::jsonb)`,
195
+ /** A JSON array of strings as text[], or null when the value isn't an array. */
196
+ texts: (key: string): string =>
197
+ `(case when jsonb_typeof($1->'${key}') = 'array'
198
+ then array(select jsonb_array_elements_text($1->'${key}')) end)`,
139
199
  }
140
200
 
141
- const toStored = (row: Record<string, unknown>): StoredEvent => ({
201
+ /** Every batch names its session. */
202
+ const SESSION = param.text('session')
203
+
204
+ /**
205
+ * The next event after `after` a log cursor takes, as a scalar subquery:
206
+ * `types` is its JSON array, or null for every type but the clear marker
207
+ * (`clearType`). Each listed type is one probe of `a2_events_types`, so a
208
+ * consumer of rare types never scans the events it skips.
209
+ */
210
+ const nextLogHead = ({
211
+ types,
212
+ after,
213
+ clearType = param.text('clearType'),
214
+ }: {
215
+ types: string
216
+ after: string
217
+ clearType?: string
218
+ }): string => `
219
+ case when ${types} is null then (
220
+ select event.idx from a2_events event
221
+ where event.session_id = ${SESSION} and event.idx > ${after}
222
+ and event.event_type <> ${clearType}
223
+ order by event.idx limit 1
224
+ ) else (
225
+ select min(next.idx)
226
+ from jsonb_array_elements_text(${types}) as wanted(event_type)
227
+ cross join lateral (
228
+ select event.idx from a2_events event
229
+ where event.session_id = ${SESSION} and event.event_type = wanted.event_type
230
+ and event.idx > ${after}
231
+ order by event.idx limit 1
232
+ ) next
233
+ where wanted.event_type <> ${clearType}
234
+ ) end`
235
+
236
+ /** The latest run of `target`'s cursor, as a lateral subquery. */
237
+ const LATEST_RUN = (target: string): string => `
238
+ select cur.epoch, cur.position, cur.head from a2_cursors cur
239
+ where cur.session_id = ${SESSION} and cur.cursor_key = ${target}.cursor_key
240
+ and cur.consumer = ${target}.consumer
241
+ order by cur.epoch desc limit 1`
242
+
243
+ /**
244
+ * Retires the runs `target` replaces, those idle or superseded by the clear
245
+ * marker `clearIndex`, skipping any another operation holds: a later reclaim
246
+ * deletes those.
247
+ */
248
+ const RETIRE_RUNS = (target: string, clearIndex: string): string => `
249
+ idle as materialized (
250
+ select cur.ctid from a2_cursors cur
251
+ join ${target} on cur.cursor_key = ${target}.cursor_key
252
+ and cur.consumer = ${target}.consumer
253
+ where cur.session_id = ${SESSION}
254
+ and (cur.head is null or cur.head <= ${clearIndex})
255
+ for update of cur skip locked
256
+ ), retired as (
257
+ delete from a2_cursors old using idle where old.ctid = idle.ctid
258
+ )`
259
+
260
+ type Row = Record<string, unknown>
261
+
262
+ const toDate = (ms: unknown): Date | null =>
263
+ ms === null || ms === undefined ? null : new Date(asNumber(ms))
264
+
265
+ const toEvent = (row: Row): Event => ({
142
266
  id: row['event_id'] as string,
143
267
  type: row['event_type'] as string,
144
268
  payload: row['payload'],
145
269
  index: asNumber(row['idx']),
146
270
  sessionId: row['session_id'] as string,
147
271
  createdAt: new Date(asNumber(row['created_at'])),
148
- cause: toCause(row['cause']),
272
+ })
273
+
274
+ const toStored = (row: Row): StoredEvent => ({
275
+ ...toEvent(row),
276
+ cause: decodeCause(row['cause']),
277
+ })
278
+
279
+ const toDispatch = (row: Row): HeadDispatch => ({
280
+ consumer: row['consumer'] as string,
149
281
  lane: (row['lane'] as string | null) ?? null,
150
- processedAt: toDate(row['processed_at']),
151
- processedByAttempt:
152
- row['processed_by_attempt'] === null ||
153
- row['processed_by_attempt'] === undefined
154
- ? null
155
- : asNumber(row['processed_by_attempt']),
156
- returnedEventIds:
157
- row['returned_event_ids'] === null ||
158
- row['returned_event_ids'] === undefined
159
- ? null
160
- : decodeReturnedEventIds(row['returned_event_ids']),
161
282
  firstClaimedAt: toDate(row['first_claimed_at']),
162
283
  lastClaimedAt: toDate(row['last_claimed_at']),
163
284
  attemptCount: asNumber(row['attempt_count']),
285
+ claimHolder: (row['claim_holder'] as string | null) ?? null,
286
+ claimExpiresAt: toDate(row['claim_expires_at']),
164
287
  failureCount: asNumber(row['failure_count']),
165
288
  lastFailedAt: toDate(row['last_failed_at']),
166
- lastFailedAttempt:
167
- row['last_failed_attempt'] === null ||
168
- row['last_failed_attempt'] === undefined
169
- ? null
170
- : asNumber(row['last_failed_attempt']),
289
+ lastFailedAttempt: asNullableNumber(row['last_failed_attempt']),
171
290
  lastError: (row['last_error'] as string | null) ?? null,
172
291
  failedAt: toDate(row['failed_at']),
173
- claimHolder: (row['claim_holder'] as string | null) ?? null,
174
- claimExpiresAt: toDate(row['claim_expires_at']),
175
292
  })
176
293
 
177
- const toEvent = (row: Record<string, unknown>): Event => ({
178
- id: row['event_id'] as string,
179
- type: row['event_type'] as string,
180
- payload: row['payload'],
181
- index: asNumber(row['idx']),
182
- sessionId: row['session_id'] as string,
183
- createdAt: new Date(asNumber(row['created_at'])),
294
+ const freshDispatch = (
295
+ consumer: string,
296
+ lane: string | null,
297
+ ): HeadDispatch => ({
298
+ consumer,
299
+ lane,
300
+ firstClaimedAt: null,
301
+ lastClaimedAt: null,
302
+ attemptCount: 0,
303
+ claimHolder: null,
304
+ claimExpiresAt: null,
305
+ failureCount: 0,
306
+ lastFailedAt: null,
307
+ lastFailedAttempt: null,
308
+ lastError: null,
309
+ failedAt: null,
310
+ })
311
+
312
+ /** A cursor row joined with its head event's `e_` columns. */
313
+ const toHead = (row: Row): HeadEvent => ({
314
+ ...toStored({
315
+ session_id: row['session_id'],
316
+ idx: row['e_idx'],
317
+ event_type: row['e_event_type'],
318
+ payload: row['e_payload'],
319
+ event_id: row['e_event_id'],
320
+ created_at: row['e_created_at'],
321
+ cause: row['e_cause'],
322
+ }),
323
+ ...toDispatch(row),
184
324
  })
185
325
 
326
+ /** A cursor run as `cursors()` reports it, given the session's clear marker. */
327
+ const toCursor = (row: Row, clearIndex: number): StoredCursor => {
328
+ const position = asNumber(row['position'])
329
+ if (row['kind'] !== 'log' || asNumber(row['epoch']) > clearIndex) {
330
+ return Object.assign(toDispatch(row), {
331
+ position,
332
+ head: asNullableNumber(row['head']),
333
+ })
334
+ }
335
+ // A clear supersedes a log cursor without touching its row: it counts
336
+ // from the marker, with nothing to run.
337
+ return Object.assign(
338
+ freshDispatch(row['consumer'] as string, row['lane'] as string),
339
+ { position: Math.max(position, clearIndex), head: null },
340
+ )
341
+ }
342
+
343
+ const compareText = (a: string, b: string): number =>
344
+ a < b ? -1 : a > b ? 1 : 0
345
+
346
+ const byHead = (a: HeadEvent, b: HeadEvent): number =>
347
+ a.index - b.index || compareText(a.consumer, b.consumer)
348
+
349
+ /** Consumer, then lane, with concurrent cursors last by head. */
350
+ const byCursor = (a: StoredCursor, b: StoredCursor): number =>
351
+ compareText(a.consumer, b.consumer) ||
352
+ (a.lane === null
353
+ ? b.lane === null
354
+ ? (a.head ?? 0) - (b.head ?? 0)
355
+ : 1
356
+ : b.lane === null
357
+ ? -1
358
+ : compareText(a.lane, b.lane))
359
+
360
+ const servedNames = (
361
+ consumers: readonly StoreConsumer[] | undefined,
362
+ ): string[] | null =>
363
+ consumers === undefined ? null : consumers.map((consumer) => consumer.name)
364
+
186
365
  const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
187
366
  try {
188
367
  return await fn()
@@ -234,20 +413,27 @@ const createListener = ({
234
413
  return listenerConnection
235
414
  })
236
415
 
237
- /** A dead-lettered event's snapshot pins, and history no pin keeps. */
416
+ type Query = PostgresConnection['query']
417
+
418
+ /** A finished or dead head's snapshot pins, and history no pin keeps. */
238
419
  const releasePins = async ({
239
420
  query,
240
421
  sessionId,
422
+ consumer,
423
+ cursorKey,
241
424
  index,
242
425
  }: {
243
- query: PostgresConnection['query']
426
+ query: Query
244
427
  sessionId: string
428
+ consumer: string
429
+ cursorKey: string
245
430
  index: number
246
431
  }): Promise<void> => {
247
432
  await query(
248
433
  `delete from a2_snapshot_pins
249
- where session_id = $1 and event_index = $2`,
250
- [sessionId, index],
434
+ where session_id = $1 and event_index = $2
435
+ and consumer = $3 and cursor_key = $4`,
436
+ [sessionId, index, consumer, cursorKey],
251
437
  )
252
438
  await query(
253
439
  `delete from a2_snapshot_history history
@@ -262,6 +448,921 @@ const releasePins = async ({
262
448
  )
263
449
  }
264
450
 
451
+ const lockSession = (query: Query, sessionId: string) =>
452
+ query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
453
+
454
+ /** Whether the session has an event at `index`. */
455
+ const hasEvent = async (
456
+ query: Query,
457
+ sessionId: string,
458
+ index: number,
459
+ ): Promise<boolean> =>
460
+ (
461
+ await query('select 1 from a2_events where session_id = $1 and idx = $2', [
462
+ sessionId,
463
+ index,
464
+ ])
465
+ ).rows.length > 0
466
+
467
+ /**
468
+ * The attempt-currency fence shared by consumer appends and clears, under
469
+ * the session lock. The cursor is read locked (`for share` by an append,
470
+ * `for update` by a clear, which then releases the head): it serializes
471
+ * with every writer of the cursor (claims, completions, failures), so a
472
+ * stale read cannot pass the fence against a takeover committing beside
473
+ * it. The lock is only taken while the head and attempt still match.
474
+ */
475
+ const assertCurrentCause = async ({
476
+ query,
477
+ sessionId,
478
+ cause,
479
+ clearIndex,
480
+ lock,
481
+ }: {
482
+ query: Query
483
+ sessionId: string
484
+ cause: EventCause
485
+ clearIndex: number
486
+ lock: 'share' | 'update'
487
+ }): Promise<void> => {
488
+ if (cause.index <= clearIndex) throw supersededAttemptError(sessionId, cause)
489
+ const { rows } = await query(
490
+ `select 1 from a2_cursors
491
+ where session_id = $1 and cursor_key = $2 and consumer = $3
492
+ and head = $4 and attempt_count = $5 and failed_at is null
493
+ for ${lock}`,
494
+ [
495
+ sessionId,
496
+ cursorKeyOf(cause.lane, cause.index),
497
+ cause.consumer,
498
+ cause.index,
499
+ cause.attempt,
500
+ ],
501
+ )
502
+ if (rows.length === 0) throw supersededAttemptError(sessionId, cause)
503
+ }
504
+
505
+ // ── one-round-trip operations ─────────────────────────────────────────
506
+ //
507
+ // An operation that writes under the session lock runs as one call of
508
+ // `a2_batch` (schema.sql): its statements, in order, in one transaction and
509
+ // one round trip, the lock held only while the server runs them. The function
510
+ // is what makes that correct. A statement's snapshot is taken before any lock
511
+ // it waits for, so a single statement that took the lock would still miss
512
+ // what the lock's last holder committed; inside the function each statement
513
+ // takes its snapshot when it starts. Every statement is a select of jsonb rows
514
+ // reading its values from `$1` (`param`). A row with `abort` fails the call and
515
+ // rolls it back (`abortedBatch`), and one with `stop` ends it there.
516
+
517
+ const BATCH = 'select r from a2_batch($1::text[], $2::jsonb) as r'
518
+
519
+ /**
520
+ * Each statement as a batch sends it, whitespace collapsed: a batch sends all
521
+ * its statements on every call. They hold no comments or spaced literals, and
522
+ * they're this module's constants, so the cache stays this size.
523
+ */
524
+ const compacted = new Map<string, string>()
525
+ const compact = (statement: string): string => {
526
+ let sent = compacted.get(statement)
527
+ if (sent === undefined) {
528
+ // A line comment would swallow the rest of the statement once it's one line.
529
+ if (statement.includes('--'))
530
+ throw new TypeError('a batch statement holds a comment')
531
+ sent = statement.replace(/\s+/g, ' ').trim()
532
+ compacted.set(statement, sent)
533
+ }
534
+ return sent
535
+ }
536
+
537
+ /** The SQLSTATE `a2_batch` raises an `abort` row with, as its detail. */
538
+ const BATCH_ABORTED = 'A2001'
539
+
540
+ /** The row an aborted `a2_batch` call raised, from `error` or its cause chain. */
541
+ const abortedBatch = (error: unknown): Row | undefined => {
542
+ for (let current = error, depth = 0; depth < 5; depth += 1) {
543
+ if (typeof current !== 'object' || current === null) return undefined
544
+ const { code, detail, cause } = current as {
545
+ code?: unknown
546
+ detail?: unknown
547
+ cause?: unknown
548
+ }
549
+ if (code === BATCH_ABORTED && typeof detail === 'string')
550
+ return JSON.parse(detail) as Row
551
+ current = cause
552
+ }
553
+ return undefined
554
+ }
555
+
556
+ const LOCK_SESSION = `
557
+ select jsonb_build_object('locked', true)
558
+ from pg_advisory_xact_lock(hashtext(${SESSION}))`
559
+
560
+ const INDEX = param.bigint('index')
561
+ const CONSUMER = param.text('consumer')
562
+
563
+ /**
564
+ * The index before the events this call inserted, the last `count` of the
565
+ * session's: read under the session lock, after the insert.
566
+ */
567
+ const INSERTED_AFTER = `(
568
+ select coalesce(max(idx), 0) - ${param.bigint('count')}
569
+ from a2_events where session_id = ${SESSION})`
570
+
571
+ /**
572
+ * An append's checks, under the session lock: the session's head and pending
573
+ * state, and what its supplied `ids` say. Ids are unique per session. When
574
+ * every event's id belongs to this session already, the batch is a retry: it
575
+ * stops with the committed rows. Some owned aborts, as does scheduled work
576
+ * from at or below the clear marker (`origin`). Rows below the marker own no
577
+ * id.
578
+ */
579
+ const APPEND_CHECKS = `
580
+ with boundary as materialized (
581
+ select coalesce(max(idx), 0) as base,
582
+ ${clearIndexOf(SESSION)} as clear_index,
583
+ ${pendingIn(SESSION, param.texts('served'))} as has_pending
584
+ from a2_events where session_id = ${SESSION}
585
+ ), owned as materialized (
586
+ select event.session_id, event.idx, event.event_type, event.payload,
587
+ event.event_id, event.created_at, event.cause
588
+ from a2_events event, boundary
589
+ where event.session_id = ${SESSION}
590
+ and event.event_id = any(${param.texts('ids')})
591
+ and event.idx >= boundary.clear_index
592
+ )
593
+ select case
594
+ when (select count(*) from owned) = ${param.bigint('count')} then
595
+ jsonb_build_object('stop', 'replay',
596
+ 'has_pending', boundary.has_pending,
597
+ 'events', (select jsonb_agg(to_jsonb(owned) order by owned.idx)
598
+ from owned))
599
+ when exists (select 1 from owned) then
600
+ jsonb_build_object('abort', 'mixed', 'owned',
601
+ (select count(*) from owned))
602
+ when ${param.bigint('origin')} <= boundary.clear_index then
603
+ jsonb_build_object('abort', 'cleared')
604
+ else jsonb_build_object('base', boundary.base,
605
+ 'has_pending', boundary.has_pending)
606
+ end
607
+ from boundary`
608
+
609
+ /**
610
+ * The attempt-currency fence of a consumer append, under the session lock.
611
+ * Each cause's cursor is read `for share`: that serializes with every writer
612
+ * of the cursor (claims, completions, failures), so a stale read cannot pass
613
+ * the fence against a takeover committing beside it. A row is locked only
614
+ * while its head and attempt still match. The first cause that fails aborts.
615
+ */
616
+ const FENCE = `
617
+ with cause as (
618
+ select * from jsonb_to_recordset($1->'causes')
619
+ as cause(ord int, consumer text, cursor_key text, idx bigint, attempt int)
620
+ ), held as materialized (
621
+ select cause.ord from cause
622
+ join a2_cursors cur
623
+ on cur.session_id = ${SESSION} and cur.cursor_key = cause.cursor_key
624
+ and cur.consumer = cause.consumer and cur.head = cause.idx
625
+ and cur.attempt_count = cause.attempt and cur.failed_at is null
626
+ where cause.idx > ${clearIndexOf(SESSION)}
627
+ for share of cur
628
+ )
629
+ select jsonb_build_object('abort', 'superseded', 'ord', cause.ord)
630
+ from cause where cause.ord not in (select ord from held)
631
+ order by cause.ord limit 1`
632
+
633
+ /**
634
+ * Frees the `ids` this session's rows below its clear marker hold, so the
635
+ * batch can take them. It never waits on a row: a reclaim deleting one may
636
+ * hold it, and one it skips aborts.
637
+ */
638
+ const REUSE_IDS = `
639
+ with stale as materialized (
640
+ select event.idx from a2_events event
641
+ where event.session_id = ${SESSION}
642
+ and event.event_id = any(${param.texts('ids')})
643
+ and event.idx < ${clearIndexOf(SESSION)}
644
+ order by event.idx
645
+ for update skip locked
646
+ ), deleted as (
647
+ delete from a2_events event using stale
648
+ where event.session_id = ${SESSION} and event.idx = stale.idx
649
+ returning event.idx
650
+ )
651
+ select jsonb_build_object('abort', 'held')
652
+ where (select count(*) from deleted) < (
653
+ select count(*) from a2_events event
654
+ where event.session_id = ${SESSION}
655
+ and event.event_id = any(${param.texts('ids')})
656
+ and event.idx < ${clearIndexOf(SESSION)})`
657
+
658
+ /** Inserts `events` after the session's head, under the session lock. */
659
+ const INSERT_EVENTS = `
660
+ with inserted as (
661
+ insert into a2_events
662
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
663
+ select ${SESSION}, base.base + event.ord, event.type,
664
+ coalesce(event.payload, 'null'::jsonb), event.id,
665
+ ${param.bigint('now')}, event.cause
666
+ from (select coalesce(max(idx), 0) as base from a2_events
667
+ where session_id = ${SESSION}) base,
668
+ jsonb_to_recordset($1->'events')
669
+ as event(ord bigint, type text, payload jsonb, id text, cause jsonb)
670
+ returning 1
671
+ )
672
+ select jsonb_build_object('inserted', count(*)) from inserted`
673
+
674
+ /** A cursor for each concurrent target: the event alone. */
675
+ const START_EVENTS = `
676
+ with started as (
677
+ insert into a2_cursors
678
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head)
679
+ select ${SESSION}, target.consumer, '#' || target.idx, target.idx, null,
680
+ 'event', target.idx - 1, target.idx
681
+ from (
682
+ select single.consumer, ${INSERTED_AFTER} + single.ord as idx
683
+ from jsonb_to_recordset($1->'singles')
684
+ as single(consumer text, ord bigint)
685
+ ) target
686
+ on conflict do nothing
687
+ returning 1
688
+ )
689
+ select jsonb_build_object('started', count(*)) from started`
690
+
691
+ /** Queues each lane target's event in its lane. */
692
+ const QUEUE_LANES = `
693
+ with queued as (
694
+ insert into a2_lane_events (session_id, consumer, lane, idx)
695
+ select ${SESSION}, queued.consumer, queued.lane,
696
+ ${INSERTED_AFTER} + queued.ord
697
+ from jsonb_to_recordset($1->'queued')
698
+ as queued(consumer text, lane text, ord bigint)
699
+ on conflict do nothing
700
+ returning 1
701
+ )
702
+ select jsonb_build_object('queued', count(*)) from queued`
703
+
704
+ /**
705
+ * Starts a run for each lane cursor the batch queues work for that has none
706
+ * running: one that is idle, superseded by the clear marker, or new. It starts
707
+ * at the lane's first event in the batch and counts from the marker.
708
+ */
709
+ const START_LANES = `
710
+ with boundary as materialized (
711
+ select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
712
+ ), target as (
713
+ select first.consumer, first.cursor_key, first.lane,
714
+ boundary.base + first.ord as idx
715
+ from boundary, jsonb_to_recordset($1->'lanes')
716
+ as first(consumer text, cursor_key text, lane text, ord bigint)
717
+ ), started as materialized (
718
+ select target.*, latest.position
719
+ from target
720
+ left join lateral (${LATEST_RUN('target')}) latest on true
721
+ where latest.head is null
722
+ or latest.head <= (select clear_index from boundary)
723
+ ), ${RETIRE_RUNS('started', '(select clear_index from boundary)')},
724
+ inserted as (
725
+ insert into a2_cursors
726
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head)
727
+ select ${SESSION}, started.consumer, started.cursor_key, started.idx,
728
+ started.lane, 'lane',
729
+ greatest(coalesce(started.position, 0),
730
+ (select clear_index from boundary)),
731
+ started.idx
732
+ from started
733
+ on conflict do nothing
734
+ returning 1
735
+ )
736
+ select jsonb_build_object('lanes', count(*)) from inserted`
737
+
738
+ /**
739
+ * Starts a run for each served log cursor the batch carries types for that
740
+ * has none running. A missing cursor starts at the batch's first event of its
741
+ * types, or at the beginning of the log with backfill. One that exists starts
742
+ * at the next event it takes after its position: a deployment that does not
743
+ * serve the consumer may have appended some while it was idle. A cursor from
744
+ * before the clear marker counts from it.
745
+ */
746
+ const WAKE_LOGS = `
747
+ with boundary as materialized (
748
+ select ${INSERTED_AFTER} as base, ${clearIndexOf(SESSION)} as clear_index
749
+ ), target as (
750
+ select log.consumer, log.cursor_key, log.lane,
751
+ boundary.base + log.ord as first, log.types, log.backfill
752
+ from boundary, jsonb_to_recordset($1->'logs')
753
+ as log(consumer text, cursor_key text, lane text, ord bigint,
754
+ types jsonb, backfill boolean)
755
+ ), woken as materialized (
756
+ select target.*,
757
+ case when latest.epoch is not null
758
+ then greatest(latest.position,
759
+ (select clear_index from boundary))
760
+ when target.backfill then (select clear_index from boundary)
761
+ else (select base from boundary) end as position,
762
+ latest.epoch is not null or target.backfill as resumes
763
+ from target
764
+ left join lateral (${LATEST_RUN('target')}) latest on true
765
+ where latest.head is null
766
+ or latest.head <= (select clear_index from boundary)
767
+ ), ${RETIRE_RUNS('woken', '(select clear_index from boundary)')}, run as (
768
+ select woken.*,
769
+ case when woken.resumes then coalesce(${nextLogHead({
770
+ types: 'woken.types',
771
+ after: 'woken.position',
772
+ })}, woken.first) else woken.first end as head
773
+ from woken
774
+ ), inserted as (
775
+ insert into a2_cursors
776
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head,
777
+ types)
778
+ select ${SESSION}, run.consumer, run.cursor_key, run.head, run.lane, 'log',
779
+ run.position, run.head, run.types
780
+ from run
781
+ on conflict do nothing
782
+ returning 1
783
+ )
784
+ select jsonb_build_object('logs', count(*)) from inserted`
785
+
786
+ /**
787
+ * The heads a claim may take, up to `through` when there is one, as
788
+ * `from ... where` for `cur`. `for update` makes a claim racing another wait
789
+ * and then re-check the row it waited for.
790
+ */
791
+ const ELIGIBLE = `
792
+ from a2_cursors cur
793
+ where cur.session_id = ${SESSION}
794
+ and cur.head > ${clearIndexOf(SESSION)}
795
+ and (${param.bigint('through')} is null
796
+ or cur.head <= ${param.bigint('through')})
797
+ and cur.failed_at is null
798
+ and (${param.texts('served')} is null
799
+ or cur.consumer = any(${param.texts('served')}))
800
+ and not exists (
801
+ select 1 from jsonb_to_recordset($1->'excluded')
802
+ as skip(consumer text, cursor_key text)
803
+ where skip.consumer = cur.consumer and skip.cursor_key = cur.cursor_key
804
+ )`
805
+
806
+ /** A lapsed claim's takeover that spends the last failed attempt dead-letters the head instead. */
807
+ const EXHAUSTED = `(cur.claim_holder is not null
808
+ and cur.failure_count + 1 >= ${param.int('maxFailures')})`
809
+
810
+ /**
811
+ * Claims every eligible head, returning each with its event. A head the claim
812
+ * waited on may have moved to an event its snapshot predates: that row comes
813
+ * back without one (`e_idx` null), for `claimAvailable` to read. It stops
814
+ * there unless one dead-lettered, whose pins `RELEASE_DEAD` then releases, or
815
+ * none was claimed, for `RETRY_AT`.
816
+ */
817
+ const CLAIM = `
818
+ with eligible as materialized (
819
+ select cur.consumer, cur.cursor_key, cur.epoch
820
+ ${ELIGIBLE}
821
+ and (cur.claim_expires_at is null
822
+ or cur.claim_expires_at <= ${param.bigint('now')})
823
+ order by cur.head, cur.consumer
824
+ for update of cur
825
+ ), claimed as (
826
+ update a2_cursors cur set
827
+ attempt_count = cur.attempt_count + 1,
828
+ failure_count = cur.failure_count
829
+ + case when cur.claim_holder is null then 0 else 1 end,
830
+ failed_at = case when ${EXHAUSTED} then ${param.bigint('now')}
831
+ else cur.failed_at end,
832
+ first_claimed_at = case when ${EXHAUSTED} then cur.first_claimed_at
833
+ else coalesce(cur.first_claimed_at, ${param.bigint('now')}) end,
834
+ last_claimed_at = case when ${EXHAUSTED} then cur.last_claimed_at
835
+ else ${param.bigint('now')} end,
836
+ claim_holder = case when ${EXHAUSTED} then null
837
+ else ${param.text('holder')} end,
838
+ claim_expires_at = case when ${EXHAUSTED} then null
839
+ else ${param.bigint('expiresAt')} end
840
+ from eligible
841
+ where cur.session_id = ${SESSION}
842
+ and cur.cursor_key = eligible.cursor_key
843
+ and cur.consumer = eligible.consumer
844
+ and cur.epoch = eligible.epoch
845
+ returning cur.*
846
+ ), heads as (
847
+ select claimed.*, ${HEAD_COLUMNS},
848
+ ${clearIndexOf(SESSION)} as clear_index,
849
+ (select coalesce(max(idx), 0) from a2_events
850
+ where session_id = ${SESSION}) as session_head
851
+ from claimed
852
+ left join a2_events event
853
+ on event.session_id = ${SESSION} and event.idx = claimed.head
854
+ )
855
+ select r from (
856
+ select 0 as ord, to_jsonb(heads) as r from heads
857
+ union all
858
+ select 1, jsonb_build_object('stop', 'claimed')
859
+ where exists (select 1 from heads)
860
+ and not exists (select 1 from heads where failed_at is not null)
861
+ ) claim
862
+ order by ord`
863
+
864
+ /**
865
+ * Releases the pins of the heads dead-lettered at `now`, the claim's among
866
+ * them, in a snapshot after the claim's locks, with the history no other pin
867
+ * keeps.
868
+ */
869
+ const RELEASE_DEAD = `
870
+ with dead as materialized (
871
+ select cur.consumer, cur.cursor_key, cur.head from a2_cursors cur
872
+ where cur.session_id = ${SESSION} and cur.head is not null
873
+ and cur.failed_at = ${param.bigint('now')} and cur.claim_holder is null
874
+ ), unpinned as (
875
+ delete from a2_snapshot_pins pin using dead
876
+ where pin.session_id = ${SESSION} and pin.event_index = dead.head
877
+ and pin.consumer = dead.consumer and pin.cursor_key = dead.cursor_key
878
+ returning 1
879
+ ), unkept as (
880
+ delete from a2_snapshot_history history
881
+ where history.session_id = ${SESSION}
882
+ and exists (select 1 from dead)
883
+ and not exists (
884
+ select 1 from a2_snapshot_pins pin
885
+ where pin.session_id = history.session_id
886
+ and pin.reducer_name = history.reducer_name
887
+ and pin.up_to_index = history.up_to_index
888
+ and not exists (
889
+ select 1 from dead
890
+ where pin.event_index = dead.head
891
+ and pin.consumer = dead.consumer
892
+ and pin.cursor_key = dead.cursor_key)
893
+ )
894
+ returning 1
895
+ )
896
+ select jsonb_build_object('released', (select count(*) from unpinned))`
897
+
898
+ /** When nothing was claimed: the earliest live claim or retry hold that blocks a head. */
899
+ const RETRY_AT = `
900
+ select jsonb_build_object('retry_at', min(cur.claim_expires_at))
901
+ ${ELIGIBLE}
902
+ and cur.claim_expires_at > ${param.bigint('now')}`
903
+
904
+ /** The cursor row `alias` whose head this attempt holds. */
905
+ const heldBy = (alias: string): string => `
906
+ ${alias}.session_id = ${SESSION}
907
+ and ${alias}.cursor_key = ${param.text('cursorKey')}
908
+ and ${alias}.consumer = ${CONSUMER} and ${alias}.head = ${INDEX}
909
+ and ${alias}.attempt_count = ${param.int('attempt')}
910
+ and ${alias}.failed_at is null and ${alias}.claim_holder is not null`
911
+
912
+ /**
913
+ * Locks the cursor while this attempt holds its head, and stops unless it
914
+ * does: a completion locks its cursor first. A cleared head is never locked.
915
+ */
916
+ const HOLD = `
917
+ with boundary as materialized (
918
+ select ${clearIndexOf(SESSION)} as clear_index
919
+ ), held as materialized (
920
+ select cur.kind from a2_cursors cur, boundary
921
+ where ${heldBy('cur')} and ${INDEX} > boundary.clear_index
922
+ for update of cur
923
+ )
924
+ select case
925
+ when ${INDEX} <= boundary.clear_index then
926
+ jsonb_build_object('stop', 'superseded')
927
+ when not exists (select 1 from held) then
928
+ jsonb_build_object('stop', 'unheld')
929
+ else jsonb_build_object('held', (select kind from held limit 1))
930
+ end
931
+ from boundary`
932
+
933
+ /** A log cursor's types once it moves: the served consumer's (`types`, when `retype`), else its own. */
934
+ const logTypesOf = (cur: string): string =>
935
+ `(case when ${param.boolean('retype')} then ${param.jsonb('types')}
936
+ else ${cur}.types end)`
937
+
938
+ /**
939
+ * The head cursor `cur` moves to from its head: its lane's next unfinished
940
+ * event, the next event of a log cursor's types, or none for a concurrent
941
+ * cursor. The lane's lookup is ordered by its whole key, so the lane's
942
+ * primary key serves it.
943
+ */
944
+ const nextHeadOf = (cur: string): string => `
945
+ case ${cur}.kind
946
+ when 'lane' then (
947
+ select lane.idx from a2_lane_events lane
948
+ where lane.session_id = ${SESSION} and lane.consumer = ${cur}.consumer
949
+ and lane.lane = ${cur}.lane and not lane.done
950
+ and lane.idx > ${cur}.head
951
+ order by lane.consumer, lane.lane, lane.idx limit 1)
952
+ when 'log' then (${nextLogHead({
953
+ types: logTypesOf(cur),
954
+ after: `${cur}.head`,
955
+ })})
956
+ end`
957
+
958
+ /**
959
+ * Takes the session lock when the held cursor would go idle: a lane or log
960
+ * cursor with no next head. See `completeAttempt` for why a completion that
961
+ * moves to a next head needs no lock.
962
+ */
963
+ const LOCK_IF_IDLE = `
964
+ select jsonb_build_object('locked',
965
+ pg_advisory_xact_lock(hashtext(${SESSION}))::text is not null)
966
+ from a2_cursors cur
967
+ where ${heldBy('cur')} and cur.kind <> 'event'
968
+ and (${nextHeadOf('cur')}) is null`
969
+
970
+ /**
971
+ * Moves the cursor `target` selects past its head `index`, if its head lies
972
+ * above the clear marker: marks the lane's event done (with this attempt's
973
+ * receipt, `receipt`), releases the head's snapshot pins with the history no
974
+ * other pin keeps, and gives the cursor its next head with fresh dispatch
975
+ * columns (a log or concurrent cursor keeps the receipt on the run). The
976
+ * marker and the next head are read in one snapshot, after the cursor's lock.
977
+ * Without a row it writes nothing, and stops if `stop`.
978
+ */
979
+ const advance = ({
980
+ target,
981
+ receipt,
982
+ missing,
983
+ }: {
984
+ target: string
985
+ receipt: boolean
986
+ missing: 'stop' | 'abort' | 'continue'
987
+ }): string => `
988
+ with target as materialized (
989
+ select cur.* from a2_cursors cur
990
+ where ${target} and ${INDEX} > ${clearIndexOf(SESSION)}
991
+ order by cur.cursor_key limit 1
992
+ for update of cur
993
+ ), done as (
994
+ update a2_lane_events lane set done = true${
995
+ receipt
996
+ ? `, completed_attempt = ${param.int('attempt')},
997
+ returned_event_ids = ${param.jsonb('ids')}`
998
+ : ''
999
+ }
1000
+ from target
1001
+ where target.kind = 'lane' and lane.session_id = ${SESSION}
1002
+ and lane.consumer = target.consumer and lane.lane = target.lane
1003
+ and lane.idx = target.head
1004
+ returning 1
1005
+ ), unpinned as (
1006
+ delete from a2_snapshot_pins pin using target
1007
+ where pin.session_id = ${SESSION} and pin.event_index = target.head
1008
+ and pin.consumer = target.consumer and pin.cursor_key = target.cursor_key
1009
+ returning 1
1010
+ ), unkept as (
1011
+ delete from a2_snapshot_history history using target
1012
+ where history.session_id = ${SESSION}
1013
+ and not exists (
1014
+ select 1 from a2_snapshot_pins pin
1015
+ where pin.session_id = history.session_id
1016
+ and pin.reducer_name = history.reducer_name
1017
+ and pin.up_to_index = history.up_to_index
1018
+ and not (pin.event_index = target.head
1019
+ and pin.consumer = target.consumer
1020
+ and pin.cursor_key = target.cursor_key)
1021
+ )
1022
+ returning 1
1023
+ ), moved as (
1024
+ update a2_cursors cur
1025
+ set position = target.head, head = ${nextHeadOf('target')},
1026
+ ${FRESH_DISPATCH},
1027
+ types = case when target.kind = 'log'
1028
+ then ${logTypesOf('target')} else cur.types end${
1029
+ receipt
1030
+ ? `,
1031
+ completed_idx = case when target.kind = 'lane'
1032
+ then cur.completed_idx else target.head end,
1033
+ completed_attempt = case when target.kind = 'lane'
1034
+ then cur.completed_attempt
1035
+ else ${param.int('attempt')} end,
1036
+ returned_event_ids = case when target.kind = 'lane'
1037
+ then cur.returned_event_ids
1038
+ else ${param.jsonb('ids')} end`
1039
+ : ''
1040
+ }
1041
+ from target
1042
+ where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
1043
+ and cur.consumer = target.consumer and cur.epoch = target.epoch
1044
+ returning cur.head
1045
+ )
1046
+ select case when exists (select 1 from moved)
1047
+ then jsonb_build_object('advanced', true)
1048
+ else jsonb_build_object(${
1049
+ missing === 'continue'
1050
+ ? `'advanced', false`
1051
+ : `'${missing}', 'superseded'`
1052
+ }) end`
1053
+
1054
+ /** Completing a held head, once the cursor's lock is taken. */
1055
+ const COMPLETE = advance({
1056
+ target: heldBy('cur'),
1057
+ receipt: true,
1058
+ missing: 'stop',
1059
+ })
1060
+
1061
+ /**
1062
+ * Completing a held head after appending its events, under the session lock:
1063
+ * the head can't have moved, but if it had, the events must not stand.
1064
+ */
1065
+ const COMPLETE_APPENDED = advance({
1066
+ target: heldBy('cur'),
1067
+ receipt: true,
1068
+ missing: 'abort',
1069
+ })
1070
+
1071
+ /**
1072
+ * A completion's checks before it appends, under the session lock, which the
1073
+ * cursor's lock preceded: a head the clear marker passed meanwhile stops, and
1074
+ * a returned id a live row owns aborts.
1075
+ */
1076
+ const RETURN_CHECKS = `
1077
+ select case
1078
+ when ${INDEX} <= ${clearIndexOf(SESSION)} then
1079
+ jsonb_build_object('stop', 'superseded')
1080
+ when exists (
1081
+ select 1 from a2_events event
1082
+ where event.session_id = ${SESSION}
1083
+ and event.event_id = any(${param.texts('ids')})
1084
+ and event.idx >= ${clearIndexOf(SESSION)}
1085
+ ) then jsonb_build_object('abort', 'owned')
1086
+ else jsonb_build_object('base', (
1087
+ select coalesce(max(idx), 0) from a2_events
1088
+ where session_id = ${SESSION}))
1089
+ end`
1090
+
1091
+ /** The consumer's dead-lettered head at `index`, locked. */
1092
+ const deadAt = (alias: string): string => `
1093
+ ${alias}.session_id = ${SESSION} and ${alias}.consumer = ${CONSUMER}
1094
+ and ${alias}.head = ${INDEX} and ${alias}.failed_at is not null`
1095
+
1096
+ /**
1097
+ * Locks the dead letter, or stops: the statements after it act only on the
1098
+ * row this one locked.
1099
+ */
1100
+ const LOCK_DEAD = `
1101
+ with dead as materialized (
1102
+ select cur.cursor_key from a2_cursors cur
1103
+ where ${deadAt('cur')} and ${INDEX} > ${clearIndexOf(SESSION)}
1104
+ order by cur.cursor_key limit 1
1105
+ for update
1106
+ )
1107
+ select case when exists (select 1 from dead)
1108
+ then jsonb_build_object('redriven', true)
1109
+ else jsonb_build_object('stop', 'alive', 'redriven', false,
1110
+ 'has_pending',
1111
+ ${pendingIn(SESSION, param.texts('served'))}) end`
1112
+
1113
+ /** Clears a dead letter, keeping its claim history and last failure. */
1114
+ const RETRY_DEAD = `
1115
+ with target as materialized (
1116
+ select cur.cursor_key, cur.epoch from a2_cursors cur
1117
+ where ${deadAt('cur')} and ${INDEX} > ${clearIndexOf(SESSION)}
1118
+ order by cur.cursor_key limit 1
1119
+ for update of cur
1120
+ ), retried as (
1121
+ update a2_cursors cur set failed_at = null, failure_count = 0
1122
+ from target
1123
+ where cur.session_id = ${SESSION} and cur.cursor_key = target.cursor_key
1124
+ and cur.consumer = ${CONSUMER} and cur.epoch = target.epoch
1125
+ returning 1
1126
+ )
1127
+ select jsonb_build_object('retried', count(*)) from retried`
1128
+
1129
+ const SKIP_DEAD = advance({
1130
+ target: deadAt('cur'),
1131
+ receipt: false,
1132
+ missing: 'continue',
1133
+ })
1134
+
1135
+ const PENDING = `
1136
+ select jsonb_build_object('has_pending',
1137
+ ${pendingIn(SESSION, param.texts('served'))})`
1138
+
1139
+ /**
1140
+ * One log cursor as `wakeLog` finds it: the session's head and clear marker,
1141
+ * the cursor's latest run, where a run would start (`start`), and the next
1142
+ * event it takes from there unless the run has a head. `retyped` says whether
1143
+ * the run's types are stale. CTEs, for a `with`.
1144
+ */
1145
+ const LOG_CURSOR = `
1146
+ boundary as materialized (
1147
+ select coalesce(max(idx), 0) as session_head,
1148
+ ${clearIndexOf(SESSION)} as clear_index
1149
+ from a2_events where session_id = ${SESSION}
1150
+ ), latest as materialized (
1151
+ select boundary.*, cur.*,
1152
+ case when cur.epoch is not null
1153
+ then greatest(cur.position, boundary.clear_index)
1154
+ when ${param.boolean('backfill')} then boundary.clear_index
1155
+ else boundary.session_head end as start
1156
+ from boundary
1157
+ left join lateral (
1158
+ select * from a2_cursors cur
1159
+ where cur.session_id = ${SESSION}
1160
+ and cur.cursor_key = ${param.text('cursorKey')}
1161
+ and cur.consumer = ${CONSUMER}
1162
+ order by cur.epoch desc limit 1
1163
+ ) cur on true
1164
+ ), found as materialized (
1165
+ select latest.*,
1166
+ latest.types is distinct from ${param.jsonb('types')} as retyped,
1167
+ case when latest.head > latest.clear_index then null
1168
+ else ${nextLogHead({
1169
+ types: param.jsonb('types'),
1170
+ after: 'latest.start',
1171
+ })} end as next_head
1172
+ from latest
1173
+ )`
1174
+
1175
+ /**
1176
+ * A run with a head gets no next one: it is left as it is, claim and all,
1177
+ * and so is an idle run with nothing new to take. Only their types change.
1178
+ */
1179
+ const KEEPS_RUN = 'found.epoch is not null and found.next_head is null'
1180
+
1181
+ /** No run yet, and nothing after the latest clear: none is started. */
1182
+ const NO_RUN_NEEDED =
1183
+ 'found.epoch is null and found.session_head = found.clear_index'
1184
+
1185
+ /** `wakeLog` without the lock: stops when there is nothing to write. */
1186
+ const READ_LOG = `
1187
+ with ${LOG_CURSOR}
1188
+ select case when ${KEEPS_RUN} and not found.retyped
1189
+ then jsonb_build_object('stop', 'unchanged',
1190
+ 'cursor', to_jsonb(found))
1191
+ when ${NO_RUN_NEEDED}
1192
+ then jsonb_build_object('stop', 'empty', 'empty', true,
1193
+ 'head', found.session_head)
1194
+ else jsonb_build_object('stale', true) end
1195
+ from found`
1196
+
1197
+ /**
1198
+ * `wakeLog` under the session lock. A kept run takes the consumer's types,
1199
+ * unless another operation holds its row: the session lock is held, so this
1200
+ * waits for none. Otherwise a run starts at `start`, retiring the runs it
1201
+ * replaces. One with a head is keyed by it; one with nothing to run is idle
1202
+ * at its position, as a run that finished its last head is, keyed by that
1203
+ * position so the next run, which starts after it, sorts later.
1204
+ */
1205
+ const WAKE_LOG = `
1206
+ with ${LOG_CURSOR}, held as materialized (
1207
+ select cur.ctid from a2_cursors cur, found
1208
+ where ${KEEPS_RUN} and found.retyped
1209
+ and cur.session_id = ${SESSION}
1210
+ and cur.cursor_key = ${param.text('cursorKey')}
1211
+ and cur.consumer = ${CONSUMER} and cur.epoch = found.epoch
1212
+ and cur.types is distinct from ${param.jsonb('types')}
1213
+ for update of cur skip locked
1214
+ ), retype as (
1215
+ update a2_cursors cur set types = ${param.jsonb('types')} from held
1216
+ where cur.ctid = held.ctid
1217
+ ), target as (
1218
+ select ${param.text('cursorKey')} as cursor_key, ${CONSUMER} as consumer
1219
+ from found where not (${KEEPS_RUN}) and not (${NO_RUN_NEEDED})
1220
+ ), ${RETIRE_RUNS('target', '(select clear_index from found)')}, started as (
1221
+ insert into a2_cursors
1222
+ (session_id, consumer, cursor_key, epoch, lane, kind, position, head,
1223
+ types)
1224
+ select ${SESSION}, target.consumer, target.cursor_key,
1225
+ coalesce(found.next_head, found.start), ${param.text('lane')}, 'log',
1226
+ found.start, found.next_head, ${param.jsonb('types')}
1227
+ from found, target
1228
+ returning *
1229
+ )
1230
+ select jsonb_build_object(
1231
+ 'cursor', coalesce((select to_jsonb(started) from started),
1232
+ to_jsonb(found)),
1233
+ 'head', found.session_head, 'clear_index', found.clear_index,
1234
+ 'empty', ${NO_RUN_NEEDED})
1235
+ from found`
1236
+
1237
+ /**
1238
+ * The statements that start the cursor work a batch of `events` carries,
1239
+ * with their parameters: each event by its position in the batch, from 1,
1240
+ * since its index is assigned under the lock. `started` says whether it
1241
+ * starts work for a served consumer.
1242
+ */
1243
+ const enqueue = (
1244
+ events: readonly AppendEvent[],
1245
+ consumers: readonly StoreConsumer[] | undefined,
1246
+ ): {
1247
+ statements: (string | false)[]
1248
+ params: Record<string, unknown>
1249
+ started: boolean
1250
+ } => {
1251
+ const served = consumers && new Set(consumers.map(({ name }) => name))
1252
+ let started = false
1253
+ const singles: { consumer: string; ord: number }[] = []
1254
+ const queued: { consumer: string; lane: string; ord: number }[] = []
1255
+ const lanes = new Map<
1256
+ string,
1257
+ { consumer: string; cursor_key: string; lane: string; ord: number }
1258
+ >()
1259
+ events.forEach((event, offset) => {
1260
+ const ord = offset + 1
1261
+ for (const target of event.targets ?? []) {
1262
+ if (!served || served.has(target.consumer)) started = true
1263
+ if (target.lane === null) {
1264
+ singles.push({ consumer: target.consumer, ord })
1265
+ continue
1266
+ }
1267
+ queued.push({ consumer: target.consumer, lane: target.lane, ord })
1268
+ const key = cursorKeyOf(target.lane, ord)
1269
+ const first = `${target.consumer}\u0000${key}`
1270
+ if (!lanes.has(first)) {
1271
+ lanes.set(first, {
1272
+ consumer: target.consumer,
1273
+ cursor_key: key,
1274
+ lane: target.lane,
1275
+ ord,
1276
+ })
1277
+ }
1278
+ }
1279
+ })
1280
+ const logs: {
1281
+ consumer: string
1282
+ cursor_key: string
1283
+ lane: string
1284
+ ord: number
1285
+ types: readonly string[] | null
1286
+ backfill: boolean
1287
+ }[] = []
1288
+ for (const consumer of consumers ?? []) {
1289
+ const log = consumer.log
1290
+ if (!log) continue
1291
+ const offset = events.findIndex(
1292
+ (event) =>
1293
+ event.type !== CLEAR_EVENT_TYPE &&
1294
+ (log.types === null || log.types.includes(event.type)),
1295
+ )
1296
+ if (offset === -1) continue
1297
+ logs.push({
1298
+ consumer: consumer.name,
1299
+ cursor_key: cursorKeyOf(log.lane, 0),
1300
+ lane: log.lane,
1301
+ ord: offset + 1,
1302
+ types: log.types,
1303
+ backfill: log.backfill,
1304
+ })
1305
+ }
1306
+ if (logs.length > 0) started = true
1307
+ return {
1308
+ statements: [
1309
+ singles.length > 0 && START_EVENTS,
1310
+ queued.length > 0 && QUEUE_LANES,
1311
+ queued.length > 0 && START_LANES,
1312
+ logs.length > 0 && WAKE_LOGS,
1313
+ ],
1314
+ params: {
1315
+ singles,
1316
+ queued,
1317
+ lanes: [...lanes.values()],
1318
+ logs,
1319
+ clearType: CLEAR_EVENT_TYPE,
1320
+ },
1321
+ started,
1322
+ }
1323
+ }
1324
+
1325
+ /**
1326
+ * The rows a batch inserted at `base + 1`, as the store returns them.
1327
+ * `events` are what the batch sent.
1328
+ */
1329
+ const insertedRows = ({
1330
+ sessionId,
1331
+ events,
1332
+ base,
1333
+ now,
1334
+ }: {
1335
+ sessionId: string
1336
+ events: readonly {
1337
+ id: string
1338
+ type: string
1339
+ payload: unknown
1340
+ cause: EventCause | null
1341
+ }[]
1342
+ base: number
1343
+ now: number
1344
+ }): StoredEvent[] =>
1345
+ events.map((event, offset) => ({
1346
+ id: event.id,
1347
+ type: event.type,
1348
+ payload: structuredClone(event.payload),
1349
+ index: base + 1 + offset,
1350
+ sessionId,
1351
+ createdAt: new Date(now),
1352
+ cause: event.cause ? { ...event.cause } : null,
1353
+ }))
1354
+
1355
+ /** A served log consumer's types, for a cursor that moves past its head. */
1356
+ const retypeOf = (
1357
+ consumer: string,
1358
+ consumers: readonly StoreConsumer[] | undefined,
1359
+ ): { retype: boolean; types: readonly string[] | null } => {
1360
+ const log = consumers?.find(({ name }) => name === consumer)?.log
1361
+ return log
1362
+ ? { retype: true, types: log.types }
1363
+ : { retype: false, types: null }
1364
+ }
1365
+
265
1366
  export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
266
1367
  const clock = options.clock ?? SYSTEM_CLOCK
267
1368
  const generateId = options.ids ?? RANDOM_IDS
@@ -301,29 +1402,23 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
301
1402
  },
302
1403
  })
303
1404
  }
304
- try {
305
- const listen = Boolean(options.listenConnectionString)
306
- const resource = lease.value
307
- await resource.initialize({
308
- key: listen ? 'schema+triggers' : 'schema',
309
- run: () => checkPostgresSchema({ client: resource.client, listen }),
310
- })
311
- return lease
312
- } catch (error) {
313
- if (!injected) await lease.release().catch(() => {})
314
- throw error
315
- }
1405
+ return { lease, client: explainingPostgresClient(lease.value.client) }
316
1406
  })
317
1407
  const client = async (): Promise<PostgresClient> => {
318
1408
  if (closed) throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
319
- const lease = await connection.get()
1409
+ const { client: explaining } = await connection.get()
320
1410
  if (closed) throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')
321
- return lease.value.client
1411
+ return explaining
322
1412
  }
323
1413
 
324
1414
  const notifications = options.listenConnectionString
325
1415
  ? postgresNotificationScope(async () => {
326
- await client()
1416
+ const c = await client()
1417
+ const resource = (await connection.get()).lease.value
1418
+ await resource.initialize({
1419
+ key: 'triggers',
1420
+ run: () => checkPostgresTriggers(c),
1421
+ })
327
1422
  const pg = await loadPg()
328
1423
  const connectionString = options.listenConnectionString!
329
1424
  const key = postgresConnectionKey({
@@ -458,9 +1553,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
458
1553
  const begin = notifications
459
1554
  ? `begin; set local a2.presence_source = '${instanceId}'`
460
1555
  : 'begin'
461
- const withTx = async <T>(
462
- fn: (query: PostgresConnection['query']) => Promise<T>,
463
- ): Promise<T> => {
1556
+ const withTx = async <T>(fn: (query: Query) => Promise<T>): Promise<T> => {
464
1557
  const c = await client()
465
1558
  if (c.connect) {
466
1559
  const conn = await c.connect()
@@ -476,12 +1569,16 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
476
1569
  conn.release()
477
1570
  }
478
1571
  }
479
- const resource = (await connection.get()).value
1572
+ const resource = (await connection.get()).lease.value
480
1573
  return resource.exclusive(async () => {
481
1574
  const raw = resource.raw
482
1575
  await raw.query(begin)
483
1576
  try {
484
- const result = await fn((text, values) => raw.query(text, values))
1577
+ const result = await fn((text, values) =>
1578
+ raw.query(text, values).catch((error: unknown) => {
1579
+ throw explainPostgresError(error)
1580
+ }),
1581
+ )
485
1582
  await raw.query('commit')
486
1583
  return result
487
1584
  } catch (err) {
@@ -491,54 +1588,37 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
491
1588
  })
492
1589
  }
493
1590
 
494
- type Query = PostgresConnection['query']
495
-
496
1591
  /**
497
- * The rows owning `ids`. This session's rows below its clear marker do
498
- * not own their ids: they are deleted here so the ids can be reused.
1592
+ * Runs `statements` (the falsy ones dropped) as one `a2_batch` call with
1593
+ * `params`, and the session's id as `session`. Its rows come back in order;
1594
+ * an aborted call throws what `abort` makes of the aborting row.
499
1595
  */
500
- const liveOwnersOf = async ({
501
- query,
1596
+ const run = async ({
502
1597
  sessionId,
503
- clearIndex,
504
- ids,
1598
+ statements,
1599
+ params,
1600
+ abort = () =>
1601
+ new A2Error('STORE_UNAVAILABLE', 'postgres store operation failed'),
505
1602
  }: {
506
- query: Query
507
1603
  sessionId: string
508
- clearIndex: number
509
- ids: readonly string[]
510
- }): Promise<Record<string, unknown>[]> => {
511
- if (ids.length === 0) return []
512
- const { rows } = await query(
513
- 'select * from a2_events where event_id = any($1::text[]) order by idx',
514
- [ids],
515
- )
516
- const stale = rows.filter(
517
- (row) =>
518
- row['session_id'] === sessionId && asNumber(row['idx']) < clearIndex,
519
- )
520
- if (stale.length === 0) return rows
521
- // Never wait on a stale row: cleared work holding it may itself be
522
- // waiting for the session lock this append holds.
523
- const { rows: deleted } = await query(
524
- `with stale as materialized (
525
- select idx from a2_events
526
- where session_id = $1 and event_id = any($2::text[])
527
- order by idx
528
- for update skip locked
529
- )
530
- delete from a2_events event using stale
531
- where event.session_id = $1 and event.idx = stale.idx
532
- returning event.idx`,
533
- [sessionId, stale.map((row) => row['event_id'])],
534
- )
535
- if (deleted.length < stale.length) {
536
- throw new A2Error(
537
- 'STORE_UNAVAILABLE',
538
- `an event id being reused is still held by cleared work in session '${sessionId}'`,
539
- )
1604
+ statements: readonly (string | false)[]
1605
+ params: Record<string, unknown>
1606
+ abort?: (reason: Row) => Error
1607
+ }): Promise<Row[]> => {
1608
+ const c = await client()
1609
+ try {
1610
+ const { rows } = await c.query(BATCH, [
1611
+ statements.flatMap((statement) =>
1612
+ statement === false ? [] : [compact(statement)],
1613
+ ),
1614
+ jsonb({ ...params, session: sessionId }),
1615
+ ])
1616
+ return rows.map((row) => row['r'] as Row)
1617
+ } catch (error) {
1618
+ const reason = abortedBatch(error)
1619
+ if (reason) throw abort(reason)
1620
+ throw error
540
1621
  }
541
- return rows.filter((row) => !stale.includes(row))
542
1622
  }
543
1623
 
544
1624
  /** Whether `index` is at or below the boundary, read in a fresh statement. */
@@ -558,240 +1638,339 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
558
1638
  return index <= asNumber(rows[0]?.['clear_index'])
559
1639
  }
560
1640
 
561
- /** Insert one event; its lane is ready unless an unfinished lane event of this lifetime precedes it. */
562
- const insertEvent = async ({
1641
+ /**
1642
+ * A completion that does not hold its head: a stale attempt, or a retry
1643
+ * after an unknown acknowledgement, which replays the committed children.
1644
+ * Takes no lock.
1645
+ */
1646
+ const settleUnheld = async ({
563
1647
  query,
564
- row,
565
- clearIndex,
566
- returning = false,
1648
+ sessionId,
1649
+ consumer,
1650
+ lane,
1651
+ index,
1652
+ attempt,
1653
+ events,
567
1654
  }: {
568
1655
  query: Query
569
- row: {
570
- sessionId: string
571
- index: number
572
- type: string
573
- payload: unknown
574
- id: string
575
- createdAt: number
576
- cause: EventCause | null
577
- lane: string | null
578
- processedAt: number | null
1656
+ sessionId: string
1657
+ consumer: string
1658
+ lane: string | null
1659
+ index: number
1660
+ attempt: number
1661
+ events: readonly ReturnedEvent[]
1662
+ }): Promise<CompleteAttemptResult> => {
1663
+ const superseded: CompleteAttemptResult = { outcome: 'superseded' }
1664
+ // The cursor's latest run, and the run the head belonged to: the latest
1665
+ // one started at or before it. A lane keeps its completions per event;
1666
+ // a log cursor keeps its latest on the run.
1667
+ const { rows: cursorRows } = await query(
1668
+ `select latest.kind, latest.head, covering.completed_idx,
1669
+ covering.completed_attempt, covering.returned_event_ids
1670
+ from (
1671
+ select kind, head from a2_cursors
1672
+ where session_id = $1 and cursor_key = $2 and consumer = $3
1673
+ order by epoch desc limit 1
1674
+ ) latest
1675
+ left join lateral (
1676
+ select completed_idx, completed_attempt, returned_event_ids
1677
+ from a2_cursors
1678
+ where session_id = $1 and cursor_key = $2 and consumer = $3
1679
+ and epoch <= $4
1680
+ order by epoch desc limit 1
1681
+ ) covering on true`,
1682
+ [sessionId, cursorKeyOf(lane, index), consumer, index],
1683
+ )
1684
+ const cursor = cursorRows[0]
1685
+ if (!cursor) {
1686
+ if (await hasEvent(query, sessionId, index)) return superseded
1687
+ // Deleted while this statement ran, after a clear.
1688
+ if (await clearedAt({ query, sessionId, index })) return superseded
1689
+ throw new TypeError(
1690
+ `no event at index ${index} in session '${sessionId}'`,
1691
+ )
579
1692
  }
580
- clearIndex: number
581
- returning?: boolean
582
- }): Promise<Record<string, unknown> | undefined> => {
583
- const { rows } = await query(
584
- `insert into a2_events
585
- (session_id, idx, event_type, payload, event_id, created_at,
586
- cause, lane, lane_ready, processed_at)
587
- values (
588
- $1, $2, $3, $4::jsonb, $5, $6, $7::jsonb, $8,
589
- $8::text is null or not exists (
590
- select 1 from a2_events
591
- where session_id = $1 and lane = $8 and processed_at is null
592
- and idx > $10
593
- ),
594
- $9
595
- )${returning ? ' returning *' : ''}`,
596
- [
597
- row.sessionId,
598
- row.index,
599
- row.type,
600
- jsonb(row.payload),
601
- row.id,
602
- row.createdAt,
603
- row.cause ? jsonb(row.cause) : null,
604
- row.lane,
605
- row.processedAt,
606
- clearIndex,
607
- ],
1693
+ const ids = events.map((event) => event.id)
1694
+ if (new Set(ids).size !== ids.length) {
1695
+ throw new A2Error(
1696
+ 'PARTIAL_DUPLICATE_BATCH',
1697
+ 'returned event batch contains the same event id more than once',
1698
+ )
1699
+ }
1700
+ if (asNullableNumber(cursor['head']) === index) return superseded
1701
+ let completion: { attempt: number | null; ids: unknown } | undefined
1702
+ if (cursor['kind'] === 'lane') {
1703
+ const { rows } = await query(
1704
+ `select completed_attempt, returned_event_ids from a2_lane_events
1705
+ where session_id = $1 and consumer = $2 and lane = $3 and idx = $4`,
1706
+ [sessionId, consumer, lane, index],
1707
+ )
1708
+ completion = rows[0] && {
1709
+ attempt: asNullableNumber(rows[0]['completed_attempt']),
1710
+ ids: rows[0]['returned_event_ids'],
1711
+ }
1712
+ } else if (asNullableNumber(cursor['completed_idx']) === index) {
1713
+ completion = {
1714
+ attempt: asNullableNumber(cursor['completed_attempt']),
1715
+ ids: cursor['returned_event_ids'],
1716
+ }
1717
+ }
1718
+ if (!completion || completion.attempt !== attempt) return superseded
1719
+ const returnedIds =
1720
+ completion.ids === null || completion.ids === undefined
1721
+ ? null
1722
+ : decodeReturnedEventIds(completion.ids)
1723
+ if (
1724
+ returnedIds === null ||
1725
+ returnedIds.length !== ids.length ||
1726
+ returnedIds.some((id, offset) => id !== ids[offset])
1727
+ ) {
1728
+ throw new A2Error(
1729
+ 'PARTIAL_DUPLICATE_BATCH',
1730
+ 'completed attempt does not match the returned event batch',
1731
+ )
1732
+ }
1733
+ if (events.length === 0) return { outcome: 'completed', events: [] }
1734
+ const { rows: existing } = await query(
1735
+ `select session_id, idx, event_type, payload, event_id, created_at, cause
1736
+ from a2_events where session_id = $1 and event_id = any($2::text[])`,
1737
+ [sessionId, ids],
1738
+ )
1739
+ const byId = new Map(
1740
+ existing.map((row) => [row['event_id'] as string, row]),
608
1741
  )
609
- return rows[0]
1742
+ const ordered = ids.map((id) => byId.get(id))
1743
+ if (
1744
+ ordered.some((row) => !row) &&
1745
+ (await clearedAt({ query, sessionId, index }))
1746
+ )
1747
+ return superseded
1748
+ if (
1749
+ ordered.some((row) => {
1750
+ const cause = row ? decodeCause(row['cause']) : null
1751
+ return (
1752
+ !row ||
1753
+ cause?.consumer !== consumer ||
1754
+ cause.lane !== lane ||
1755
+ cause.index !== index ||
1756
+ cause.attempt !== attempt
1757
+ )
1758
+ })
1759
+ ) {
1760
+ throw new A2Error(
1761
+ 'PARTIAL_DUPLICATE_BATCH',
1762
+ 'returned event retry does not match the committed batch',
1763
+ )
1764
+ }
1765
+ return {
1766
+ outcome: 'completed',
1767
+ events: idempotentReplay(events, (ordered as Row[]).map(toStored)),
1768
+ }
610
1769
  }
611
1770
 
612
1771
  const store: PostgresStore = {
613
1772
  async append(sessionId, events, appendOptions) {
1773
+ const consumers = appendOptions?.consumers
1774
+ const served = servedNames(consumers)
614
1775
  if (events.length === 0) {
615
- return wrap(async () => {
616
- const c = await client()
617
- const { rows } = await c.query(
618
- `select exists(
619
- select 1 from a2_events
620
- where session_id = $1 and processed_at is null
621
- and idx > ${clearIndexOf('$1')}
622
- ) as has_pending`,
623
- [sessionId],
624
- )
625
- return {
626
- events: [],
627
- hasPending: rows[0]?.['has_pending'] === true,
628
- }
629
- })
630
- }
631
- return wrap(() =>
632
- withTx(async (query) => {
633
- // Serialize appends per session — index assignment must not
634
- // race across writers. Transaction-scoped, self-releasing.
635
- await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
636
- const { rows: statusRows } = await query(
637
- `select coalesce(max(idx), 0) as max,
638
- ${clearIndexOf('$1')} as clear_index,
639
- exists(
640
- select 1 from a2_events pending
641
- where pending.session_id = $1
642
- and pending.processed_at is null
643
- and pending.idx > ${clearIndexOf('$1')}
644
- ) as session_has_pending
645
- from a2_events
646
- where session_id = $1`,
647
- [sessionId],
648
- )
649
- const base = asNumber(statusRows[0]?.['max'])
650
- const clearIndex = asNumber(statusRows[0]?.['clear_index'])
651
- const sessionHasPending =
652
- statusRows[0]?.['session_has_pending'] === true
653
-
654
- const supplied = events.filter((e) => e.id !== undefined)
655
- const suppliedIds = supplied.map((e) => e.id as string)
656
- if (new Set(suppliedIds).size !== suppliedIds.length) {
657
- throw new A2Error(
658
- 'PARTIAL_DUPLICATE_BATCH',
659
- 'batch contains the same event id more than once',
660
- )
661
- }
662
- if (suppliedIds.length > 0) {
663
- const existing = await liveOwnersOf({
664
- query,
665
- sessionId,
666
- clearIndex,
667
- ids: suppliedIds,
668
- })
669
- if (existing.length > 0) {
670
- const foreign = existing.find(
671
- (row) => row['session_id'] !== sessionId,
672
- )
673
- if (foreign) {
674
- throw new A2Error(
675
- 'PARTIAL_DUPLICATE_BATCH',
676
- `event id '${String(foreign['event_id'])}' already exists in another session`,
677
- )
678
- }
679
- if (existing.length === events.length) {
680
- return {
681
- events: idempotentReplay(events, existing.map(toStored)),
682
- hasPending: sessionHasPending,
683
- }
684
- }
685
- throw new A2Error(
686
- 'PARTIAL_DUPLICATE_BATCH',
687
- `batch mixes ${existing.length} already-appended and ${events.length - existing.length} fresh events`,
688
- )
689
- }
690
- }
691
-
692
- const originIndex = appendOptions?.originIndex
693
- if (originIndex !== undefined && originIndex <= clearIndex) {
694
- throw sessionClearedError(
695
- `scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
696
- )
697
- }
698
- for (const e of events) {
699
- if (e.cause && e.cause.index <= clearIndex)
700
- throw supersededAttemptError(sessionId, e.cause)
1776
+ return wrap(async () => {
1777
+ const c = await client()
1778
+ const { rows } = await c.query(
1779
+ `select ${pendingIn('$1', '$2')} as has_pending`,
1780
+ [sessionId, served],
1781
+ )
1782
+ return {
1783
+ events: [],
1784
+ hasPending: rows[0]?.['has_pending'] === true,
701
1785
  }
702
- // Attempt-currency fence: a fresh handler append commits only
703
- // while its causal attempt is still the parent's latest
704
- // (store.ts `append`). `for update` serializes this read against
705
- // a concurrent `claimAvailable` — an unlocked read admits write
706
- // skew.
707
- const causeIndexes = [
708
- ...new Set(
709
- events
710
- .filter((e) => e.cause !== undefined)
711
- .map((e) => e.cause!.index),
1786
+ })
1787
+ }
1788
+ return wrap(async () => {
1789
+ const supplied = events.filter((e) => e.id !== undefined)
1790
+ const suppliedIds = supplied.map((e) => e.id as string)
1791
+ if (new Set(suppliedIds).size !== suppliedIds.length) {
1792
+ throw new A2Error(
1793
+ 'PARTIAL_DUPLICATE_BATCH',
1794
+ 'batch contains the same event id more than once',
1795
+ )
1796
+ }
1797
+ const now = clock.now().getTime()
1798
+ const rows = events.map((event, offset) => ({
1799
+ ord: offset + 1,
1800
+ id: event.id ?? generateId(),
1801
+ type: event.type,
1802
+ payload: event.payload,
1803
+ cause: event.cause ?? null,
1804
+ }))
1805
+ const causes = [
1806
+ ...new Map(
1807
+ events.flatMap((event) =>
1808
+ event.cause ? [[jsonb(event.cause), event.cause] as const] : [],
712
1809
  ),
713
- ]
714
- if (causeIndexes.length > 0) {
715
- const { rows: parents } = await query(
716
- `select idx, attempt_count, failed_at from a2_events
717
- where session_id = $1 and idx = any($2::bigint[])
718
- for update`,
719
- [sessionId, causeIndexes],
720
- )
721
- const byIndex = new Map(
722
- parents.map((row) => [asNumber(row['idx']), row]),
723
- )
724
- for (const e of events) {
725
- if (!e.cause) continue
726
- const parent = byIndex.get(e.cause.index)
727
- if (!parent) {
728
- throw new TypeError(
729
- `no event at index ${e.cause.index} in session '${sessionId}'`,
1810
+ ).values(),
1811
+ ]
1812
+ const work = enqueue(events, consumers)
1813
+ const originIndex = appendOptions?.originIndex
1814
+ // One round trip, holding the session lock only while the server
1815
+ // runs it: index assignment must not race across writers.
1816
+ const results = await run({
1817
+ sessionId,
1818
+ statements: [
1819
+ LOCK_SESSION,
1820
+ APPEND_CHECKS,
1821
+ causes.length > 0 && FENCE,
1822
+ suppliedIds.length > 0 && REUSE_IDS,
1823
+ INSERT_EVENTS,
1824
+ ...work.statements,
1825
+ ],
1826
+ params: {
1827
+ ...work.params,
1828
+ served,
1829
+ ids: suppliedIds,
1830
+ count: events.length,
1831
+ origin: originIndex ?? null,
1832
+ // Attempt-currency fence: a fresh consumer append commits only
1833
+ // while its causal attempt still owns the cursor's head
1834
+ // (store.ts `append`).
1835
+ causes: causes.map((cause, offset) => ({
1836
+ ord: offset + 1,
1837
+ consumer: cause.consumer,
1838
+ cursor_key: cursorKeyOf(cause.lane, cause.index),
1839
+ idx: cause.index,
1840
+ attempt: cause.attempt,
1841
+ })),
1842
+ events: rows,
1843
+ now,
1844
+ },
1845
+ abort: (reason) => {
1846
+ switch (reason['abort']) {
1847
+ case 'mixed': {
1848
+ const owned = asNumber(reason['owned'])
1849
+ return new A2Error(
1850
+ 'PARTIAL_DUPLICATE_BATCH',
1851
+ `batch mixes ${owned} already-appended and ${events.length - owned} fresh events`,
730
1852
  )
731
1853
  }
732
- if (
733
- asNumber(parent['attempt_count']) !== e.cause.attempt ||
734
- parent['failed_at'] !== null
735
- ) {
736
- throw supersededAttemptError(sessionId, e.cause)
737
- }
1854
+ case 'cleared':
1855
+ return sessionClearedError(
1856
+ `scheduled work from event ${originIndex} was cleared in session '${sessionId}'`,
1857
+ )
1858
+ case 'superseded':
1859
+ return supersededAttemptError(
1860
+ sessionId,
1861
+ causes[asNumber(reason['ord']) - 1]!,
1862
+ )
1863
+ case 'held':
1864
+ return new A2Error(
1865
+ 'STORE_UNAVAILABLE',
1866
+ `an event id being reused is still held by cleared work in session '${sessionId}'`,
1867
+ )
1868
+ default:
1869
+ return new A2Error(
1870
+ 'STORE_UNAVAILABLE',
1871
+ 'postgres store operation failed',
1872
+ )
738
1873
  }
1874
+ },
1875
+ })
1876
+ const checked = results.find(
1877
+ (row) => row['stop'] === 'replay' || 'base' in row,
1878
+ )!
1879
+ if (checked['stop'] === 'replay') {
1880
+ return {
1881
+ events: idempotentReplay(
1882
+ events,
1883
+ (checked['events'] as Row[]).map(toStored),
1884
+ ),
1885
+ hasPending: checked['has_pending'] === true,
739
1886
  }
1887
+ }
1888
+ return {
1889
+ events: insertedRows({
1890
+ sessionId,
1891
+ events: rows,
1892
+ base: asNumber(checked['base']),
1893
+ now,
1894
+ }),
1895
+ hasPending: checked['has_pending'] === true || work.started,
1896
+ }
1897
+ })
1898
+ },
740
1899
 
741
- const now = clock.now().getTime()
742
- const inserted: StoredEvent[] = []
743
- for (let i = 0; i < events.length; i += 1) {
744
- const e = events[i]!
745
- const id = e.id ?? generateId()
746
- const index = base + 1 + i
747
- // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts
748
- await insertEvent({
749
- query,
750
- row: {
751
- sessionId,
752
- index,
753
- type: e.type,
754
- payload: e.payload,
755
- id,
756
- createdAt: now,
757
- cause: e.cause ?? null,
758
- lane: e.lane ?? null,
759
- processedAt: e.settled ? now : null,
760
- },
761
- clearIndex,
762
- })
763
- inserted.push({
764
- id,
765
- type: e.type,
766
- payload: structuredClone(e.payload),
767
- index,
768
- sessionId,
769
- createdAt: new Date(now),
770
- cause: e.cause ? { ...e.cause } : null,
771
- lane: e.lane ?? null,
772
- processedAt: e.settled ? new Date(now) : null,
773
- processedByAttempt: null,
774
- returnedEventIds: null,
775
- firstClaimedAt: null,
776
- lastClaimedAt: null,
777
- attemptCount: 0,
778
- failureCount: 0,
779
- lastFailedAt: null,
780
- lastFailedAttempt: null,
781
- lastError: null,
782
- failedAt: null,
783
- claimHolder: null,
784
- claimExpiresAt: null,
785
- })
786
- }
1900
+ async wakeLog({ sessionId, consumer }) {
1901
+ const { log } = consumer
1902
+ return wrap(async () => {
1903
+ // A read that finds nothing to write needs no lock: the cursor and
1904
+ // the head come from one snapshot. Otherwise the session lock comes
1905
+ // first, as an append takes it: the log and the cursor's runs hold
1906
+ // still, and no cursor row is waited on after.
1907
+ const results = await run({
1908
+ sessionId,
1909
+ statements: [READ_LOG, LOCK_SESSION, WAKE_LOG],
1910
+ params: {
1911
+ cursorKey: cursorKeyOf(log.lane, 0),
1912
+ consumer: consumer.name,
1913
+ lane: log.lane,
1914
+ types: log.types,
1915
+ backfill: log.backfill,
1916
+ clearType: CLEAR_EVENT_TYPE,
1917
+ },
1918
+ })
1919
+ const found = results.at(-1)!
1920
+ // Nothing after the latest clear: a missing cursor stays missing.
1921
+ if (found['empty'] === true) {
1922
+ const head = asNumber(found['head'])
787
1923
  return {
788
- events: inserted,
789
- hasPending:
790
- sessionHasPending ||
791
- events.some((event) => event.settled !== true),
1924
+ cursor: idleLogCursor(consumer.name, log.lane, head),
1925
+ head,
792
1926
  }
793
- }),
794
- )
1927
+ }
1928
+ const cursor = found['cursor'] as Row
1929
+ const clearIndex = asNumber(
1930
+ found['stop'] === 'unchanged'
1931
+ ? cursor['clear_index']
1932
+ : found['clear_index'],
1933
+ )
1934
+ return {
1935
+ cursor: toCursor(cursor, clearIndex),
1936
+ head: asNumber(
1937
+ found['stop'] === 'unchanged'
1938
+ ? cursor['session_head']
1939
+ : found['head'],
1940
+ ),
1941
+ }
1942
+ })
1943
+ },
1944
+
1945
+ async cursors(sessionId) {
1946
+ return wrap(async () => {
1947
+ const c = await client()
1948
+ const { rows } = await c.query(
1949
+ `with boundary as materialized (
1950
+ select ${clearIndexOf('$1')} as clear_index
1951
+ )
1952
+ select cur.*, boundary.clear_index
1953
+ from a2_cursors cur, boundary
1954
+ where cur.session_id = $1
1955
+ and (
1956
+ cur.head > boundary.clear_index
1957
+ or (cur.kind <> 'event' and not exists (
1958
+ select 1 from a2_cursors later
1959
+ where later.session_id = cur.session_id
1960
+ and later.cursor_key = cur.cursor_key
1961
+ and later.consumer = cur.consumer
1962
+ and later.epoch > cur.epoch
1963
+ ) and (
1964
+ cur.kind = 'log'
1965
+ or (cur.head is null and cur.position > boundary.clear_index)
1966
+ ))
1967
+ )`,
1968
+ [sessionId],
1969
+ )
1970
+ return rows
1971
+ .map((row) => toCursor(row, asNumber(row['clear_index'])))
1972
+ .toSorted(byCursor)
1973
+ })
795
1974
  },
796
1975
 
797
1976
  async read(sessionId, opts) {
@@ -810,7 +1989,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
810
1989
  // Reads start at the latest clear marker.
811
1990
  conditions.push(`idx >= ${clearIndexOf('$1')}`)
812
1991
  const { rows } = await c.query(
813
- `select * from a2_events where ${conditions.join(' and ')} order by idx`,
1992
+ `select session_id, idx, event_type, payload, event_id, created_at, cause
1993
+ from a2_events where ${conditions.join(' and ')} order by idx`,
814
1994
  params,
815
1995
  )
816
1996
  return rows.map(toStored)
@@ -851,91 +2031,71 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
851
2031
  holder,
852
2032
  ttlMs,
853
2033
  expiresAtMs,
854
- excludeIndexes = [],
2034
+ exclude = [],
2035
+ consumers,
855
2036
  maxFailures,
2037
+ through,
856
2038
  }) {
857
- return wrap(() =>
858
- withTx(async (query) => {
859
- const now = clock.now().getTime()
860
- const expiresAt = expiresAtMs ?? now + ttlMs
861
- // A lapsed claim's takeover that spends the last failed attempt
862
- // dead-letters the event instead of claiming it.
863
- const exhausted = `(claimed.claim_holder is not null
864
- and claimed.failure_count + 1 >= $6::int)`
865
- const { rows } = await query(
866
- `with eligible as materialized (
867
- select event.idx
868
- from a2_events event
869
- where event.session_id = $1
870
- and event.processed_at is null
871
- and event.failed_at is null
872
- and event.lane_ready
873
- and event.idx > ${clearIndexOf('$1')}
874
- and (event.claim_expires_at is null or event.claim_expires_at <= $2)
875
- and not (event.idx = any($5::bigint[]))
876
- for update of event
877
- )
878
- update a2_events claimed set
879
- attempt_count = claimed.attempt_count + 1,
880
- failure_count = claimed.failure_count
881
- + case when claimed.claim_holder is null then 0 else 1 end,
882
- failed_at = case when ${exhausted} then $2::bigint else claimed.failed_at end,
883
- first_claimed_at = case when ${exhausted}
884
- then claimed.first_claimed_at
885
- else coalesce(claimed.first_claimed_at, $2::bigint) end,
886
- last_claimed_at = case when ${exhausted}
887
- then claimed.last_claimed_at else $2::bigint end,
888
- claim_holder = case when ${exhausted} then null else $3::text end,
889
- claim_expires_at = case when ${exhausted} then null else $4::bigint end
890
- from eligible
891
- where claimed.session_id = $1 and claimed.idx = eligible.idx
892
- returning claimed.*, ${clearIndexOf('$1')} as clear_index`,
893
- [
894
- sessionId,
895
- now,
896
- holder,
897
- expiresAt,
898
- [...excludeIndexes],
899
- maxFailures ?? null,
900
- ],
2039
+ return wrap(async () => {
2040
+ const now = clock.now().getTime()
2041
+ const results = await run({
2042
+ sessionId,
2043
+ statements: [CLAIM, RELEASE_DEAD, RETRY_AT],
2044
+ params: {
2045
+ now,
2046
+ served: servedNames(consumers),
2047
+ excluded: exclude.map((ref) => ({
2048
+ consumer: ref.consumer,
2049
+ cursor_key: cursorKeyOf(ref.lane, ref.index),
2050
+ })),
2051
+ holder,
2052
+ expiresAt: expiresAtMs ?? now + ttlMs,
2053
+ maxFailures: maxFailures ?? null,
2054
+ through: through ?? null,
2055
+ },
2056
+ })
2057
+ const rows = results.filter((row) => 'e_idx' in row)
2058
+ const unseen = rows.filter((row) => row['e_idx'] === null)
2059
+ if (unseen.length > 0) {
2060
+ // The claim waited on these rows while they moved to events that
2061
+ // committed after its snapshot. A fresh read finds them; one it
2062
+ // doesn't was cleared and reclaimed, and its claim is superseded.
2063
+ const c = await client()
2064
+ const { rows: events } = await c.query(
2065
+ `select ${HEAD_COLUMNS} from a2_events event
2066
+ where event.session_id = $1 and event.idx = any($2::bigint[])`,
2067
+ [sessionId, unseen.map((row) => row['head'])],
901
2068
  )
902
- for (const row of rows) {
903
- if (row['failed_at'] !== null)
904
- await releasePins({
905
- query,
906
- sessionId,
907
- index: asNumber(row['idx']),
908
- })
909
- }
910
- if (rows.length > 0) {
911
- const events = rows
912
- .map((row) => {
913
- const stored = toStored(row)
914
- const clearIndex = asNumber(row['clear_index'])
915
- if (clearIndex > 0) stored.clearIndex = clearIndex
916
- return stored
917
- })
918
- .toSorted((a, b) => a.index - b.index)
919
- return { outcome: 'claimed', events }
920
- }
921
- const { rows: activeRows } = await query(
922
- `select min(claim_expires_at) as retry_at
923
- from a2_events
924
- where session_id = $1
925
- and processed_at is null
926
- and failed_at is null
927
- and lane_ready
928
- and idx > ${clearIndexOf('$1')}
929
- and claim_expires_at > $2
930
- and not (idx = any($3::bigint[]))`,
931
- [sessionId, now, [...excludeIndexes]],
2069
+ const byIndex = new Map(
2070
+ events.map((event) => [asNumber(event['e_idx']), event]),
932
2071
  )
933
- const retryAt = activeRows[0]?.['retry_at']
934
- return retryAt === null || retryAt === undefined
935
- ? { outcome: 'settled' }
936
- : { outcome: 'busy', retryAt: new Date(asNumber(retryAt)) }
937
- }),
938
- )
2072
+ for (const row of unseen)
2073
+ 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
2078
+ .map((row) => {
2079
+ const head = toHead(row)
2080
+ const clearIndex = asNumber(row['clear_index'])
2081
+ if (clearIndex > 0) head.clearIndex = clearIndex
2082
+ return head
2083
+ })
2084
+ .toSorted(byHead)
2085
+ return {
2086
+ outcome: 'claimed',
2087
+ events,
2088
+ head: Math.max(
2089
+ ...heads.map((row) => asNumber(row['session_head'])),
2090
+ ...events.map((event) => event.index),
2091
+ ),
2092
+ }
2093
+ }
2094
+ const retryAt = results.at(-1)!['retry_at']
2095
+ return retryAt === null || retryAt === undefined
2096
+ ? { outcome: 'settled' }
2097
+ : { outcome: 'busy', retryAt: new Date(asNumber(retryAt)) }
2098
+ })
939
2099
  },
940
2100
 
941
2101
  async renewClaims({ sessionId, holder, claims, ttlMs, expiresAtMs }) {
@@ -944,320 +2104,274 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
944
2104
  const c = await client()
945
2105
  const now = clock.now().getTime()
946
2106
  const expiresAt = expiresAtMs ?? now + ttlMs
947
- const indexes = claims.map((claim) => claim.index)
948
- const attempts = new Map(
949
- claims.map((claim) => [claim.index, claim.attempt]),
2107
+ const ordered = claims.toSorted(
2108
+ (a, b) => a.index - b.index || compareText(a.consumer, b.consumer),
950
2109
  )
951
2110
  const { rows } = await c.query(
952
2111
  `with listed as (
953
- select idx, attempt
954
- from unnest($3::bigint[], $6::int[]) as listed(idx, attempt)
955
- ),
956
- renewed as (
957
- update a2_events event set claim_expires_at = $1
2112
+ select * from unnest($3::text[], $4::text[], $5::bigint[], $6::int[])
2113
+ with ordinality as listed(consumer, cursor_key, idx, attempt, ord)
2114
+ ), renewed as (
2115
+ update a2_cursors cur set claim_expires_at = $7
958
2116
  from listed
959
- where event.session_id = $2
960
- and event.idx = listed.idx
961
- and event.idx > ${clearIndexOf('$2')}
962
- and event.attempt_count = listed.attempt
963
- and event.processed_at is null
964
- and event.failed_at is null
965
- and event.claim_holder = $4
966
- and event.claim_expires_at > $5
967
- returning event.idx
2117
+ where cur.session_id = $1
2118
+ and cur.cursor_key = listed.cursor_key
2119
+ and cur.consumer = listed.consumer
2120
+ and cur.head = listed.idx
2121
+ and listed.idx > ${clearIndexOf('$1')}
2122
+ and cur.attempt_count = listed.attempt
2123
+ and cur.failed_at is null
2124
+ and cur.claim_holder = $2
2125
+ and cur.claim_expires_at > $8
2126
+ returning cur.cursor_key, cur.consumer, cur.head
968
2127
  )
969
- select listed.idx, event.attempt_count,
970
- exists(select 1 from renewed where renewed.idx = listed.idx)
971
- as renewed,
972
- listed.idx <= ${clearIndexOf('$2')} as cleared
2128
+ select listed.ord, latest.head, latest.position, latest.attempt_count,
2129
+ latest.epoch is not null as found,
2130
+ exists(
2131
+ select 1 from renewed
2132
+ where renewed.cursor_key = listed.cursor_key
2133
+ and renewed.consumer = listed.consumer
2134
+ and renewed.head = listed.idx
2135
+ ) as renewed,
2136
+ listed.idx <= ${clearIndexOf('$1')} as cleared
973
2137
  from listed
974
- left join a2_events event
975
- on event.session_id = $2 and event.idx = listed.idx`,
2138
+ left join lateral (
2139
+ select cur.epoch, cur.head, cur.position, cur.attempt_count
2140
+ from a2_cursors cur
2141
+ where cur.session_id = $1
2142
+ and cur.cursor_key = listed.cursor_key
2143
+ and cur.consumer = listed.consumer
2144
+ order by cur.epoch desc limit 1
2145
+ ) latest on true
2146
+ order by listed.ord`,
976
2147
  [
977
- expiresAt,
978
2148
  sessionId,
979
- indexes,
980
2149
  holder,
2150
+ ordered.map((claim) => claim.consumer),
2151
+ ordered.map((claim) => cursorKeyOf(claim.lane, claim.index)),
2152
+ ordered.map((claim) => claim.index),
2153
+ ordered.map((claim) => claim.attempt),
2154
+ expiresAt,
981
2155
  now,
982
- claims.map((claim) => claim.attempt),
983
2156
  ],
984
2157
  )
985
- const renewed: number[] = []
986
- const superseded: number[] = []
2158
+ const renewed: HeadRef[] = []
2159
+ const superseded: HeadRef[] = []
987
2160
  for (const row of rows) {
988
- const index = asNumber(row['idx'])
989
- if (row['renewed'] === true) renewed.push(index)
990
- else if (
991
- row['cleared'] === true ||
992
- (row['attempt_count'] !== null &&
993
- asNumber(row['attempt_count']) > (attempts.get(index) ?? 0))
994
- ) {
995
- superseded.push(index)
2161
+ const { attempt, ...ref } = ordered[asNumber(row['ord']) - 1]!
2162
+ if (row['renewed'] === true) {
2163
+ renewed.push(ref)
2164
+ continue
996
2165
  }
2166
+ if (row['cleared'] === true) {
2167
+ superseded.push(ref)
2168
+ continue
2169
+ }
2170
+ if (row['found'] !== true) continue
2171
+ const head = asNullableNumber(row['head'])
2172
+ // An event the cursor has not reached yet holds no claim to renew.
2173
+ if (head !== ref.index && asNumber(row['position']) < ref.index)
2174
+ continue
2175
+ if (head !== ref.index || asNumber(row['attempt_count']) > attempt)
2176
+ superseded.push(ref)
997
2177
  }
998
- return {
999
- renewed: renewed.toSorted((a, b) => a - b),
1000
- superseded: superseded.toSorted((a, b) => a - b),
1001
- }
2178
+ return { renewed, superseded }
1002
2179
  })
1003
2180
  },
1004
2181
 
1005
- async completeAttempt({ sessionId, index, attempt, events }) {
1006
- return wrap(() =>
1007
- withTx(async (query) => {
1008
- const { rows: parentRows } = await query(
1009
- `select boundary.clear_index, parent.*
1010
- from (select ${clearIndexOf('$1')} as clear_index) boundary
1011
- left join lateral (
1012
- select * from a2_events
1013
- where session_id = $1 and idx = $2 and idx > boundary.clear_index
1014
- for update
1015
- ) parent on true`,
1016
- [sessionId, index],
1017
- )
1018
- if (index <= asNumber(parentRows[0]?.['clear_index']))
1019
- return { outcome: 'superseded' }
1020
- const parent =
1021
- parentRows[0]?.['idx'] == null ? undefined : parentRows[0]
1022
- if (!parent) {
1023
- // Deleted while this statement waited for its lock, after a clear.
1024
- if (await clearedAt({ query, sessionId, index }))
1025
- return { outcome: 'superseded' }
1026
- throw new TypeError(
1027
- `no event at index ${index} in session '${sessionId}'`,
1028
- )
1029
- }
1030
- const ids = events.map((event) => event.id)
1031
- if (new Set(ids).size !== ids.length) {
1032
- throw new A2Error(
1033
- 'PARTIAL_DUPLICATE_BATCH',
1034
- 'returned event batch contains the same event id more than once',
1035
- )
1036
- }
1037
- if (parent['processed_at'] !== null) {
1038
- if (asNumber(parent['processed_by_attempt']) !== attempt) {
1039
- return { outcome: 'superseded' }
1040
- }
1041
- const returnedIds =
1042
- parent['returned_event_ids'] === null ||
1043
- parent['returned_event_ids'] === undefined
1044
- ? null
1045
- : decodeReturnedEventIds(parent['returned_event_ids'])
1046
- if (
1047
- returnedIds === null ||
1048
- returnedIds.length !== ids.length ||
1049
- returnedIds.some((id, offset) => id !== ids[offset])
1050
- ) {
1051
- throw new A2Error(
1052
- 'PARTIAL_DUPLICATE_BATCH',
1053
- 'completed attempt does not match the returned event batch',
1054
- )
1055
- }
1056
- if (events.length === 0) {
1057
- return { outcome: 'completed', events: [] }
1058
- }
1059
- const { rows: existing } = await query(
1060
- `select * from a2_events
1061
- where event_id = any($1::text[])`,
1062
- [ids],
1063
- )
1064
- const byId = new Map(
1065
- existing.map((row) => [row['event_id'] as string, row]),
1066
- )
1067
- const ordered = ids.map((id) => byId.get(id))
1068
- if (
1069
- ordered.some((row) => !row) &&
1070
- (await clearedAt({ query, sessionId, index }))
1071
- )
1072
- return { outcome: 'superseded' }
1073
- if (
1074
- ordered.some((row) => {
1075
- const cause = row ? toCause(row['cause']) : null
1076
- return (
1077
- !row ||
1078
- row['session_id'] !== sessionId ||
1079
- cause?.index !== index ||
1080
- cause.attempt !== attempt
2182
+ /*
2183
+ * One round trip. The cursor's lock comes first, and only while this
2184
+ * attempt holds the head (`HOLD`): a completion waiting on a write-back
2185
+ * holds nothing a push needs, and a cleared head is never locked. A
2186
+ * completion that returns events then takes the session lock to give them
2187
+ * indexes, as an append does.
2188
+ *
2189
+ * One that returns none takes the session lock only when its lane or log
2190
+ * cursor would go idle (`LOCK_IF_IDLE`), the one case that needs it: an
2191
+ * append that finds a cursor with a head leaves its events to the
2192
+ * cursor's next head, so a cursor going idle must see every append that
2193
+ * committed before, or that work would wait with no head to run it.
2194
+ * Moving to a next head needs no lock, because the head it reads is the
2195
+ * one it would read under the lock:
2196
+ *
2197
+ * - Every insert into `a2_events` and `a2_lane_events` holds the session
2198
+ * lock through its commit, so they commit in index order: a snapshot
2199
+ * that sees the next head sees every event before it, and nothing
2200
+ * committed later lies before it.
2201
+ * - Only a holder of the cursor's lock marks its lane's events done, and
2202
+ * rows go away only below a clear marker, which `COMPLETE` reads in the
2203
+ * snapshot it reads the next head in: cleared, it writes nothing. A
2204
+ * clear that commits after that snapshot puts its marker past the next
2205
+ * head, which a later append then treats as cleared.
2206
+ *
2207
+ * A concurrent cursor has no next head and no append touches its row, so
2208
+ * it never takes the session lock.
2209
+ */
2210
+ async completeAttempt({
2211
+ sessionId,
2212
+ consumer,
2213
+ lane,
2214
+ index,
2215
+ attempt,
2216
+ events,
2217
+ consumers,
2218
+ }) {
2219
+ return wrap(async () => {
2220
+ const ids = events.map((event) => event.id)
2221
+ const duplicated = new Set(ids).size !== ids.length
2222
+ const cause: EventCause = { consumer, lane, index, attempt }
2223
+ const now = clock.now().getTime()
2224
+ const rows = events.map((event, offset) => ({
2225
+ ord: offset + 1,
2226
+ id: event.id,
2227
+ type: event.type,
2228
+ payload: event.payload,
2229
+ cause,
2230
+ }))
2231
+ const work =
2232
+ events.length > 0 && !duplicated
2233
+ ? enqueue(events, consumers)
2234
+ : undefined
2235
+ const results = await run({
2236
+ sessionId,
2237
+ statements: duplicated
2238
+ ? [HOLD]
2239
+ : work
2240
+ ? [
2241
+ HOLD,
2242
+ LOCK_SESSION,
2243
+ RETURN_CHECKS,
2244
+ REUSE_IDS,
2245
+ INSERT_EVENTS,
2246
+ ...work.statements,
2247
+ COMPLETE_APPENDED,
2248
+ ]
2249
+ : [HOLD, LOCK_IF_IDLE, COMPLETE],
2250
+ params: {
2251
+ ...work?.params,
2252
+ ...retypeOf(consumer, consumers),
2253
+ cursorKey: cursorKeyOf(lane, index),
2254
+ consumer,
2255
+ index,
2256
+ attempt,
2257
+ ids,
2258
+ count: events.length,
2259
+ events: rows,
2260
+ now,
2261
+ clearType: CLEAR_EVENT_TYPE,
2262
+ },
2263
+ abort: (reason) =>
2264
+ reason['abort'] === 'owned'
2265
+ ? new A2Error(
2266
+ 'PARTIAL_DUPLICATE_BATCH',
2267
+ 'returned event batch contains an already-appended event id',
1081
2268
  )
1082
- })
1083
- ) {
1084
- throw new A2Error(
1085
- 'PARTIAL_DUPLICATE_BATCH',
1086
- 'returned event retry does not match the committed batch',
1087
- )
1088
- }
1089
- return {
1090
- outcome: 'completed',
1091
- events: idempotentReplay(
1092
- events,
1093
- (ordered as Record<string, unknown>[]).map(toStored),
1094
- ),
1095
- }
1096
- }
1097
- if (
1098
- asNumber(parent['attempt_count']) !== attempt ||
1099
- parent['claim_holder'] === null ||
1100
- parent['claim_holder'] === undefined ||
1101
- parent['failed_at'] !== null
1102
- ) {
1103
- return { outcome: 'superseded' }
1104
- }
1105
-
1106
- // The parent lock came first; only a statement after the session
1107
- // lock sees every clear that committed while this one waited.
1108
- await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
1109
- const { rows: maxRows } = await query(
1110
- `select coalesce(max(idx), 0) as max, ${clearIndexOf('$1')} as clear_index
1111
- from a2_events where session_id = $1`,
1112
- [sessionId],
2269
+ : new A2Error(
2270
+ 'STORE_UNAVAILABLE',
2271
+ reason['abort'] === 'held'
2272
+ ? `an event id being reused is still held by cleared work in session '${sessionId}'`
2273
+ : 'postgres store operation failed',
2274
+ ),
2275
+ })
2276
+ const held = results[0]!
2277
+ if (held['stop'] === 'superseded') return { outcome: 'superseded' }
2278
+ if (held['stop'] === 'unheld') {
2279
+ const c = await client()
2280
+ return settleUnheld({
2281
+ query: (text, values) => c.query(text, values),
2282
+ sessionId,
2283
+ consumer,
2284
+ lane,
2285
+ index,
2286
+ attempt,
2287
+ events,
2288
+ })
2289
+ }
2290
+ if (duplicated) {
2291
+ throw new A2Error(
2292
+ 'PARTIAL_DUPLICATE_BATCH',
2293
+ 'returned event batch contains the same event id more than once',
1113
2294
  )
1114
- const clearIndex = asNumber(maxRows[0]?.['clear_index'])
1115
- if (index <= clearIndex) return { outcome: 'superseded' }
1116
- if (ids.length > 0) {
1117
- const existing = await liveOwnersOf({
1118
- query,
1119
- sessionId,
1120
- clearIndex,
1121
- ids,
1122
- })
1123
- if (existing.length > 0) {
1124
- throw new A2Error(
1125
- 'PARTIAL_DUPLICATE_BATCH',
1126
- 'returned event batch contains an already-appended event id',
1127
- )
1128
- }
1129
- }
1130
-
1131
- const base = asNumber(maxRows[0]?.['max'])
1132
- const now = clock.now().getTime()
1133
- const inserted: StoredEvent[] = []
1134
- for (let offset = 0; offset < events.length; offset += 1) {
1135
- const event = events[offset]!
1136
- const childIndex = base + offset + 1
1137
- // oxlint-disable-next-line no-await-in-loop -- one tx, ordered inserts
1138
- const row = await insertEvent({
1139
- query,
1140
- row: {
2295
+ }
2296
+ if (results.at(-1)!['stop'] === 'superseded')
2297
+ return { outcome: 'superseded' }
2298
+ const checked = results.find((row) => 'base' in row)
2299
+ return {
2300
+ outcome: 'completed',
2301
+ events: checked
2302
+ ? insertedRows({
1141
2303
  sessionId,
1142
- index: childIndex,
1143
- type: event.type,
1144
- payload: event.payload,
1145
- id: event.id,
1146
- createdAt: now,
1147
- cause: { index, attempt },
1148
- lane: event.lane ?? null,
1149
- processedAt: event.settled ? now : null,
1150
- },
1151
- clearIndex,
1152
- returning: true,
1153
- })
1154
- inserted.push(toStored(row!))
1155
- }
1156
- await query(
1157
- `update a2_events set
1158
- processed_at = $1,
1159
- processed_by_attempt = $2,
1160
- returned_event_ids = $5::jsonb,
1161
- lane_ready = false,
1162
- claim_holder = null,
1163
- claim_expires_at = null
1164
- where session_id = $3 and idx = $4`,
1165
- [now, attempt, sessionId, index, jsonb(ids)],
1166
- )
1167
- await query(
1168
- `delete from a2_snapshot_pins
1169
- where session_id = $1 and event_index = $2`,
1170
- [sessionId, index],
1171
- )
1172
- await query(
1173
- `delete from a2_snapshot_history history
1174
- where history.session_id = $1
1175
- and not exists (
1176
- select 1 from a2_snapshot_pins pin
1177
- where pin.session_id = history.session_id
1178
- and pin.reducer_name = history.reducer_name
1179
- and pin.up_to_index = history.up_to_index
1180
- )`,
1181
- [sessionId],
1182
- )
1183
- const parentLane = (parent['lane'] as string | null) ?? null
1184
- if (parentLane !== null) {
1185
- await query(
1186
- `update a2_events set lane_ready = true
1187
- where session_id = $1
1188
- and idx = (
1189
- select idx from a2_events
1190
- where session_id = $1 and lane = $2 and processed_at is null
1191
- and idx > $3
1192
- order by idx limit 1
1193
- )`,
1194
- [sessionId, parentLane, clearIndex],
1195
- )
1196
- }
1197
- return { outcome: 'completed', events: inserted }
1198
- }),
1199
- )
2304
+ events: rows,
2305
+ base: asNumber(checked['base']),
2306
+ now,
2307
+ })
2308
+ : [],
2309
+ }
2310
+ })
1200
2311
  },
1201
2312
 
1202
2313
  async failAttempt({
1203
2314
  sessionId,
2315
+ consumer,
2316
+ lane,
1204
2317
  index,
1205
2318
  attempt,
1206
2319
  error,
1207
2320
  maxFailures,
1208
2321
  retryAfterMs,
1209
2322
  }) {
1210
- const now = clock.now().getTime()
1211
2323
  return wrap(() =>
1212
2324
  withTx(async (query) => {
2325
+ const now = clock.now().getTime()
2326
+ const cursorKey = cursorKeyOf(lane, index)
1213
2327
  const { rows } = await query(
1214
2328
  `with current as materialized (
1215
- select e.* from a2_events e
1216
- where e.session_id = $1 and e.idx = $2
1217
- and e.idx > ${clearIndexOf('$1')}
1218
- for update of e
2329
+ select cur.* from a2_cursors cur
2330
+ where cur.session_id = $1 and cur.cursor_key = $2
2331
+ and cur.consumer = $3 and cur.head = $4
2332
+ and cur.head > ${clearIndexOf('$1')}
2333
+ for update of cur
1219
2334
  ), updated as (
1220
- update a2_events e set
1221
- failure_count = e.failure_count + 1,
1222
- last_error = $4,
1223
- last_failed_at = $6,
1224
- last_failed_attempt = $3,
2335
+ update a2_cursors cur set
2336
+ failure_count = cur.failure_count + 1,
2337
+ last_error = $6,
2338
+ last_failed_at = $8,
2339
+ last_failed_attempt = $5,
1225
2340
  failed_at = case
1226
- when e.failure_count + 1 >= $5 then $6
1227
- else e.failed_at
2341
+ when cur.failure_count + 1 >= $7 then $8
2342
+ else cur.failed_at
1228
2343
  end,
1229
2344
  claim_holder = null,
1230
2345
  claim_expires_at = case
1231
- when e.failure_count + 1 >= $5 then null
1232
- else $7::bigint
2346
+ when cur.failure_count + 1 >= $7 then null
2347
+ else $9::bigint
1233
2348
  end
1234
2349
  from current
1235
- where e.session_id = $1
1236
- and e.idx = $2
1237
- and current.processed_at is null
2350
+ where cur.session_id = $1 and cur.cursor_key = $2
2351
+ and cur.consumer = $3 and cur.epoch = current.epoch
2352
+ and current.attempt_count = $5
1238
2353
  and current.failed_at is null
1239
- and current.attempt_count = $3
1240
2354
  and current.claim_holder is not null
1241
- returning e.failure_count, e.failed_at
2355
+ returning cur.failure_count, cur.failed_at
1242
2356
  )
1243
2357
  select case
1244
- when $2 <= ${clearIndexOf('$1')} then 'cleared'
2358
+ when $4 <= ${clearIndexOf('$1')} then 'cleared'
1245
2359
  when not exists (select 1 from current) then 'missing'
1246
2360
  when exists (
1247
2361
  select 1 from updated where failed_at is not null
1248
2362
  ) then 'dead_lettered'
1249
2363
  when exists (select 1 from updated) then 'failed'
1250
2364
  when exists (
1251
- select 1 from current
1252
- where processed_at is not null or attempt_count <> $3
2365
+ select 1 from current where attempt_count <> $5
1253
2366
  ) then 'superseded'
1254
- when exists (
1255
- select 1 from current where failed_at is not null
1256
- ) then 'dead_lettered'
1257
2367
  when exists (
1258
2368
  select 1 from current
1259
- where claim_holder is null and last_failed_attempt = $3
2369
+ where claim_holder is null and last_failed_attempt = $5
2370
+ and failed_at is null
1260
2371
  ) then 'failed'
2372
+ when exists (
2373
+ select 1 from current where failed_at is not null
2374
+ ) then 'dead_lettered'
1261
2375
  else 'superseded'
1262
2376
  end as outcome,
1263
2377
  coalesce(
@@ -1266,6 +2380,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1266
2380
  ) as failure_count`,
1267
2381
  [
1268
2382
  sessionId,
2383
+ cursorKey,
2384
+ consumer,
1269
2385
  index,
1270
2386
  attempt,
1271
2387
  error,
@@ -1279,15 +2395,24 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1279
2395
  return { outcome: 'superseded', failureCount: 0 }
1280
2396
  }
1281
2397
  if (!row || row['outcome'] === 'missing') {
1282
- // Deleted while this statement waited for its lock, after a clear.
1283
- if (await clearedAt({ query, sessionId, index }))
2398
+ const { rows: cursorRows } = await query(
2399
+ `select 1 from a2_cursors
2400
+ where session_id = $1 and cursor_key = $2 and consumer = $3`,
2401
+ [sessionId, cursorKey, consumer],
2402
+ )
2403
+ if (
2404
+ cursorRows.length > 0 ||
2405
+ (await hasEvent(query, sessionId, index)) ||
2406
+ // Deleted while this statement waited for its lock, after a clear.
2407
+ (await clearedAt({ query, sessionId, index }))
2408
+ )
1284
2409
  return { outcome: 'superseded', failureCount: 0 }
1285
2410
  throw new TypeError(
1286
2411
  `no event at index ${index} in session '${sessionId}'`,
1287
2412
  )
1288
2413
  }
1289
2414
  if (row['outcome'] === 'dead_lettered')
1290
- await releasePins({ query, sessionId, index })
2415
+ await releasePins({ query, sessionId, consumer, cursorKey, index })
1291
2416
  return {
1292
2417
  outcome: row['outcome'] as
1293
2418
  'failed' | 'dead_lettered' | 'superseded',
@@ -1301,80 +2426,47 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1301
2426
  return wrap(async () => {
1302
2427
  const c = await client()
1303
2428
  const { rows } = await c.query(
1304
- `select * from a2_events
1305
- where session_id = $1
1306
- and processed_at is null
1307
- and failed_at is not null
1308
- and idx > ${clearIndexOf('$1')}
1309
- order by idx`,
2429
+ `select cur.*, ${HEAD_COLUMNS}
2430
+ from a2_cursors cur
2431
+ join a2_events event
2432
+ on event.session_id = cur.session_id and event.idx = cur.head
2433
+ where cur.session_id = $1
2434
+ and cur.failed_at is not null
2435
+ and cur.head > ${clearIndexOf('$1')}`,
1310
2436
  [sessionId],
1311
2437
  )
1312
- return rows.map(toStored)
2438
+ return rows.map(toHead).toSorted(byHead)
1313
2439
  })
1314
2440
  },
1315
2441
 
1316
- async redrive({ sessionId, index, action }) {
1317
- return wrap(() =>
1318
- withTx(async (query) => {
1319
- // Lane promotion must see every append that committed before it,
1320
- // so a skip takes the session lock first, in append's order. The
1321
- // row lock serializes both actions with claims and handler appends.
1322
- if (action === 'skip') {
1323
- await query('select pg_advisory_xact_lock(hashtext($1))', [
1324
- sessionId,
1325
- ])
1326
- }
1327
- const { rows } = await query(
1328
- `select lane from a2_events
1329
- where session_id = $1 and idx = $2
1330
- and idx > ${clearIndexOf('$1')}
1331
- and processed_at is null
1332
- and failed_at is not null
1333
- for update`,
1334
- [sessionId, index],
1335
- )
1336
- const row = rows[0]
1337
- if (row && action === 'retry') {
1338
- await query(
1339
- `update a2_events set failed_at = null, failure_count = 0
1340
- where session_id = $1 and idx = $2`,
1341
- [sessionId, index],
1342
- )
1343
- } else if (row) {
1344
- await query(
1345
- `update a2_events set processed_at = $3, lane_ready = false
1346
- where session_id = $1 and idx = $2`,
1347
- [sessionId, index, clock.now().getTime()],
1348
- )
1349
- const lane = (row['lane'] as string | null) ?? null
1350
- if (lane !== null) {
1351
- await query(
1352
- `update a2_events set lane_ready = true
1353
- where session_id = $1
1354
- and idx = (
1355
- select idx from a2_events
1356
- where session_id = $1 and lane = $2 and processed_at is null
1357
- and idx > ${clearIndexOf('$1')}
1358
- order by idx limit 1
1359
- )`,
1360
- [sessionId, lane],
1361
- )
1362
- }
1363
- }
1364
- const { rows: status } = await query(
1365
- `select exists(
1366
- select 1 from a2_events
1367
- where session_id = $1 and processed_at is null
1368
- and idx > ${clearIndexOf('$1')}
1369
- ) as has_pending`,
1370
- [sessionId],
1371
- )
1372
- return {
1373
- redriven: row !== undefined,
1374
- hasPending: status[0]?.['has_pending'] === true,
1375
- }
1376
- }),
1377
- )
2442
+ async redrive({ sessionId, consumer, index, action, consumers }) {
2443
+ return wrap(async () => {
2444
+ // Lane promotion must see every append that committed before it,
2445
+ // so a skip takes the session lock first, in append's order. The
2446
+ // cursor lock serializes both actions with claims and consumer
2447
+ // appends.
2448
+ const results = await run({
2449
+ sessionId,
2450
+ statements: [
2451
+ action === 'skip' && LOCK_SESSION,
2452
+ LOCK_DEAD,
2453
+ action === 'skip' ? SKIP_DEAD : RETRY_DEAD,
2454
+ PENDING,
2455
+ ],
2456
+ params: {
2457
+ ...retypeOf(consumer, consumers),
2458
+ consumer,
2459
+ index,
2460
+ served: servedNames(consumers),
2461
+ clearType: CLEAR_EVENT_TYPE,
2462
+ },
2463
+ })
2464
+ return {
2465
+ redriven:
2466
+ results.find((row) => 'redriven' in row)?.['redriven'] === true,
2467
+ hasPending: results.at(-1)!['has_pending'] === true,
2468
+ }
2469
+ })
1378
2470
  },
1379
2471
 
1380
2472
  async readState(sessionId, reducerName, stateOptions) {
@@ -1437,39 +2529,65 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1437
2529
  headRows[0]?.['up_to_index'] == null ? undefined : headRows[0]
1438
2530
 
1439
2531
  const requestedPins = normalized.flatMap((write) =>
1440
- (write.pinEventIndexes ?? []).map((eventIndex) => ({
1441
- eventIndex,
2532
+ (write.pins ?? []).map((pin) => ({
2533
+ eventIndex: pin.index,
2534
+ consumer: pin.consumer,
2535
+ cursorKey: cursorKeyOf(pin.lane, pin.index),
1442
2536
  snapshotIndex: write.index,
1443
2537
  })),
1444
2538
  )
1445
2539
  if (requestedPins.length > 0) {
1446
- const eventIndexes = [
1447
- ...new Set(requestedPins.map((pin) => pin.eventIndex)),
2540
+ const heads = [
2541
+ ...new Map(
2542
+ requestedPins.map((pin) => [
2543
+ `${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`,
2544
+ pin,
2545
+ ]),
2546
+ ).values(),
1448
2547
  ]
1449
- const { rows: events } = await query(
1450
- `select idx, processed_at, failed_at from a2_events
1451
- where session_id = $1 and idx = any($2::bigint[]) and idx > $3
1452
- for update`,
1453
- [sessionId, eventIndexes, clearIndex],
2548
+ // Held `for share` so a completion or dead letter releasing the
2549
+ // head's pins waits for these to commit, and sees them.
2550
+ const { rows: live } = await query(
2551
+ `select cur.consumer, cur.cursor_key, cur.head
2552
+ from a2_cursors cur
2553
+ join unnest($2::text[], $3::text[], $4::bigint[])
2554
+ as pin(consumer, cursor_key, idx)
2555
+ on cur.consumer = pin.consumer
2556
+ and cur.cursor_key = pin.cursor_key
2557
+ and cur.head = pin.idx
2558
+ where cur.session_id = $1 and cur.head > $5
2559
+ and cur.failed_at is null
2560
+ order by cur.cursor_key, cur.consumer
2561
+ for share of cur`,
2562
+ [
2563
+ sessionId,
2564
+ heads.map((pin) => pin.consumer),
2565
+ heads.map((pin) => pin.cursorKey),
2566
+ heads.map((pin) => pin.eventIndex),
2567
+ clearIndex,
2568
+ ],
1454
2569
  )
1455
2570
  const unfinished = new Set(
1456
- events
1457
- .filter(
1458
- (row) =>
1459
- row['processed_at'] === null && row['failed_at'] === null,
1460
- )
1461
- .map((row) => asNumber(row['idx'])),
2571
+ live.map(
2572
+ (row) =>
2573
+ `${String(row['consumer'])}\u0000${String(row['cursor_key'])}\u0000${asNumber(row['head'])}`,
2574
+ ),
1462
2575
  )
1463
2576
  const accepted = requestedPins.filter((pin) =>
1464
- unfinished.has(pin.eventIndex),
2577
+ unfinished.has(
2578
+ `${pin.consumer}\u0000${pin.cursorKey}\u0000${pin.eventIndex}`,
2579
+ ),
1465
2580
  )
1466
2581
  if (accepted.length > 0) {
1467
2582
  await query(
1468
2583
  `insert into a2_snapshot_pins
1469
- (session_id, event_index, reducer_name, up_to_index)
1470
- select $1, pin."eventIndex", $2, pin."snapshotIndex"
2584
+ (session_id, event_index, consumer, cursor_key, reducer_name, up_to_index)
2585
+ select $1, pin."eventIndex", pin.consumer, pin."cursorKey",
2586
+ $2, pin."snapshotIndex"
1471
2587
  from jsonb_to_recordset($3::jsonb) as pin(
1472
2588
  "eventIndex" bigint,
2589
+ consumer text,
2590
+ "cursorKey" text,
1473
2591
  "snapshotIndex" bigint
1474
2592
  )
1475
2593
  on conflict do nothing`,
@@ -1582,42 +2700,38 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1582
2700
  const atMs = meta.at.getTime()
1583
2701
  // Expiry anchors on the storage clock — the sender's `at`
1584
2702
  // orders writes but never anchors their lifetime.
1585
- const expiresAtMs = clock.now().getTime() + meta.ttlMs
2703
+ const nowMs = clock.now().getTime()
2704
+ const expiresAtMs = nowMs + meta.ttlMs
1586
2705
  const applied: Record<string, unknown> = nullProtoRecord()
1587
- const write = async (
1588
- query: PostgresConnection['query'],
1589
- ): Promise<void> => {
2706
+ const write = async (query: Query): Promise<void> => {
1590
2707
  for (const [field, value] of entries) {
1591
- // Field-wise LWW: a stored row with a strictly newer `at`
1592
- // survives; ties go to the incoming write (memory parity).
2708
+ // Field-wise LWW against values and tombstones (a JSON null
2709
+ // value, a delete's `at` kept until it expires) alike: an
2710
+ // unexpired stored row with a strictly newer `at` survives;
2711
+ // ties go to the incoming write (memory parity).
1593
2712
  // oxlint-disable-next-line no-await-in-loop -- independent per-field registers
1594
- const result = await (value === null
1595
- ? query(
1596
- `delete from a2_presence
1597
- where ns = $1 and participant = $2 and field = $3
1598
- and at <= $4${notifications ? ' returning field' : ''}`,
1599
- [ns, participant, field, atMs],
1600
- )
1601
- : query(
1602
- `insert into a2_presence
1603
- (ns, participant, field, value, seen, at, expires_at)
1604
- values ($1, $2, $3, $4::jsonb, $5, $6, $7)
1605
- on conflict (ns, participant, field) do update set
1606
- value = excluded.value,
1607
- seen = excluded.seen,
1608
- at = excluded.at,
1609
- expires_at = excluded.expires_at
1610
- where a2_presence.at <= excluded.at${notifications ? ' returning field' : ''}`,
1611
- [
1612
- ns,
1613
- participant,
1614
- field,
1615
- jsonb(value),
1616
- meta.seen,
1617
- atMs,
1618
- expiresAtMs,
1619
- ],
1620
- ))
2713
+ const result = await query(
2714
+ `insert into a2_presence
2715
+ (ns, participant, field, value, seen, at, expires_at)
2716
+ values ($1, $2, $3, $4::jsonb, $5, $6, $7)
2717
+ on conflict (ns, participant, field) do update set
2718
+ value = excluded.value,
2719
+ seen = excluded.seen,
2720
+ at = excluded.at,
2721
+ expires_at = excluded.expires_at
2722
+ where a2_presence.at <= excluded.at
2723
+ or a2_presence.expires_at <= $8${notifications ? ' returning field' : ''}`,
2724
+ [
2725
+ ns,
2726
+ participant,
2727
+ field,
2728
+ jsonb(value),
2729
+ meta.seen,
2730
+ atMs,
2731
+ expiresAtMs,
2732
+ nowMs,
2733
+ ],
2734
+ )
1621
2735
  if (result.rows.length) applied[field] = structuredClone(value)
1622
2736
  }
1623
2737
  }
@@ -1655,7 +2769,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1655
2769
  )
1656
2770
  select participant, field, value, seen, at, expires_at
1657
2771
  from a2_presence
1658
- where ns = $1 and expires_at > $2
2772
+ where ns = $1 and expires_at > $2 and value <> 'null'::jsonb
1659
2773
  order by participant, field`,
1660
2774
  [ns, now],
1661
2775
  )
@@ -1701,7 +2815,7 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1701
2815
  async clear({ sessionId, ifLastIndex, cause }) {
1702
2816
  return wrap(() =>
1703
2817
  withTx(async (query) => {
1704
- await query('select pg_advisory_xact_lock(hashtext($1))', [sessionId])
2818
+ await lockSession(query, sessionId)
1705
2819
  const { rows: headRows } = await query(
1706
2820
  `select coalesce(max(idx), 0) as head,
1707
2821
  ${clearIndexOf('$1')} as clear_index
@@ -1711,26 +2825,13 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1711
2825
  const head = asNumber(headRows[0]?.['head'])
1712
2826
  const clearIndex = asNumber(headRows[0]?.['clear_index'])
1713
2827
  if (cause) {
1714
- if (cause.index <= clearIndex)
1715
- throw supersededAttemptError(sessionId, cause)
1716
- const { rows: parents } = await query(
1717
- `select attempt_count, failed_at from a2_events
1718
- where session_id = $1 and idx = $2
1719
- for update`,
1720
- [sessionId, cause.index],
1721
- )
1722
- const parent = parents[0]
1723
- if (!parent) {
1724
- throw new TypeError(
1725
- `no event at index ${cause.index} in session '${sessionId}'`,
1726
- )
1727
- }
1728
- if (
1729
- asNumber(parent['attempt_count']) !== cause.attempt ||
1730
- parent['failed_at'] !== null
1731
- ) {
1732
- throw supersededAttemptError(sessionId, cause)
1733
- }
2828
+ await assertCurrentCause({
2829
+ query,
2830
+ sessionId,
2831
+ cause,
2832
+ clearIndex,
2833
+ lock: 'update',
2834
+ })
1734
2835
  }
1735
2836
  if (ifLastIndex !== undefined && clearIndex === ifLastIndex + 1) {
1736
2837
  return { cleared: true, index: head, clearIndex }
@@ -1742,33 +2843,38 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1742
2843
  return { cleared: true, index: head, clearIndex }
1743
2844
  }
1744
2845
  const marker = head + 1
1745
- const now = clock.now().getTime()
1746
- await insertEvent({
1747
- query,
1748
- row: {
2846
+ await query(
2847
+ `insert into a2_events
2848
+ (session_id, idx, event_type, payload, event_id, created_at, cause)
2849
+ values ($1, $2, $3, 'null'::jsonb, $4, $5, null)`,
2850
+ [
1749
2851
  sessionId,
1750
- index: marker,
1751
- type: CLEAR_EVENT_TYPE,
1752
- payload: null,
1753
- id: generateId(),
1754
- createdAt: now,
1755
- cause: null,
1756
- lane: null,
1757
- processedAt: now,
1758
- },
1759
- clearIndex,
1760
- })
1761
- // Releasing a clearing handler's claim makes a claim waiting on its
1762
- // row skip it once this commits, instead of re-running the handler.
2852
+ marker,
2853
+ CLEAR_EVENT_TYPE,
2854
+ generateId(),
2855
+ clock.now().getTime(),
2856
+ ],
2857
+ )
2858
+ // Every cursor at or below the marker is superseded by the marker
2859
+ // alone, and reclaim deletes it. Only a clearing consumer's own
2860
+ // cursor is written: releasing its head makes a claim waiting on
2861
+ // the row skip it once this commits, instead of re-running it.
1763
2862
  await query(
1764
2863
  `with released as (
1765
- update a2_events
1766
- set lane_ready = false, claim_holder = null, claim_expires_at = null
1767
- where session_id = $1 and idx = $3
2864
+ update a2_cursors
2865
+ set head = null, ${FRESH_DISPATCH}
2866
+ where session_id = $1 and cursor_key = $3 and consumer = $4
2867
+ and head = $5
1768
2868
  )
1769
2869
  insert into a2_clears (session_id, clear_index) values ($1, $2)
1770
2870
  on conflict (session_id) do update set clear_index = excluded.clear_index`,
1771
- [sessionId, marker, cause?.index ?? null],
2871
+ [
2872
+ sessionId,
2873
+ marker,
2874
+ cause ? cursorKeyOf(cause.lane, cause.index) : null,
2875
+ cause?.consumer ?? null,
2876
+ cause?.index ?? null,
2877
+ ],
1772
2878
  )
1773
2879
  return { cleared: true, index: marker, clearIndex: marker }
1774
2880
  }),
@@ -1797,6 +2903,26 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1797
2903
  ), pins as (
1798
2904
  delete from a2_snapshot_pins pin using deleted
1799
2905
  where pin.session_id = $1 and pin.event_index = deleted.idx
2906
+ ), lanes as materialized (
2907
+ -- Each lane's queue through the batch, by the lane's own key.
2908
+ select lane.ctid from a2_cursors cur
2909
+ join a2_lane_events lane
2910
+ on lane.session_id = cur.session_id
2911
+ and lane.consumer = cur.consumer and lane.lane = cur.lane
2912
+ where cur.session_id = $1 and cur.kind = 'lane'
2913
+ and lane.idx <= (select max(idx) from batch)
2914
+ for update of lane skip locked
2915
+ ), gone_lanes as (
2916
+ delete from a2_lane_events target using lanes
2917
+ where target.ctid = lanes.ctid
2918
+ ), singles as materialized (
2919
+ select cur.ctid from a2_cursors cur
2920
+ join batch on cur.cursor_key = '#' || batch.idx
2921
+ where cur.session_id = $1 and cur.kind = 'event'
2922
+ for update of cur skip locked
2923
+ ), gone_singles as (
2924
+ delete from a2_cursors target using singles
2925
+ where target.ctid = singles.ctid
1800
2926
  )
1801
2927
  select (select count(*) from deleted) as deleted,
1802
2928
  (select clear_index from boundary) as clear_index`,
@@ -1805,14 +2931,23 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1805
2931
  const deleted = asNumber(rows[0]?.['deleted'])
1806
2932
  const clearIndex = asNumber(rows[0]?.['clear_index'])
1807
2933
  if (clearIndex === 0) return { deleted: 0, done: true }
1808
- if (deleted > 0) return { deleted, done: false }
1809
2934
  const { rows: remaining } = await query(
1810
2935
  'select 1 from a2_events where session_id = $1 and idx < $2 limit 1',
1811
2936
  [sessionId, clearIndex],
1812
2937
  )
1813
- if (remaining.length > 0) return { deleted: 0, done: false }
2938
+ if (remaining.length > 0) return { deleted, done: false }
2939
+ // Writes serialize with the clear, so none lands below the marker
2940
+ // later: one sweep of what the batches left finishes the session.
1814
2941
  const { rows: swept } = await query(
1815
- `with pins as materialized (
2942
+ `with lanes as materialized (
2943
+ select ctid from a2_lane_events
2944
+ where session_id = $1 and idx < $2
2945
+ for update skip locked
2946
+ ), cursors as materialized (
2947
+ select cur.ctid from a2_cursors cur
2948
+ where ${SUPERSEDED_CURSOR}
2949
+ for update skip locked
2950
+ ), pins as materialized (
1816
2951
  select ctid from a2_snapshot_pins
1817
2952
  where session_id = $1 and up_to_index < $2
1818
2953
  for update skip locked
@@ -1824,6 +2959,12 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1824
2959
  select ctid from a2_snapshots
1825
2960
  where session_id = $1 and up_to_index < $2
1826
2961
  for update skip locked
2962
+ ), gone_lanes as (
2963
+ delete from a2_lane_events target using lanes
2964
+ where target.ctid = lanes.ctid
2965
+ ), gone_cursors as (
2966
+ delete from a2_cursors target using cursors
2967
+ where target.ctid = cursors.ctid
1827
2968
  ), gone_pins as (
1828
2969
  delete from a2_snapshot_pins target using pins where target.ctid = pins.ctid
1829
2970
  ), gone_history as (
@@ -1832,18 +2973,24 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1832
2973
  ), gone_heads as (
1833
2974
  delete from a2_snapshots target using heads where target.ctid = heads.ctid
1834
2975
  )
1835
- select (select count(*) from a2_snapshot_pins
2976
+ select (select count(*) from a2_lane_events
2977
+ where session_id = $1 and idx < $2)
2978
+ + (select count(*) from a2_cursors cur
2979
+ where ${SUPERSEDED_CURSOR})
2980
+ + (select count(*) from a2_snapshot_pins
1836
2981
  where session_id = $1 and up_to_index < $2)
1837
2982
  + (select count(*) from a2_snapshot_history
1838
2983
  where session_id = $1 and up_to_index < $2)
1839
2984
  + (select count(*) from a2_snapshots
1840
2985
  where session_id = $1 and up_to_index < $2)
2986
+ - (select count(*) from lanes)
2987
+ - (select count(*) from cursors)
1841
2988
  - (select count(*) from pins)
1842
2989
  - (select count(*) from history)
1843
2990
  - (select count(*) from heads) as skipped`,
1844
2991
  [sessionId, clearIndex],
1845
2992
  )
1846
- return { deleted: 0, done: asNumber(swept[0]?.['skipped']) === 0 }
2993
+ return { deleted, done: asNumber(swept[0]?.['skipped']) === 0 }
1847
2994
  }),
1848
2995
  )
1849
2996
  },
@@ -1855,8 +3002,8 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1855
3002
  subscription.stop()
1856
3003
  presenceSubscriptions.clear()
1857
3004
  await notifications?.close()
1858
- const lease = await connection.peek()?.catch(() => undefined)
1859
- await (injected ?? lease)?.release()
3005
+ const opened = await connection.peek()?.catch(() => undefined)
3006
+ await (injected ?? opened?.lease)?.release()
1860
3007
  },
1861
3008
  }
1862
3009
  if (notifications) {
@@ -1866,7 +3013,9 @@ export function postgres(options: PostgresStoreOptions = {}): PostgresStore {
1866
3013
  const subscription = presenceSubscriptions.get(ns)
1867
3014
  await subscription?.ready
1868
3015
  await subscription?.poll.ready
1869
- return read(ns)
3016
+ return subscription
3017
+ ? subscription.poll.snapshot(() => read(ns))
3018
+ : read(ns)
1870
3019
  }
1871
3020
  presence.subscribe = (ns, onPatch) => {
1872
3021
  if (closed) throw new A2Error('STORE_UNAVAILABLE', 'the store was closed')