experimental-a2 0.16.1 → 0.18.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 (406) hide show
  1. package/CHANGELOG.md +373 -0
  2. package/README.md +1 -1
  3. package/dist/{actor-ohPC-81x.d.ts → actor-Bjce9_5U.d.ts} +21 -11
  4. package/dist/actor-Bjce9_5U.d.ts.map +1 -0
  5. package/dist/actor-client.d.ts +2 -5
  6. package/dist/actor-client.d.ts.map +1 -1
  7. package/dist/actor-client.js +13 -4
  8. package/dist/actor-client.js.map +1 -1
  9. package/dist/actor-react.d.ts +5 -3
  10. package/dist/actor-react.d.ts.map +1 -1
  11. package/dist/actor-react.js +20 -10
  12. package/dist/actor-react.js.map +1 -1
  13. package/dist/{actor-shared-B5tJfzt-.js → actor-shared-t7w8xIKP.js} +12 -7
  14. package/dist/actor-shared-t7w8xIKP.js.map +1 -0
  15. package/dist/actor.d.ts +1 -1
  16. package/dist/actor.js +42 -23
  17. package/dist/actor.js.map +1 -1
  18. package/dist/{ai-DgOBltJ_.js → ai-BpOGm-zB.js} +621 -216
  19. package/dist/ai-BpOGm-zB.js.map +1 -0
  20. package/dist/{ai-CrEf6p_W.d.ts → ai-DrUaMyRt.d.ts} +114 -100
  21. package/dist/ai-DrUaMyRt.d.ts.map +1 -0
  22. package/dist/ai-client.d.ts +27 -0
  23. package/dist/ai-client.d.ts.map +1 -0
  24. package/dist/ai-client.js +29 -0
  25. package/dist/ai-client.js.map +1 -0
  26. package/dist/ai-fx.browser.d.ts +1 -0
  27. package/dist/ai-fx.browser.js +6 -0
  28. package/dist/ai-fx.browser.js.map +1 -0
  29. package/dist/ai-fx.d.ts +10 -0
  30. package/dist/ai-fx.d.ts.map +1 -0
  31. package/dist/ai-fx.js +634 -0
  32. package/dist/ai-fx.js.map +1 -0
  33. package/dist/ai-harness-BuLh_EYi.js +273 -0
  34. package/dist/ai-harness-BuLh_EYi.js.map +1 -0
  35. package/dist/ai-harness-DvsPeJeB.d.ts +26 -0
  36. package/dist/ai-harness-DvsPeJeB.d.ts.map +1 -0
  37. package/dist/ai-pi.browser.d.ts +1 -0
  38. package/dist/ai-pi.browser.js +6 -0
  39. package/dist/ai-pi.browser.js.map +1 -0
  40. package/dist/ai-pi.d.ts +17 -0
  41. package/dist/ai-pi.d.ts.map +1 -0
  42. package/dist/ai-pi.js +904 -0
  43. package/dist/ai-pi.js.map +1 -0
  44. package/dist/ai-react.d.ts +96 -0
  45. package/dist/ai-react.d.ts.map +1 -0
  46. package/dist/ai-react.js +142 -0
  47. package/dist/ai-react.js.map +1 -0
  48. package/dist/ai-server.browser.js +2 -2
  49. package/dist/ai-server.browser.js.map +1 -1
  50. package/dist/ai-server.d.ts +109 -43
  51. package/dist/ai-server.d.ts.map +1 -1
  52. package/dist/ai-server.js +526 -283
  53. package/dist/ai-server.js.map +1 -1
  54. package/dist/ai.d.ts +2 -2
  55. package/dist/ai.js +2 -2
  56. package/dist/bounded-cache-CPHWF-8h.js +41 -0
  57. package/dist/bounded-cache-CPHWF-8h.js.map +1 -0
  58. package/dist/{client-BYzHjkwU.js → client-BReKqwZC.js} +447 -80
  59. package/dist/client-BReKqwZC.js.map +1 -0
  60. package/dist/{client-CzyacQpJ.d.ts → client-h_DKFAjj.d.ts} +14 -13
  61. package/dist/client-h_DKFAjj.d.ts.map +1 -0
  62. package/dist/client.d.ts +4 -2
  63. package/dist/client.js +1 -1
  64. package/dist/{contract-CKRg_E4q.js → contract-BYKASlCV.js} +12 -21
  65. package/dist/contract-BYKASlCV.js.map +1 -0
  66. package/dist/{idempotent-replay-DVOlyYbx.js → idempotent-replay-JdgMQRRm.js} +2 -2
  67. package/dist/{idempotent-replay-DVOlyYbx.js.map → idempotent-replay-JdgMQRRm.js.map} +1 -1
  68. package/dist/index.d.ts +6 -5
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4 -3
  71. package/dist/{errors-DvhSXnxk.d.ts → initial-snapshot-Bx-c0pDP.d.ts} +7 -3
  72. package/dist/initial-snapshot-Bx-c0pDP.d.ts.map +1 -0
  73. package/dist/{internal-Dq2qYxou.js → internal-B2T3U4Rq.js} +29 -2
  74. package/dist/internal-B2T3U4Rq.js.map +1 -0
  75. package/dist/otel.d.ts +1 -1
  76. package/dist/react-BBXgHWFc.d.ts +86 -0
  77. package/dist/react-BBXgHWFc.d.ts.map +1 -0
  78. package/dist/react.d.ts +2 -122
  79. package/dist/react.js +107 -100
  80. package/dist/react.js.map +1 -1
  81. package/dist/reducer-BAXc8JyF.js +87 -0
  82. package/dist/reducer-BAXc8JyF.js.map +1 -0
  83. package/dist/{reducer-otzHjuJj.d.ts → reducer-CqA59Q6U.d.ts} +46 -30
  84. package/dist/reducer-CqA59Q6U.d.ts.map +1 -0
  85. package/dist/reducer-runtime-DQwz_J59.js +28 -0
  86. package/dist/reducer-runtime-DQwz_J59.js.map +1 -0
  87. package/dist/scheduler-qstash.d.ts +3 -3
  88. package/dist/scheduler-qstash.d.ts.map +1 -1
  89. package/dist/scheduler-qstash.js +33 -10
  90. package/dist/scheduler-qstash.js.map +1 -1
  91. package/dist/{scheduler-task-BpzhPnRS.js → scheduler-task-X1SToDjP.js} +18 -3
  92. package/dist/scheduler-task-X1SToDjP.js.map +1 -0
  93. package/dist/scheduler-vercel.d.ts +4 -9
  94. package/dist/scheduler-vercel.d.ts.map +1 -1
  95. package/dist/scheduler-vercel.js +25 -44
  96. package/dist/scheduler-vercel.js.map +1 -1
  97. package/dist/{server-BD5ckxZb.js → server-Dw_k_7kO.js} +904 -219
  98. package/dist/server-Dw_k_7kO.js.map +1 -0
  99. package/dist/{server-DbD7IJVK.d.ts → server-En7cbi3v.d.ts} +116 -26
  100. package/dist/server-En7cbi3v.d.ts.map +1 -0
  101. package/dist/server.d.ts +4 -4
  102. package/dist/server.js +2 -2
  103. package/dist/session-clear-Bo-XTFEy.d.ts +12 -0
  104. package/dist/session-clear-Bo-XTFEy.d.ts.map +1 -0
  105. package/dist/{errors-DCk6ch5n.js → session-clear-DGI4jTQ5.js} +17 -2
  106. package/dist/session-clear-DGI4jTQ5.js.map +1 -0
  107. package/dist/{store-DyZM6fS5.d.ts → store-Djdv0bnZ.d.ts} +73 -3
  108. package/dist/store-Djdv0bnZ.d.ts.map +1 -0
  109. package/dist/store-N8PXxDAS.js.map +1 -1
  110. package/dist/store-memory.d.ts +1 -1
  111. package/dist/store-memory.d.ts.map +1 -1
  112. package/dist/store-memory.js +205 -66
  113. package/dist/store-memory.js.map +1 -1
  114. package/dist/{store-polling-CmxUbV93.js → store-polling-B5FcwJfo.js} +21 -4
  115. package/dist/store-polling-B5FcwJfo.js.map +1 -0
  116. package/dist/store-postgres.d.ts +1 -1
  117. package/dist/store-postgres.d.ts.map +1 -1
  118. package/dist/store-postgres.js +450 -108
  119. package/dist/store-postgres.js.map +1 -1
  120. package/dist/{store-presence-polling-C7-XZyW9.js → store-presence-polling-BAwS8RWn.js} +3 -3
  121. package/dist/{store-presence-polling-C7-XZyW9.js.map → store-presence-polling-BAwS8RWn.js.map} +1 -1
  122. package/dist/store-redis-http.d.ts +1 -1
  123. package/dist/store-redis-http.d.ts.map +1 -1
  124. package/dist/store-redis-http.js +293 -11
  125. package/dist/store-redis-http.js.map +1 -1
  126. package/dist/store-redis-notify-BRITti_o.js +2493 -0
  127. package/dist/store-redis-notify-BRITti_o.js.map +1 -0
  128. package/dist/store-redis.d.ts +1 -1
  129. package/dist/store-redis.d.ts.map +1 -1
  130. package/dist/store-redis.js +11 -11
  131. package/dist/store-redis.js.map +1 -1
  132. package/dist/store-sqlite.d.ts +1 -1
  133. package/dist/store-sqlite.d.ts.map +1 -1
  134. package/dist/store-sqlite.js +197 -33
  135. package/dist/store-sqlite.js.map +1 -1
  136. package/dist/stream-buffer-Cx7AAPGq.js +23 -0
  137. package/dist/stream-buffer-Cx7AAPGq.js.map +1 -0
  138. package/dist/{telemetry-B5jzpy6y.d.ts → telemetry-Cyfqo6Qt.d.ts} +2 -2
  139. package/dist/telemetry-Cyfqo6Qt.d.ts.map +1 -0
  140. package/dist/{wire-BO5wWCb1.js → wire-rSUglYo8.js} +132 -10
  141. package/dist/wire-rSUglYo8.js.map +1 -0
  142. package/docs/01-quickstart.mdx +50 -84
  143. package/docs/{guides/07-examples.mdx → 02-examples.mdx} +10 -20
  144. package/docs/actors/01-introduction.mdx +48 -82
  145. package/docs/actors/02-concurrency.mdx +43 -70
  146. package/docs/actors/03-timers.mdx +28 -42
  147. package/docs/actors/04-routes.mdx +110 -137
  148. package/docs/actors/meta.ts +1 -1
  149. package/docs/agents/01-introduction.mdx +257 -0
  150. package/docs/agents/02-tools.mdx +247 -0
  151. package/docs/agents/03-conversations.mdx +538 -0
  152. package/docs/agents/04-harnesses.mdx +136 -0
  153. package/docs/agents/meta.ts +1 -0
  154. package/docs/client/01-react.mdx +294 -0
  155. package/docs/client/02-transports.mdx +86 -0
  156. package/docs/client/03-presence.mdx +224 -0
  157. package/docs/client/meta.ts +1 -0
  158. package/docs/contract/01-events.mdx +57 -0
  159. package/docs/contract/02-state.mdx +142 -0
  160. package/docs/contract/03-sessions.mdx +175 -0
  161. package/docs/index.mdx +112 -156
  162. package/docs/reference/01-core.mdx +139 -0
  163. package/docs/reference/02-server.mdx +855 -0
  164. package/docs/reference/03-client.mdx +301 -0
  165. package/docs/reference/04-ai.mdx +722 -0
  166. package/docs/reference/05-adapters.mdx +351 -0
  167. package/docs/reference/06-errors.mdx +138 -0
  168. package/docs/reference/meta.ts +1 -1
  169. package/docs/server/01-handlers.mdx +200 -0
  170. package/docs/server/02-timers.mdx +130 -0
  171. package/docs/server/03-cancellation.mdx +141 -0
  172. package/docs/server/04-routes.mdx +243 -0
  173. package/docs/server/05-production.mdx +322 -0
  174. package/docs/server/06-guarantees.mdx +123 -0
  175. package/examples/playground/DEPLOY.md +53 -7
  176. package/examples/playground/app/a0/[appId]/loading.tsx +16 -0
  177. package/examples/playground/app/a0/[appId]/page.tsx +53 -0
  178. package/examples/playground/app/a0/a0.css +818 -0
  179. package/examples/playground/app/a0/app-list.tsx +218 -0
  180. package/examples/playground/app/a0/app-view.tsx +63 -0
  181. package/examples/playground/app/a0/apps/[appId]/preview/route.ts +27 -0
  182. package/examples/playground/app/a0/apps/[appId]/route.ts +28 -0
  183. package/examples/playground/app/a0/apps.test.ts +128 -0
  184. package/examples/playground/app/a0/apps.ts +102 -0
  185. package/examples/playground/app/a0/chat.tsx +410 -0
  186. package/examples/playground/app/a0/columns.tsx +109 -0
  187. package/examples/playground/app/a0/context-menu.tsx +116 -0
  188. package/examples/playground/app/a0/cookies.ts +46 -0
  189. package/examples/playground/app/a0/crash-on-request.ts +72 -0
  190. package/examples/playground/app/a0/crashes/route.ts +3 -0
  191. package/examples/playground/app/a0/crashes.test.ts +33 -0
  192. package/examples/playground/app/a0/crashes.ts +28 -0
  193. package/examples/playground/app/a0/devtools.tsx +164 -0
  194. package/examples/playground/app/a0/events/route.ts +6 -0
  195. package/examples/playground/app/a0/flags.ts +30 -0
  196. package/examples/playground/app/a0/fx/events/route.ts +9 -0
  197. package/examples/playground/app/a0/fx/scheduler/route.ts +7 -0
  198. package/examples/playground/app/a0/fx/short/events/route.ts +11 -0
  199. package/examples/playground/app/a0/layout.tsx +38 -0
  200. package/examples/playground/app/a0/list/route.ts +10 -0
  201. package/examples/playground/app/a0/model.test.ts +73 -0
  202. package/examples/playground/app/a0/model.ts +95 -0
  203. package/examples/playground/app/a0/no-photon.cjs +4 -0
  204. package/examples/playground/app/a0/no-quickjs.cjs +3 -0
  205. package/examples/playground/app/a0/page.tsx +14 -0
  206. package/examples/playground/app/a0/pi/events/route.ts +9 -0
  207. package/examples/playground/app/a0/pi/scheduler/route.ts +7 -0
  208. package/examples/playground/app/a0/pi/short/events/route.ts +11 -0
  209. package/examples/playground/app/a0/preview-pane.tsx +145 -0
  210. package/examples/playground/app/a0/preview.test.ts +53 -0
  211. package/examples/playground/app/a0/preview.ts +26 -0
  212. package/examples/playground/app/a0/sandbox-log.ts +75 -0
  213. package/examples/playground/app/a0/sandbox-session.ts +152 -0
  214. package/examples/playground/app/a0/sandbox.ts +191 -0
  215. package/examples/playground/app/a0/sandboxes/scheduler/route.ts +6 -0
  216. package/examples/playground/app/a0/scheduler/route.ts +7 -0
  217. package/examples/playground/app/a0/server.ts +213 -0
  218. package/examples/playground/app/a0/session.ts +59 -0
  219. package/examples/playground/app/a0/shell.tsx +158 -0
  220. package/examples/playground/app/a0/short/events/route.ts +9 -0
  221. package/examples/playground/app/a0/splitter.tsx +120 -0
  222. package/examples/playground/app/agent/[agentId]/agent-client.tsx +144 -174
  223. package/examples/playground/app/agent/[agentId]/command-receipt.test.tsx +65 -0
  224. package/examples/playground/app/agent/[agentId]/command-receipt.tsx +27 -0
  225. package/examples/playground/app/agent/[agentId]/compaction-event.tsx +25 -24
  226. package/examples/playground/app/agent/[agentId]/compaction-panel.test.tsx +119 -0
  227. package/examples/playground/app/agent/[agentId]/compaction-panel.tsx +208 -165
  228. package/examples/playground/app/agent/[agentId]/page.tsx +2 -16
  229. package/examples/playground/app/agent/events/route.ts +5 -2
  230. package/examples/playground/app/agent/ingress.test.ts +101 -0
  231. package/examples/playground/app/agent/model-input.test.tsx +58 -0
  232. package/examples/playground/app/agent/model-input.tsx +115 -0
  233. package/examples/playground/app/agent/model.test.ts +73 -0
  234. package/examples/playground/app/agent/model.ts +58 -26
  235. package/examples/playground/app/agent/new-agent-session.tsx +37 -19
  236. package/examples/playground/app/agent/page.tsx +4 -4
  237. package/examples/playground/app/agent/server.ts +19 -15
  238. package/examples/playground/app/agent/session.ts +5 -6
  239. package/examples/playground/app/agent/telemetry.test.ts +189 -0
  240. package/examples/playground/app/agent/telemetry.ts +30 -0
  241. package/examples/playground/app/agent/trace.test.ts +23 -0
  242. package/examples/playground/app/agent/trace.ts +18 -0
  243. package/examples/playground/app/canvas/[canvasId]/canvas-client.tsx +12 -12
  244. package/examples/playground/app/canvas/[canvasId]/page.tsx +1 -2
  245. package/examples/playground/app/canvas/[canvasId]/session.ts +2 -2
  246. package/examples/playground/app/canvas/model.ts +53 -53
  247. package/examples/playground/app/chat/[chatId]/agent-state.test.ts +176 -0
  248. package/examples/playground/app/chat/[chatId]/agent-stream-drawer.tsx +16 -1
  249. package/examples/playground/app/chat/[chatId]/chat-client.tsx +55 -77
  250. package/examples/playground/app/chat/[chatId]/chat-view.ts +37 -3
  251. package/examples/playground/app/chat/[chatId]/page.tsx +7 -15
  252. package/examples/playground/app/chat/[chatId]/session.ts +2 -19
  253. package/examples/playground/app/chat/agent/route.ts +9 -0
  254. package/examples/playground/app/chat/model.test.ts +46 -119
  255. package/examples/playground/app/chat/model.ts +82 -128
  256. package/examples/playground/app/chat/scheduler/route.ts +2 -2
  257. package/examples/playground/app/chat/server.test.ts +112 -0
  258. package/examples/playground/app/chat/server.ts +113 -117
  259. package/examples/playground/app/components/activity-feed.tsx +30 -1
  260. package/examples/playground/app/components/activity-history.test.tsx +35 -0
  261. package/examples/playground/app/components/demo-icons.tsx +89 -0
  262. package/examples/playground/app/components/demos.tsx +33 -0
  263. package/examples/playground/app/components/fast-link.test.tsx +58 -0
  264. package/examples/playground/app/components/fast-link.tsx +55 -0
  265. package/examples/playground/app/components/site-header.tsx +96 -0
  266. package/examples/playground/app/counter/counter-client.tsx +3 -3
  267. package/examples/playground/app/counter/model.test.ts +3 -3
  268. package/examples/playground/app/counter/model.ts +11 -21
  269. package/examples/playground/app/counter/page.tsx +2 -15
  270. package/examples/playground/app/counter/session.ts +2 -2
  271. package/examples/playground/app/documents/[documentId]/code-editor.tsx +12 -1
  272. package/examples/playground/app/documents/[documentId]/document-client.tsx +10 -11
  273. package/examples/playground/app/documents/[documentId]/page.tsx +1 -2
  274. package/examples/playground/app/documents/[documentId]/session.ts +2 -2
  275. package/examples/playground/app/documents/model.ts +27 -27
  276. package/examples/playground/app/globals.css +420 -207
  277. package/examples/playground/app/initial-state.test.tsx +54 -0
  278. package/examples/playground/app/layout.tsx +7 -23
  279. package/examples/playground/app/orders/[orderId]/page.tsx +1 -2
  280. package/examples/playground/app/orders/[orderId]/session.ts +2 -2
  281. package/examples/playground/app/orders/model.ts +49 -49
  282. package/examples/playground/app/page.tsx +13 -102
  283. package/examples/playground/app/recovery/[recoveryId]/page.tsx +1 -2
  284. package/examples/playground/app/recovery/[recoveryId]/session.ts +2 -2
  285. package/examples/playground/app/recovery/model.ts +30 -30
  286. package/examples/playground/instrumentation.ts +5 -0
  287. package/examples/playground/next.config.ts +17 -5
  288. package/examples/playground/package.json +13 -2
  289. package/examples/playground/scripts/a0-template.ts +77 -0
  290. package/examples/playground/vercel.json +36 -0
  291. package/package.json +29 -3
  292. package/src/actor-client.ts +16 -4
  293. package/src/actor-react.ts +25 -11
  294. package/src/actor-shared.ts +21 -6
  295. package/src/actor.ts +76 -31
  296. package/src/ai-agent-actions.ts +219 -0
  297. package/src/ai-agent.ts +2517 -0
  298. package/src/ai-app-state.ts +91 -0
  299. package/src/ai-client-state.ts +6 -3
  300. package/src/ai-client.ts +62 -0
  301. package/src/ai-context-schema.ts +25 -5
  302. package/src/ai-context.ts +90 -40
  303. package/src/ai-control-server.ts +92 -72
  304. package/src/ai-coordinator.ts +23 -22
  305. package/src/ai-fx.browser.ts +5 -0
  306. package/src/ai-fx.ts +833 -0
  307. package/src/ai-harness.ts +518 -0
  308. package/src/ai-message-base.ts +209 -0
  309. package/src/ai-message-projection.ts +262 -64
  310. package/src/ai-model-metadata.ts +11 -6
  311. package/src/ai-pi.browser.ts +5 -0
  312. package/src/ai-pi.ts +1263 -0
  313. package/src/ai-progress-batches.ts +26 -0
  314. package/src/ai-projector.ts +95 -14
  315. package/src/ai-react.ts +146 -0
  316. package/src/ai-sdk-step.ts +2 -2
  317. package/src/ai-server.browser.ts +2 -2
  318. package/src/ai-server.ts +23 -1965
  319. package/src/ai-stored-state.ts +48 -163
  320. package/src/ai.ts +339 -155
  321. package/src/bootstrap-stream.ts +72 -0
  322. package/src/bounded-cache.ts +55 -0
  323. package/src/client-render-session.ts +49 -0
  324. package/src/client.ts +688 -151
  325. package/src/contract.ts +92 -42
  326. package/src/deterministic-id.ts +37 -0
  327. package/src/equal-json.ts +32 -0
  328. package/src/errors.ts +13 -1
  329. package/src/index.ts +5 -1
  330. package/src/initial-snapshot.ts +39 -0
  331. package/src/internal.ts +52 -0
  332. package/src/libfx.d.ts +34 -0
  333. package/src/postgres-notification-scope.ts +14 -3
  334. package/src/react.ts +171 -206
  335. package/src/redis-http-reply.ts +256 -0
  336. package/src/redis-http-stream.ts +73 -0
  337. package/src/redis-http-subscriptions.ts +13 -5
  338. package/src/reducer-runtime.ts +52 -0
  339. package/src/reducer.ts +93 -11
  340. package/src/scheduler-qstash.ts +49 -20
  341. package/src/scheduler-task.ts +34 -2
  342. package/src/scheduler-vercel.ts +32 -84
  343. package/src/server-fetch.ts +139 -35
  344. package/src/server.ts +1133 -255
  345. package/src/session-clear.ts +23 -0
  346. package/src/session-socket.ts +154 -49
  347. package/src/sse.ts +71 -4
  348. package/src/store-memory.ts +252 -75
  349. package/src/store-polling.ts +18 -4
  350. package/src/store-postgres.ts +541 -114
  351. package/src/store-redis-core.ts +1450 -493
  352. package/src/store-redis-http.ts +18 -2
  353. package/src/store-redis-notify.ts +262 -85
  354. package/src/store-redis.ts +19 -9
  355. package/src/store-sqlite.ts +319 -45
  356. package/src/store.ts +79 -1
  357. package/src/stream-buffer.ts +43 -0
  358. package/src/telemetry.ts +14 -1
  359. package/src/wire.ts +241 -13
  360. package/dist/actor-ohPC-81x.d.ts.map +0 -1
  361. package/dist/actor-shared-B5tJfzt-.js.map +0 -1
  362. package/dist/ai-CrEf6p_W.d.ts.map +0 -1
  363. package/dist/ai-DgOBltJ_.js.map +0 -1
  364. package/dist/client-BYzHjkwU.js.map +0 -1
  365. package/dist/client-CzyacQpJ.d.ts.map +0 -1
  366. package/dist/contract-CKRg_E4q.js.map +0 -1
  367. package/dist/errors-DCk6ch5n.js.map +0 -1
  368. package/dist/errors-DvhSXnxk.d.ts.map +0 -1
  369. package/dist/internal-Dq2qYxou.js.map +0 -1
  370. package/dist/react.d.ts.map +0 -1
  371. package/dist/reducer-DEMjEY_O.js +0 -29
  372. package/dist/reducer-DEMjEY_O.js.map +0 -1
  373. package/dist/reducer-otzHjuJj.d.ts.map +0 -1
  374. package/dist/scheduler-task-BpzhPnRS.js.map +0 -1
  375. package/dist/server-BD5ckxZb.js.map +0 -1
  376. package/dist/server-DbD7IJVK.d.ts.map +0 -1
  377. package/dist/store-DyZM6fS5.d.ts.map +0 -1
  378. package/dist/store-polling-CmxUbV93.js.map +0 -1
  379. package/dist/store-redis-notify-BVLUfI3j.js +0 -1583
  380. package/dist/store-redis-notify-BVLUfI3j.js.map +0 -1
  381. package/dist/telemetry-B5jzpy6y.d.ts.map +0 -1
  382. package/dist/validate-XKT4FSNn.js +0 -30
  383. package/dist/validate-XKT4FSNn.js.map +0 -1
  384. package/dist/wire-BO5wWCb1.js.map +0 -1
  385. package/docs/concepts/01-contracts.mdx +0 -142
  386. package/docs/concepts/02-handlers.mdx +0 -284
  387. package/docs/concepts/03-durability.mdx +0 -314
  388. package/docs/concepts/04-state.mdx +0 -209
  389. package/docs/guides/01-timers.mdx +0 -185
  390. package/docs/guides/02-cancellation.mdx +0 -161
  391. package/docs/guides/03-react.mdx +0 -384
  392. package/docs/guides/04-local-first.mdx +0 -88
  393. package/docs/guides/05-production.mdx +0 -521
  394. package/docs/guides/06-ai-agents.mdx +0 -1318
  395. package/docs/guides/08-application-data.mdx +0 -113
  396. package/docs/guides/09-presence.mdx +0 -266
  397. package/docs/guides/10-transports.mdx +0 -221
  398. package/docs/reference/01-api.mdx +0 -1896
  399. package/docs/reference/02-errors.mdx +0 -149
  400. package/examples/playground/app/agent/[agentId]/compaction/route.ts +0 -14
  401. package/examples/playground/app/agent/compaction-settings.test.ts +0 -144
  402. package/examples/playground/app/agent/compaction-settings.ts +0 -49
  403. package/examples/playground/app/agent/compaction-timeline.test.ts +0 -377
  404. package/examples/playground/app/agent/compaction-timeline.ts +0 -208
  405. /package/docs/{concepts → contract}/meta.ts +0 -0
  406. /package/docs/{guides → server}/meta.ts +0 -0
@@ -6,8 +6,11 @@
6
6
  *
7
7
  * The stream IS the store: each event is a stream entry whose ID is its
8
8
  * dense a2 index (`${index}-0`), so `XRANGE` is the catch-up read.
9
- * Mutable drain/failure bookkeeping lives in a side hash (stream
10
- * entries are immutable); every write path is one atomic Lua script.
9
+ * Mutable drain/failure bookkeeping lives in side hashes of fixed index
10
+ * ranges (stream entries are immutable); every write path is one atomic
11
+ * Lua script.
12
+ * Global event-id ownership is one key per id, so no key grows with
13
+ * the number of sessions.
11
14
  * The conformance suite in test/conformance is the executable
12
15
  * contract, run against a real spawned `redis-server`.
13
16
  *
@@ -17,10 +20,12 @@
17
20
  */
18
21
 
19
22
  import type { PresencePatch } from './contract.ts'
20
- import { A2Error } from './errors.ts'
23
+ import { A2Error, supersededAttemptError } from './errors.ts'
24
+ import { CLEAR_EVENT_TYPE, sessionClearedError } from './session-clear.ts'
21
25
  import { idempotentReplay } from './idempotent-replay.ts'
22
26
  import { nullProtoRecord } from './internal.ts'
23
27
  import { decodeReturnedEventIds } from './store-codec.ts'
28
+ import type { SerializedEvent } from './stream-buffer.ts'
24
29
  import type {
25
30
  A2Store,
26
31
  Clock,
@@ -41,12 +46,25 @@ export type RedisCall = (
41
46
 
42
47
  export type RedisStoreCoreOptions = {
43
48
  call: RedisCall
49
+ /**
50
+ * Issues a command whose bulk replies arrive as raw bytes. Live feed
51
+ * catch-up pages use it to decode only the fields a row needs.
52
+ */
53
+ callBytes?: RedisCall
44
54
  clock: Clock
45
55
  ids: IdSource
46
56
  keyPrefix: string
47
57
  presenceSource: string
58
+ readStream?: RedisStreamRead
48
59
  }
49
60
 
61
+ /** Stream entries from `from` onward, in nonempty ordered batches. */
62
+ export type RedisStreamRead = (options: {
63
+ key: string
64
+ from: string
65
+ signal: AbortSignal
66
+ }) => AsyncGenerator<StreamEntry[], void>
67
+
50
68
  export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
51
69
  /**
52
70
  * The storage half of the presence capability, shared by both
@@ -72,233 +90,508 @@ export type RedisStoreCore = Omit<A2Store, 'stream' | 'presence'> & {
72
90
  /** One bounded XRANGE page of public events for live feeds. */
73
91
  readEvents(sessionId: string, afterIndex: number): Promise<Event[]>
74
92
  /**
75
- * Declare the session watched for `ttlMs` — writes publish wake-ups
76
- * only while this presence marker lives. Live feeds refresh it
77
- * ahead of expiry; an expired marker degrades latency to the safety
78
- * cadence, never delivery.
93
+ * One bounded XRANGE page of catch-up rows for a live feed without a
94
+ * streaming reader, payloads left as stored JSON. The reply is held whole,
95
+ * so a page retains up to `FEED_PAGE_SIZE` entries.
96
+ */
97
+ readFeedPage(
98
+ sessionId: string,
99
+ afterIndex: number,
100
+ ): Promise<SerializedEvent[]>
101
+ /** Catch-up events after `afterIndex`, in nonempty ordered batches. */
102
+ streamEvents?: (options: {
103
+ sessionId: string
104
+ afterIndex: number
105
+ signal: AbortSignal
106
+ }) => AsyncGenerator<SerializedEvent[], void>
107
+ /**
108
+ * Events from `fromIndex` through `throughIndex` or the newest entry in
109
+ * one newest-first page, or null when the range doesn't fit in one.
79
110
  */
80
- markWatched(sessionId: string, ttlMs: number): Promise<void>
111
+ readTail(
112
+ sessionId: string,
113
+ fromIndex: number,
114
+ throughIndex?: number,
115
+ ): Promise<Event[] | null>
81
116
  }
82
117
 
83
- const READ_PAGE_SIZE = 256
118
+ export const READ_PAGE_SIZE = 256
119
+ export const FEED_PAGE_SIZE = 1000
84
120
  const READ_CONCURRENCY = 16
85
121
 
86
122
  // ── Lua scripts (each runs atomically) ───────────────────────────────
87
123
 
124
+ const META_CHUNK_SIZE = 1000
125
+
126
+ /** The metadata columns of a full stored row. */
127
+ const STORED_EVENT_META_SUFFIXES = [
128
+ 'p',
129
+ 'pa',
130
+ 'ri',
131
+ 'ch',
132
+ 'ce',
133
+ 'f',
134
+ 'fc',
135
+ 'lc',
136
+ 'lf',
137
+ 'lfa',
138
+ 'd',
139
+ 'a',
140
+ 'e',
141
+ ] as const
142
+
88
143
  /**
89
- * KEYS: store stream, counter, global id hash, metadata, pending, ready,
90
- * watched.
91
- * ARGV: sessionId, nowMs, n, then per event:
92
- * id, type, payloadJson, causeJson, lane, settled; finally notify channel.
93
- * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
94
- * ['dup', hasPending, ...indexes] | ['partial', count] |
95
- * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx].
144
+ * Upstash bills every call a script makes, so scripts read the counter and
145
+ * split with one MGET, read metadata with one HMGET per hash (`read_fields`),
146
+ * and buffer hash writes (`put`) into one HSET per hash (`flush`). Buffered
147
+ * writes are invisible to reads until flushed, so a script must not read a
148
+ * field it has buffered. Per-event metadata lives in hashes of
149
+ * META_CHUNK_SIZE consecutive indexes, so no key grows with a session's
150
+ * history. Indexes at or below the split predate chunking and stay in the
151
+ * base hash, which also holds every session's lane pointers. An unset split
152
+ * means the current event count, so any script that adds events must write
153
+ * `split_write` pairs in its write phase. Including scripts bind COUNTER,
154
+ * META (the base hash), and SPLIT first.
96
155
  */
97
- const APPEND_LUA = `
156
+ const META_LUA = `
157
+ local split, split_stored = nil, false
158
+ -- The latest clear marker lives beside the counter, {prefix}:{session}:clear,
159
+ -- and is read in the same MGET, so a session never cleared pays nothing.
160
+ local CLEAR = string.sub(COUNTER, 1, -6) .. 'clear'
161
+ local boundary = 0
162
+ local function use_split(stored, count, cleared)
163
+ split_stored = stored ~= false
164
+ split = tonumber(stored or count or '0')
165
+ boundary = tonumber(cleared or '0')
166
+ end
167
+ local function mkey(field)
168
+ if split == nil then
169
+ local values = redis.call('MGET', SPLIT, COUNTER, CLEAR)
170
+ use_split(values[1], values[2], values[3])
171
+ end
172
+ local idx = tonumber(field)
173
+ if idx <= split then return META end
174
+ return META .. ':' .. tostring(math.floor((idx - 1) / ${META_CHUNK_SIZE}))
175
+ end
176
+ local function split_write(args)
177
+ if not split_stored then
178
+ args[#args + 1] = SPLIT
179
+ args[#args + 1] = tostring(split)
180
+ end
181
+ end
182
+ local function sliced(command, key, args)
183
+ local results = {}
184
+ for first = 1, #args, 1000 do
185
+ local last = math.min(first + 999, #args)
186
+ local reply
187
+ if key then
188
+ reply = redis.call(command, key, unpack(args, first, last))
189
+ else
190
+ reply = redis.call(command, unpack(args, first, last))
191
+ end
192
+ if type(reply) == 'table' then
193
+ for _, value in ipairs(reply) do results[#results + 1] = value end
194
+ end
195
+ end
196
+ return results
197
+ end
198
+ local function read_fields(fields, suffixes)
199
+ local groups, order = {}, {}
200
+ for _, field in ipairs(fields) do
201
+ local key = mkey(field)
202
+ local group = groups[key]
203
+ if not group then
204
+ group = { fields = {}, names = {} }
205
+ groups[key] = group
206
+ order[#order + 1] = key
207
+ end
208
+ group.fields[#group.fields + 1] = field
209
+ for _, suffix in ipairs(suffixes) do
210
+ group.names[#group.names + 1] = field .. ':' .. suffix
211
+ end
212
+ end
213
+ local rows = {}
214
+ for _, key in ipairs(order) do
215
+ local group = groups[key]
216
+ local values = sliced('HMGET', key, group.names)
217
+ for position, field in ipairs(group.fields) do
218
+ local row = {}
219
+ for column = 1, #suffixes do
220
+ row[column] = values[(position - 1) * #suffixes + column]
221
+ end
222
+ rows[field] = row
223
+ end
224
+ end
225
+ return rows
226
+ end
227
+ local writes, write_order = {}, {}
228
+ local function put(key, field, value)
229
+ local fields = writes[key]
230
+ if not fields then
231
+ fields = {}
232
+ writes[key] = fields
233
+ write_order[#write_order + 1] = key
234
+ end
235
+ fields[#fields + 1] = field
236
+ fields[#fields + 1] = value
237
+ end
238
+ local function flush()
239
+ for _, key in ipairs(write_order) do sliced('HSET', key, writes[key]) end
240
+ writes, write_order = {}, {}
241
+ end
242
+ `
243
+
244
+ /**
245
+ * Lane-aware enqueueing for scripts that add events. Including scripts bind
246
+ * PENDING and READY. Lane tails are read once per lane and tracked in
247
+ * memory, so a batch in one lane links its events without re-reading. A
248
+ * lane emptied earlier in the script (`clear_lane`) is known to have no tail,
249
+ * and its pointers are deleted at `flush_queue` only if nothing re-filled it.
250
+ */
251
+ const ENQUEUE_LUA = `
98
252
  local function lane_field(lane, suffix)
99
253
  return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
100
254
  end
101
- local function enqueue(field, idx, lane)
102
- redis.call('ZADD', KEYS[5], idx, field)
255
+ local queued, ready_now, lane_tails, lane_order = {}, {}, {}, {}
256
+ local function enqueue(field, lane)
257
+ local score = tonumber(field)
258
+ queued[#queued + 1] = score
259
+ queued[#queued + 1] = field
103
260
  if lane == '' then
104
- redis.call('ZADD', KEYS[6], idx, field)
261
+ ready_now[#ready_now + 1] = score
262
+ ready_now[#ready_now + 1] = field
105
263
  return
106
264
  end
107
- local tail_key = lane_field(lane, 'tail')
108
- local tail = redis.call('HGET', KEYS[4], tail_key)
265
+ put(mkey(field), field .. ':l', lane)
266
+ local tail = lane_tails[lane]
267
+ if tail == nil then
268
+ tail = redis.call('HGET', META, lane_field(lane, 'tail'))
269
+ if tail and tonumber(tail) <= boundary then tail = false end
270
+ lane_order[#lane_order + 1] = lane
271
+ end
109
272
  if tail then
110
- redis.call('HSET', KEYS[4], tail .. ':next', field)
273
+ put(mkey(tail), tail .. ':next', field)
111
274
  else
112
- redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
113
- redis.call('ZADD', KEYS[6], idx, field)
275
+ put(META, lane_field(lane, 'head'), field)
276
+ ready_now[#ready_now + 1] = score
277
+ ready_now[#ready_now + 1] = field
278
+ end
279
+ lane_tails[lane] = field
280
+ end
281
+ local function clear_lane(lane)
282
+ if lane_tails[lane] == nil then lane_order[#lane_order + 1] = lane end
283
+ lane_tails[lane] = false
284
+ end
285
+ local function flush_queue()
286
+ for _, lane in ipairs(lane_order) do
287
+ if lane_tails[lane] then
288
+ put(META, lane_field(lane, 'tail'), lane_tails[lane])
289
+ else
290
+ redis.call('HDEL', META, lane_field(lane, 'head'), lane_field(lane, 'tail'))
291
+ end
114
292
  end
115
- redis.call('HSET', KEYS[4], tail_key, field)
293
+ sliced('ZADD', PENDING, queued)
294
+ sliced('ZADD', READY, ready_now)
295
+ end
296
+ `
297
+
298
+ /**
299
+ * Settles one unfinished event for scripts that include ENQUEUE_LUA: it
300
+ * leaves the pending and ready sets, and a lane head hands its lane to the
301
+ * next unfinished event. A dead-lettered successor stays out of the ready set.
302
+ */
303
+ const SETTLE_LUA = `
304
+ local function settle(field, lane, next_field)
305
+ redis.call('ZREM', PENDING, field)
306
+ redis.call('ZREM', READY, field)
307
+ if lane == '' then return end
308
+ local head_key = lane_field(lane, 'head')
309
+ if redis.call('HGET', META, head_key) ~= field then return end
310
+ while next_field do
311
+ local row = read_fields({ next_field }, { 'p', 'f', 'next' })[next_field]
312
+ if not row[1] then
313
+ redis.call('HSET', META, head_key, next_field)
314
+ if not row[2] then
315
+ redis.call('ZADD', READY, tonumber(next_field), next_field)
316
+ end
317
+ return
318
+ end
319
+ next_field = row[3]
320
+ end
321
+ clear_lane(lane)
322
+ end
323
+ `
324
+
325
+ /**
326
+ * Event-id ownership for scripts that add events. Each script reads the
327
+ * counter, split, clear boundary, and per-id keys with one MGET and applies
328
+ * the first three. Returns the owner record, or false, per event, and the
329
+ * counter. A record this session made below its latest clear is free.
330
+ */
331
+ const ID_OWNERS_LUA = `
332
+ local function read_owners(key_offset, n, session)
333
+ local keys = { COUNTER, SPLIT, CLEAR }
334
+ for i = 1, n do keys[#keys + 1] = KEYS[key_offset + i] end
335
+ local values = sliced('MGET', nil, keys)
336
+ use_split(values[2], values[1], values[3])
337
+ local owners = {}
338
+ for i = 1, n do
339
+ local owner = values[3 + i]
340
+ if owner then
341
+ local rec = cjson.decode(owner)
342
+ if rec[1] == session and tonumber(rec[2]) < boundary then owner = false end
343
+ end
344
+ owners[i] = owner
345
+ end
346
+ return owners, values[1]
347
+ end
348
+ `
349
+
350
+ const WAKE_PAYLOAD_LIMIT = 32_768
351
+
352
+ /**
353
+ * Wake-up messages carry the events they announce, so a subscribed reader
354
+ * that is caught up applies them without reading the stream:
355
+ * `{"f":frontier,"t":createdAt,"e":[[index,id,type,payload],...]}`. Payloads
356
+ * are the stored JSON text, inlined verbatim. A batch over
357
+ * WAKE_PAYLOAD_LIMIT bytes publishes `{"f":frontier}` and readers catch up
358
+ * with XRANGE.
359
+ */
360
+ const WAKE_LUA = `
361
+ local wake_entries, wake_bytes = {}, 0
362
+ local function wake_add(field, id, typ, payload)
363
+ if wake_bytes + #payload > ${WAKE_PAYLOAD_LIMIT} then
364
+ wake_bytes = ${WAKE_PAYLOAD_LIMIT} + 1
365
+ return
366
+ end
367
+ local entry = '[' .. field .. ',' .. cjson.encode(id) .. ',' ..
368
+ cjson.encode(typ) .. ',' .. payload .. ']'
369
+ wake_bytes = wake_bytes + #entry + 1
370
+ if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
371
+ wake_entries[#wake_entries + 1] = entry
372
+ end
373
+ end
374
+ local function wake(channel, frontier, created_at)
375
+ local message = '{"f":' .. frontier .. '}'
376
+ if wake_bytes <= ${WAKE_PAYLOAD_LIMIT} then
377
+ message = '{"f":' .. frontier .. ',"t":' .. created_at .. ',"e":[' ..
378
+ table.concat(wake_entries, ',') .. ']}'
379
+ end
380
+ redis.pcall('PUBLISH', channel, message)
381
+ end
382
+ `
383
+
384
+ const RELEASE_PINS_LUA = `
385
+ local function release_snapshot_pins(pins, counts)
386
+ local refs = redis.call('SMEMBERS', pins)
387
+ if #refs == 0 then return end
388
+ for _, ref in ipairs(refs) do
389
+ local count = redis.call('HINCRBY', counts, ref, -1)
390
+ if count <= 0 then
391
+ redis.call('HDEL', counts, ref)
392
+ local checkpoint = cjson.decode(ref)
393
+ redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
394
+ end
395
+ end
396
+ redis.call('DEL', pins)
397
+ end
398
+ `
399
+
400
+ /**
401
+ * KEYS: store stream, counter, metadata, pending, ready, metadata split,
402
+ * then one id ownership key per event.
403
+ * ARGV: sessionId, nowMs, n, then per event:
404
+ * id, type, payloadJson, causeJson, lane, settled; then notify channel and
405
+ * origin index or ''.
406
+ * Returns ['empty', hasPending] | ['ok', firstIndex, hasPending] |
407
+ * ['dup', hasPending, ...indexes] | ['partial', count] |
408
+ * ['foreign', eventId] | ['no_parent', idx] | ['superseded', idx] |
409
+ * ['cleared', originIndex].
410
+ */
411
+ const APPEND_LUA = `
412
+ local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[6], KEYS[4], KEYS[5]
413
+ ${META_LUA}
414
+ ${ENQUEUE_LUA}
415
+ ${ID_OWNERS_LUA}
416
+ ${WAKE_LUA}
417
+ local function has_pending()
418
+ return redis.call('ZCARD', PENDING) > 0 and '1' or '0'
116
419
  end
117
420
  local n = tonumber(ARGV[3])
118
- local base = tonumber(redis.call('GET', KEYS[2]) or '0')
119
- local has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
120
- if n == 0 then return { 'empty', has_pending } end
421
+ if n == 0 then return { 'empty', has_pending() } end
422
+ local owners, count = read_owners(6, n, ARGV[1])
423
+ local base = tonumber(count or '0')
121
424
  local dups = {}
122
425
  for i = 1, n do
123
- local id = ARGV[3 + (i - 1) * 6 + 1]
124
- local existing = redis.call('HGET', KEYS[3], id)
125
- if existing then
126
- local rec = cjson.decode(existing)
127
- if rec[1] ~= ARGV[1] then return { 'foreign', id } end
426
+ if owners[i] then
427
+ local rec = cjson.decode(owners[i])
428
+ if rec[1] ~= ARGV[1] then return { 'foreign', ARGV[3 + (i - 1) * 6 + 1] } end
128
429
  dups[#dups + 1] = tostring(rec[2])
129
430
  end
130
431
  end
131
432
  if #dups == n then
132
- local res = { 'dup', has_pending }
433
+ local res = { 'dup', has_pending() }
133
434
  for i = 1, #dups do res[#res + 1] = dups[i] end
134
435
  return res
135
436
  end
136
437
  if #dups > 0 then return { 'partial', tostring(#dups) } end
438
+ local origin = ARGV[5 + n * 6]
439
+ if origin and origin ~= '' and tonumber(origin) <= boundary then
440
+ return { 'cleared', origin }
441
+ end
442
+ local causes, parents = {}, {}
137
443
  for i = 1, n do
138
444
  local cause = ARGV[3 + (i - 1) * 6 + 4]
139
445
  if cause ~= '' then
140
446
  local parsed = cjson.decode(cause)
141
447
  local pidx = tonumber(parsed.index)
142
- if pidx < 1 or pidx > base then
143
- return { 'no_parent', tostring(parsed.index) }
144
- end
145
- local field = tostring(pidx)
146
- local dispatches = tonumber(redis.call('HGET', KEYS[4], field .. ':d') or '0')
147
- if dispatches ~= tonumber(parsed.attempt) or
148
- redis.call('HEXISTS', KEYS[4], field .. ':f') == 1 then
149
- return { 'superseded', field }
150
- end
448
+ causes[#causes + 1] = parsed
449
+ if pidx >= 1 and pidx <= base then parents[#parents + 1] = tostring(pidx) end
450
+ end
451
+ end
452
+ local parent_rows = read_fields(parents, { 'd', 'f', 'p' })
453
+ local parent_pending = false
454
+ for _, parsed in ipairs(causes) do
455
+ local pidx = tonumber(parsed.index)
456
+ if pidx <= boundary then return { 'superseded', tostring(pidx) } end
457
+ if pidx < 1 or pidx > base then
458
+ return { 'no_parent', tostring(parsed.index) }
459
+ end
460
+ local row = parent_rows[tostring(pidx)]
461
+ if tonumber(row[1] or '0') ~= tonumber(parsed.attempt) or row[2] then
462
+ return { 'superseded', tostring(pidx) }
151
463
  end
464
+ -- A claimed, unprocessed parent is still in the pending set.
465
+ if not row[3] then parent_pending = true end
152
466
  end
467
+ local owner_writes = {}
153
468
  for i = 1, n do
154
- local id = ARGV[3 + (i - 1) * 6 + 1]
155
- local typ = ARGV[3 + (i - 1) * 6 + 2]
156
- local payload = ARGV[3 + (i - 1) * 6 + 3]
157
- local cause = ARGV[3 + (i - 1) * 6 + 4]
158
- local lane = ARGV[3 + (i - 1) * 6 + 5]
159
- local settled = ARGV[3 + (i - 1) * 6 + 6]
160
- local idx = base + i
161
- redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
162
- 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
163
- 'cause', cause, 'lane', lane)
164
- redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
165
- redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
166
- if settled == '1' then
167
- redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
469
+ local o = 3 + (i - 1) * 6
470
+ local field = tostring(base + i)
471
+ redis.call('XADD', KEYS[1], field .. '-0',
472
+ 'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
473
+ 'created_at', ARGV[2], 'cause', ARGV[o + 4], 'lane', ARGV[o + 5])
474
+ owner_writes[#owner_writes + 1] = KEYS[6 + i]
475
+ owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], base + i })
476
+ wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
477
+ if ARGV[o + 6] == '1' then
478
+ put(mkey(field), field .. ':p', ARGV[2])
168
479
  else
169
- enqueue(tostring(idx), idx, lane)
480
+ enqueue(field, ARGV[o + 5])
170
481
  end
171
482
  end
172
- redis.call('SET', KEYS[2], tostring(base + n))
173
- has_pending = redis.call('ZCARD', KEYS[5]) > 0 and '1' or '0'
174
- if redis.call('EXISTS', KEYS[7]) == 1 then
175
- redis.pcall('PUBLISH', ARGV[4 + n * 6], tostring(base + n))
176
- end
177
- return { 'ok', tostring(base + 1), has_pending }
483
+ owner_writes[#owner_writes + 1] = COUNTER
484
+ owner_writes[#owner_writes + 1] = tostring(base + n)
485
+ split_write(owner_writes)
486
+ sliced('MSET', nil, owner_writes)
487
+ flush_queue()
488
+ flush()
489
+ local pending_after = (#queued > 0 or parent_pending) and '1' or has_pending()
490
+ wake(ARGV[4 + n * 6], tostring(base + n), ARGV[2])
491
+ return { 'ok', tostring(base + 1), pending_after }
178
492
  `
179
493
 
180
494
  /**
181
- * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts.
495
+ * KEYS: counter, meta, pending, ready, event snapshot pins, snapshot pin counts,
496
+ * metadata split.
182
497
  * ARGV: idx, attempt, error, maxFailures, nowMs.
183
498
  */
184
499
  const FAIL_ATTEMPT_LUA = `
185
- local function release_snapshot_pins()
186
- local refs = redis.call('SMEMBERS', KEYS[5])
187
- for _, ref in ipairs(refs) do
188
- local count = redis.call('HINCRBY', KEYS[6], ref, -1)
189
- if count <= 0 then
190
- redis.call('HDEL', KEYS[6], ref)
191
- local checkpoint = cjson.decode(ref)
192
- redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
193
- end
194
- end
195
- redis.call('DEL', KEYS[5])
196
- if redis.call('HLEN', KEYS[6]) == 0 then redis.call('DEL', KEYS[6]) end
197
- end
198
- local function lane_field(lane, suffix)
199
- return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
200
- end
201
- local function settle(field)
202
- redis.call('ZREM', KEYS[3], field)
203
- redis.call('ZREM', KEYS[4], field)
204
- local lane = redis.call('HGET', KEYS[2], field .. ':l') or ''
205
- if lane == '' then return end
206
- local head_key = lane_field(lane, 'head')
207
- if redis.call('HGET', KEYS[2], head_key) ~= field then return end
208
- local next_field = redis.call('HGET', KEYS[2], field .. ':next')
209
- while next_field and redis.call('HEXISTS', KEYS[2], next_field .. ':p') == 1 do
210
- next_field = redis.call('HGET', KEYS[2], next_field .. ':next')
211
- end
212
- if next_field then
213
- redis.call('HSET', KEYS[2], head_key, next_field)
214
- if redis.call('HEXISTS', KEYS[2], next_field .. ':f') == 0 then
215
- redis.call('ZADD', KEYS[4], tonumber(next_field), next_field)
216
- end
217
- else
218
- redis.call('HDEL', KEYS[2], head_key, lane_field(lane, 'tail'))
219
- end
220
- end
500
+ local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[7]
501
+ ${META_LUA}
502
+ ${RELEASE_PINS_LUA}
221
503
  local idx = tonumber(ARGV[1])
222
- if idx < 1 or idx > tonumber(redis.call('GET', KEYS[1]) or '0') then
223
- return { 'missing', '0' }
224
- end
504
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
505
+ local total = tonumber(counts[1] or '0')
506
+ use_split(counts[2], counts[1], counts[3])
507
+ if idx <= boundary then return { 'cleared', '0' } end
508
+ if idx < 1 or idx > total then return { 'missing', '0' } end
225
509
  local field = ARGV[1]
226
- local failures = tonumber(redis.call('HGET', KEYS[2], field .. ':a') or '0')
227
- local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
228
- if redis.call('HEXISTS', KEYS[2], field .. ':p') == 1 or dispatches ~= tonumber(ARGV[2]) then
510
+ local attempt = tonumber(ARGV[2])
511
+ local row = read_fields({ field }, { 'a', 'd', 'p', 'f', 'lfa', 'ch' })[field]
512
+ local failures = tonumber(row[1] or '0')
513
+ if row[3] or tonumber(row[2] or '0') ~= attempt then
229
514
  return { 'superseded', tostring(failures) }
230
515
  end
231
- if redis.call('HEXISTS', KEYS[2], field .. ':f') == 1 then
232
- return { 'dead_lettered', tostring(failures) }
233
- end
234
- if redis.call('HEXISTS', KEYS[2], field .. ':lfa') == 1 and
235
- tonumber(redis.call('HGET', KEYS[2], field .. ':lfa')) == tonumber(ARGV[2]) and
236
- redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
516
+ if row[4] then return { 'dead_lettered', tostring(failures) } end
517
+ if row[5] and tonumber(row[5]) == attempt and not row[6] then
237
518
  return { 'failed', tostring(failures) }
238
519
  end
239
- if redis.call('HEXISTS', KEYS[2], field .. ':ch') == 0 then
240
- return { 'superseded', tostring(failures) }
241
- end
242
- failures = redis.call('HINCRBY', KEYS[2], field .. ':a', 1)
243
- redis.call('HSET', KEYS[2],
244
- field .. ':e', ARGV[3],
245
- field .. ':lf', ARGV[5],
246
- field .. ':lfa', ARGV[2])
247
- redis.call('HDEL', KEYS[2], field .. ':ch', field .. ':ce')
248
- if failures >= tonumber(ARGV[4]) then
249
- redis.call('HSET', KEYS[2], field .. ':f', ARGV[5])
520
+ if not row[6] then return { 'superseded', tostring(failures) } end
521
+ failures = failures + 1
522
+ local dead = failures >= tonumber(ARGV[4])
523
+ local meta_key = mkey(field)
524
+ put(meta_key, field .. ':a', tostring(failures))
525
+ put(meta_key, field .. ':e', ARGV[3])
526
+ put(meta_key, field .. ':lf', ARGV[5])
527
+ put(meta_key, field .. ':lfa', ARGV[2])
528
+ if dead then put(meta_key, field .. ':f', ARGV[5]) end
529
+ flush()
530
+ redis.call('HDEL', meta_key, field .. ':ch', field .. ':ce')
531
+ if dead then
250
532
  redis.call('ZREM', KEYS[4], field)
251
- release_snapshot_pins()
533
+ release_snapshot_pins(KEYS[5], KEYS[6])
252
534
  return { 'dead_lettered', tostring(failures) }
253
535
  end
254
536
  return { 'failed', tostring(failures) }
255
537
  `
256
538
 
257
539
  /**
258
- * KEYS: store, counter, meta, pending, ready.
540
+ * KEYS: store, counter, meta, pending, ready, metadata split.
259
541
  * ARGV: holder, nowMs, expiresAtMs, excludedCount, excluded indexes.
542
+ * The ready set is a subset of the pending set, so an empty ready set is
543
+ * enough to report 'settled'. A claimed result ends with the clear boundary.
260
544
  */
261
545
  const CLAIM_AVAILABLE_LUA = `
262
- if redis.call('ZCARD', KEYS[4]) == 0 then return { 'settled' } end
546
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[6]
547
+ ${META_LUA}
263
548
  local ready = redis.call('ZRANGE', KEYS[5], 0, -1)
264
549
  local excluded = {}
265
550
  for i = 1, tonumber(ARGV[4]) do excluded[ARGV[4 + i]] = true end
551
+ local candidates = {}
552
+ for _, field in ipairs(ready) do
553
+ if not excluded[field] then candidates[#candidates + 1] = field end
554
+ end
555
+ local rows = read_fields(candidates, { 'ce', 'd', 'fc', 'a', 'e', 'lf', 'lfa' })
266
556
  local claimed = {}
267
557
  local retry_at = nil
268
- for _, field in ipairs(ready) do
269
- if not excluded[field] then
270
- local expires = tonumber(redis.call('HGET', KEYS[3], field .. ':ce') or '0')
271
- if expires > tonumber(ARGV[2]) then
272
- if not retry_at or expires < retry_at then retry_at = expires end
273
- else
274
- local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
275
- if #entries == 0 then return { 'missing', field } end
276
- local attempt = redis.call('HINCRBY', KEYS[3], field .. ':d', 1)
277
- redis.call('HSETNX', KEYS[3], field .. ':fc', ARGV[2])
278
- redis.call('HSET', KEYS[3],
279
- field .. ':lc', ARGV[2],
280
- field .. ':ch', ARGV[1],
281
- field .. ':ce', ARGV[3])
282
- claimed[#claimed + 1] = {
283
- tostring(attempt),
284
- redis.call('HGET', KEYS[3], field .. ':a') or '0',
285
- redis.call('HGET', KEYS[3], field .. ':e') or false,
286
- redis.call('HGET', KEYS[3], field .. ':fc') or false,
287
- ARGV[2],
288
- redis.call('HGET', KEYS[3], field .. ':lf') or false,
289
- redis.call('HGET', KEYS[3], field .. ':lfa') or false,
290
- ARGV[1],
291
- ARGV[3],
292
- entries[1]
293
- }
294
- end
558
+ for _, field in ipairs(candidates) do
559
+ local row = rows[field]
560
+ local expires = tonumber(row[1] or '0')
561
+ if expires > tonumber(ARGV[2]) then
562
+ if not retry_at or expires < retry_at then retry_at = expires end
563
+ else
564
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
565
+ if #entries == 0 then return { 'missing', field } end
566
+ local attempt = tostring(tonumber(row[2] or '0') + 1)
567
+ local first_claimed = row[3] or ARGV[2]
568
+ local meta_key = mkey(field)
569
+ put(meta_key, field .. ':d', attempt)
570
+ put(meta_key, field .. ':fc', first_claimed)
571
+ put(meta_key, field .. ':lc', ARGV[2])
572
+ put(meta_key, field .. ':ch', ARGV[1])
573
+ put(meta_key, field .. ':ce', ARGV[3])
574
+ claimed[#claimed + 1] = {
575
+ attempt,
576
+ row[4] or '0',
577
+ row[5] or false,
578
+ first_claimed,
579
+ ARGV[2],
580
+ row[6] or false,
581
+ row[7] or false,
582
+ ARGV[1],
583
+ ARGV[3],
584
+ entries[1]
585
+ }
295
586
  end
296
587
  end
588
+ flush()
297
589
  if #claimed > 0 then
298
590
  local result = { 'claimed', tostring(#claimed) }
299
591
  for _, claim in ipairs(claimed) do
300
592
  for _, value in ipairs(claim) do result[#result + 1] = value end
301
593
  end
594
+ result[#result + 1] = tostring(boundary)
302
595
  return result
303
596
  end
304
597
  if retry_at then return { 'busy', tostring(retry_at) } end
@@ -306,35 +599,43 @@ return { 'settled' }
306
599
  `
307
600
 
308
601
  /**
309
- * KEYS: counter, meta.
602
+ * KEYS: counter, meta, metadata split.
310
603
  * ARGV: holder, nowMs, expiresAtMs, n, then per claim: index, attempt.
311
604
  * Returns [renewedCount, ...renewed, ...superseded].
312
605
  */
313
606
  const RENEW_CLAIMS_LUA = `
314
- local total = tonumber(redis.call('GET', KEYS[1]) or '0')
315
- local renewed = {}
316
- local superseded = {}
607
+ local COUNTER, META, SPLIT = KEYS[1], KEYS[2], KEYS[3]
608
+ ${META_LUA}
609
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
610
+ local total = tonumber(counts[1] or '0')
611
+ use_split(counts[2], counts[1], counts[3])
612
+ local claims, fields, cleared = {}, {}, {}
317
613
  for i = 1, tonumber(ARGV[4]) do
318
614
  local field = ARGV[4 + (i - 1) * 2 + 1]
319
- local attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2])
320
615
  local idx = tonumber(field)
321
- if idx >= 1 and idx <= total then
322
- local dispatches = tonumber(redis.call('HGET', KEYS[2], field .. ':d') or '0')
323
- if dispatches > attempt then
324
- superseded[#superseded + 1] = field
325
- else
326
- local expires = tonumber(redis.call('HGET', KEYS[2], field .. ':ce') or '0')
327
- if tonumber(ARGV[3]) > tonumber(ARGV[2]) and
328
- redis.call('HEXISTS', KEYS[2], field .. ':p') == 0 and
329
- redis.call('HEXISTS', KEYS[2], field .. ':f') == 0 and
330
- redis.call('HGET', KEYS[2], field .. ':ch') == ARGV[1] and
331
- expires > tonumber(ARGV[2]) then
332
- redis.call('HSET', KEYS[2], field .. ':ce', ARGV[3])
333
- renewed[#renewed + 1] = field
334
- end
335
- end
616
+ if idx <= boundary then
617
+ cleared[#cleared + 1] = field
618
+ elseif idx >= 1 and idx <= total then
619
+ claims[#claims + 1] = { field = field, attempt = tonumber(ARGV[4 + (i - 1) * 2 + 2]) }
620
+ fields[#fields + 1] = field
621
+ end
622
+ end
623
+ local rows = read_fields(fields, { 'd', 'ce', 'p', 'f', 'ch' })
624
+ local renewed = {}
625
+ local superseded = {}
626
+ for _, claim in ipairs(claims) do
627
+ local row = rows[claim.field]
628
+ if tonumber(row[1] or '0') > claim.attempt then
629
+ superseded[#superseded + 1] = claim.field
630
+ elseif tonumber(ARGV[3]) > tonumber(ARGV[2]) and
631
+ not row[3] and not row[4] and row[5] == ARGV[1] and
632
+ tonumber(row[2] or '0') > tonumber(ARGV[2]) then
633
+ put(mkey(claim.field), claim.field .. ':ce', ARGV[3])
634
+ renewed[#renewed + 1] = claim.field
336
635
  end
337
636
  end
637
+ flush()
638
+ for _, field in ipairs(cleared) do superseded[#superseded + 1] = field end
338
639
  local result = { tostring(#renewed) }
339
640
  for i = 1, #renewed do result[#result + 1] = renewed[i] end
340
641
  for i = 1, #superseded do result[#result + 1] = superseded[i] end
@@ -342,85 +643,39 @@ return result
342
643
  `
343
644
 
344
645
  /**
345
- * KEYS: store, counter, ids, meta, pending, ready, watched,
346
- * event snapshot pins, snapshot pin counts.
646
+ * KEYS: store, counter, meta, pending, ready, event snapshot pins, snapshot
647
+ * pin counts, metadata split, then one id ownership key per child.
347
648
  * ARGV: sessionId, nowMs, parent index, attempt, n, then per child:
348
649
  * id, type, payloadJson, lane, settled; finally notify channel.
349
650
  */
350
651
  const COMPLETE_ATTEMPT_LUA = `
351
- local function release_snapshot_pins()
352
- local refs = redis.call('SMEMBERS', KEYS[8])
353
- for _, ref in ipairs(refs) do
354
- local count = redis.call('HINCRBY', KEYS[9], ref, -1)
355
- if count <= 0 then
356
- redis.call('HDEL', KEYS[9], ref)
357
- local checkpoint = cjson.decode(ref)
358
- redis.call('ZREMRANGEBYSCORE', checkpoint[1], checkpoint[2], checkpoint[2])
359
- end
360
- end
361
- redis.call('DEL', KEYS[8])
362
- if redis.call('HLEN', KEYS[9]) == 0 then redis.call('DEL', KEYS[9]) end
363
- end
364
- local function lane_field(lane, suffix)
365
- return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
366
- end
367
- local function settle(field)
368
- redis.call('ZREM', KEYS[5], field)
369
- redis.call('ZREM', KEYS[6], field)
370
- local lane = redis.call('HGET', KEYS[4], field .. ':l') or ''
371
- if lane == '' then return end
372
- local head_key = lane_field(lane, 'head')
373
- if redis.call('HGET', KEYS[4], head_key) ~= field then return end
374
- local next_field = redis.call('HGET', KEYS[4], field .. ':next')
375
- while next_field and redis.call('HEXISTS', KEYS[4], next_field .. ':p') == 1 do
376
- next_field = redis.call('HGET', KEYS[4], next_field .. ':next')
377
- end
378
- if next_field then
379
- redis.call('HSET', KEYS[4], head_key, next_field)
380
- if redis.call('HEXISTS', KEYS[4], next_field .. ':f') == 0 then
381
- redis.call('ZADD', KEYS[6], tonumber(next_field), next_field)
382
- end
383
- else
384
- redis.call('HDEL', KEYS[4], head_key, lane_field(lane, 'tail'))
385
- end
386
- end
387
- local function enqueue(field, idx, lane)
388
- redis.call('ZADD', KEYS[5], idx, field)
389
- if lane == '' then
390
- redis.call('ZADD', KEYS[6], idx, field)
391
- return
392
- end
393
- local tail_key = lane_field(lane, 'tail')
394
- local tail = redis.call('HGET', KEYS[4], tail_key)
395
- if tail then
396
- redis.call('HSET', KEYS[4], tail .. ':next', field)
397
- else
398
- redis.call('HSET', KEYS[4], lane_field(lane, 'head'), field)
399
- redis.call('ZADD', KEYS[6], idx, field)
400
- end
401
- redis.call('HSET', KEYS[4], tail_key, field)
402
- end
652
+ local COUNTER, META, SPLIT, PENDING, READY = KEYS[2], KEYS[3], KEYS[8], KEYS[4], KEYS[5]
653
+ ${META_LUA}
654
+ ${ENQUEUE_LUA}
655
+ ${ID_OWNERS_LUA}
656
+ ${RELEASE_PINS_LUA}
657
+ ${WAKE_LUA}
658
+ ${SETTLE_LUA}
403
659
  local parent = ARGV[3]
404
660
  local attempt = tonumber(ARGV[4])
405
661
  local n = tonumber(ARGV[5])
406
- local total = tonumber(redis.call('GET', KEYS[2]) or '0')
662
+ local owners, count = read_owners(8, n, ARGV[1])
663
+ local total = tonumber(count or '0')
664
+ if tonumber(parent) <= boundary then return { 'superseded' } end
407
665
  if tonumber(parent) < 1 or tonumber(parent) > total then return { 'missing' } end
408
- local dispatches = tonumber(redis.call('HGET', KEYS[4], parent .. ':d') or '0')
409
- local processed = redis.call('HGET', KEYS[4], parent .. ':p')
410
- local processed_attempt = tonumber(redis.call('HGET', KEYS[4], parent .. ':pa') or '0')
411
- if processed then
412
- if processed_attempt ~= attempt then return { 'superseded' } end
413
- local raw_recorded_ids = redis.call('HGET', KEYS[4], parent .. ':ri')
414
- if not raw_recorded_ids then return { 'partial' } end
415
- local recorded_ids = cjson.decode(raw_recorded_ids)
666
+ local row = read_fields({ parent }, { 'd', 'p', 'pa', 'ri', 'ch', 'f', 'lfa', 'l', 'next' })[parent]
667
+ if row[2] then
668
+ if tonumber(row[3] or '0') ~= attempt then return { 'superseded' } end
669
+ if not row[4] then return { 'partial' } end
670
+ local recorded_ids = cjson.decode(row[4])
416
671
  if #recorded_ids ~= n then return { 'partial' } end
672
+ for i = 1, n do
673
+ if recorded_ids[i] ~= ARGV[5 + (i - 1) * 5 + 1] then return { 'partial' } end
674
+ end
417
675
  local indexes = {}
418
676
  for i = 1, n do
419
- local id = ARGV[5 + (i - 1) * 5 + 1]
420
- if recorded_ids[i] ~= id then return { 'partial' } end
421
- local existing = redis.call('HGET', KEYS[3], id)
422
- if not existing then return { 'partial' } end
423
- local rec = cjson.decode(existing)
677
+ if not owners[i] then return { 'partial' } end
678
+ local rec = cjson.decode(owners[i])
424
679
  if rec[1] ~= ARGV[1] then return { 'partial' } end
425
680
  local entries = redis.call('XRANGE', KEYS[1], tostring(rec[2]) .. '-0', tostring(rec[2]) .. '-0')
426
681
  if #entries == 0 then return { 'partial' } end
@@ -439,62 +694,153 @@ if processed then
439
694
  for _, idx in ipairs(indexes) do result[#result + 1] = idx end
440
695
  return result
441
696
  end
442
- if dispatches ~= attempt then return { 'superseded' } end
443
- if redis.call('HEXISTS', KEYS[4], parent .. ':ch') == 0 or
444
- redis.call('HEXISTS', KEYS[4], parent .. ':f') == 1 or
445
- tonumber(redis.call('HGET', KEYS[4], parent .. ':lfa') or '0') == attempt then
697
+ if tonumber(row[1] or '0') ~= attempt then return { 'superseded' } end
698
+ if not row[5] or row[6] or tonumber(row[7] or '0') == attempt then
446
699
  return { 'superseded' }
447
700
  end
448
701
  for i = 1, n do
449
- local id = ARGV[5 + (i - 1) * 5 + 1]
450
- if redis.call('HEXISTS', KEYS[3], id) == 1 then return { 'partial' } end
702
+ if owners[i] then return { 'partial' } end
451
703
  end
452
704
  local returned_ids = {}
453
705
  for i = 1, n do returned_ids[i] = ARGV[5 + (i - 1) * 5 + 1] end
454
706
  local returned_json = '[]'
455
707
  if n > 0 then returned_json = cjson.encode(returned_ids) end
456
- redis.call('HSET', KEYS[4],
457
- parent .. ':p', ARGV[2],
458
- parent .. ':pa', ARGV[4],
459
- parent .. ':ri', returned_json)
460
- redis.call('HDEL', KEYS[4], parent .. ':ch', parent .. ':ce')
461
- settle(parent)
462
- release_snapshot_pins()
708
+ local parent_key = mkey(parent)
709
+ put(parent_key, parent .. ':p', ARGV[2])
710
+ put(parent_key, parent .. ':pa', ARGV[4])
711
+ put(parent_key, parent .. ':ri', returned_json)
712
+ redis.call('HDEL', parent_key, parent .. ':ch', parent .. ':ce')
713
+ settle(parent, row[8] or '', row[9])
714
+ release_snapshot_pins(KEYS[6], KEYS[7])
463
715
  local cause = cjson.encode({ index = tonumber(parent), attempt = attempt })
716
+ local owner_writes = {}
464
717
  for i = 1, n do
465
- local id = ARGV[5 + (i - 1) * 5 + 1]
466
- local typ = ARGV[5 + (i - 1) * 5 + 2]
467
- local payload = ARGV[5 + (i - 1) * 5 + 3]
468
- local lane = ARGV[5 + (i - 1) * 5 + 4]
469
- local settled = ARGV[5 + (i - 1) * 5 + 5]
470
- local idx = total + i
471
- redis.call('XADD', KEYS[1], tostring(idx) .. '-0',
472
- 'id', id, 'type', typ, 'payload', payload, 'created_at', ARGV[2],
473
- 'cause', cause, 'lane', lane)
474
- redis.call('HSET', KEYS[3], id, cjson.encode({ ARGV[1], idx }))
475
- redis.call('HSET', KEYS[4], tostring(idx) .. ':l', lane)
476
- if settled == '1' then
477
- redis.call('HSET', KEYS[4], tostring(idx) .. ':p', ARGV[2])
718
+ local o = 5 + (i - 1) * 5
719
+ local field = tostring(total + i)
720
+ redis.call('XADD', KEYS[1], field .. '-0',
721
+ 'id', ARGV[o + 1], 'type', ARGV[o + 2], 'payload', ARGV[o + 3],
722
+ 'created_at', ARGV[2], 'cause', cause, 'lane', ARGV[o + 4])
723
+ owner_writes[#owner_writes + 1] = KEYS[8 + i]
724
+ owner_writes[#owner_writes + 1] = cjson.encode({ ARGV[1], total + i })
725
+ wake_add(field, ARGV[o + 1], ARGV[o + 2], ARGV[o + 3])
726
+ if ARGV[o + 5] == '1' then
727
+ put(mkey(field), field .. ':p', ARGV[2])
478
728
  else
479
- enqueue(tostring(idx), idx, lane)
729
+ enqueue(field, ARGV[o + 4])
480
730
  end
481
731
  end
482
- redis.call('SET', KEYS[2], tostring(total + n))
483
- if n > 0 and redis.call('EXISTS', KEYS[7]) == 1 then
484
- redis.pcall('PUBLISH', ARGV[6 + n * 5], tostring(total + n))
732
+ if n > 0 then
733
+ owner_writes[#owner_writes + 1] = COUNTER
734
+ owner_writes[#owner_writes + 1] = tostring(total + n)
735
+ split_write(owner_writes)
736
+ sliced('MSET', nil, owner_writes)
485
737
  end
738
+ flush_queue()
739
+ flush()
740
+ if n > 0 then wake(ARGV[6 + n * 5], tostring(total + n), ARGV[2]) end
486
741
  return { 'ok', tostring(total + 1) }
487
742
  `
488
743
 
489
744
  /**
490
- * KEYS: head, history, event counter, event meta, pin counts.
745
+ * KEYS: counter, meta, pending, ready, metadata split.
746
+ * ARGV: idx, action ('retry' | 'skip'), nowMs.
747
+ * Returns [redriven, hasPending]. Acts only on an unfinished, dead-lettered
748
+ * event above the clear boundary. A retry keeps the event pending and makes
749
+ * it ready again when it is unlaned or its lane's head.
750
+ */
751
+ const REDRIVE_LUA = `
752
+ local COUNTER, META, SPLIT, PENDING, READY = KEYS[1], KEYS[2], KEYS[5], KEYS[3], KEYS[4]
753
+ ${META_LUA}
754
+ ${ENQUEUE_LUA}
755
+ ${SETTLE_LUA}
756
+ local function result(redriven)
757
+ return { redriven, redis.call('ZCARD', PENDING) > 0 and '1' or '0' }
758
+ end
759
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
760
+ local total = tonumber(counts[1] or '0')
761
+ use_split(counts[2], counts[1], counts[3])
762
+ local idx = tonumber(ARGV[1])
763
+ if idx <= boundary or idx > total then return result('0') end
764
+ local field = tostring(idx)
765
+ local row = read_fields({ field }, { 'p', 'f', 'l', 'next' })[field]
766
+ if row[1] or not row[2] then return result('0') end
767
+ local meta_key = mkey(field)
768
+ local lane = row[3] or ''
769
+ if ARGV[2] == 'skip' then
770
+ put(meta_key, field .. ':p', ARGV[3])
771
+ settle(field, lane, row[4])
772
+ flush_queue()
773
+ flush()
774
+ else
775
+ redis.call('HDEL', meta_key, field .. ':f', field .. ':a')
776
+ if lane == '' or redis.call('HGET', META, lane_field(lane, 'head')) == field then
777
+ redis.call('ZADD', READY, idx, field)
778
+ end
779
+ end
780
+ return result('1')
781
+ `
782
+
783
+ /**
784
+ * KEYS: store, counter, meta, pending, metadata split.
785
+ * Returns the dead-lettered events above the clear boundary, oldest first,
786
+ * as pairs of a stream entry and its STORED_EVENT_META_SUFFIXES columns.
787
+ * Every unfinished event is in the pending set, so the scan is bounded by
788
+ * unfinished work, not history; only dead letters read their full columns.
789
+ */
790
+ const DEAD_LETTERS_LUA = `
791
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[5]
792
+ ${META_LUA}
793
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
794
+ use_split(counts[2], counts[1], counts[3])
795
+ local candidates = {}
796
+ for _, field in ipairs(redis.call('ZRANGE', KEYS[4], 0, -1)) do
797
+ if tonumber(field) > boundary then candidates[#candidates + 1] = field end
798
+ end
799
+ local status = read_fields(candidates, { 'p', 'f' })
800
+ local dead = {}
801
+ for _, field in ipairs(candidates) do
802
+ if status[field][2] and not status[field][1] then dead[#dead + 1] = field end
803
+ end
804
+ local rows = read_fields(dead, { ${STORED_EVENT_META_SUFFIXES.map((suffix) => `'${suffix}'`).join(', ')} })
805
+ local result = {}
806
+ for _, field in ipairs(dead) do
807
+ local entries = redis.call('XRANGE', KEYS[1], field .. '-0', field .. '-0')
808
+ if #entries > 0 then
809
+ result[#result + 1] = entries[1]
810
+ result[#result + 1] = rows[field]
811
+ end
812
+ end
813
+ return result
814
+ `
815
+
816
+ /**
817
+ * KEYS: head, history, event counter, event meta, pin counts, metadata split.
491
818
  * ARGV: nowMs, event pin key prefix, write count, then per write:
492
- * index, snapshot JSON, pin count, pin event indexes.
819
+ * index, snapshot JSON, pin count, pin event indexes; finally the reducer
820
+ * name. Writes from before the latest clear are dropped, and a head from
821
+ * before it is replaced as if absent.
493
822
  */
494
823
  const PUT_SNAPSHOTS_LUA = `
495
- local total = tonumber(redis.call('GET', KEYS[3]) or '0')
496
- local writes = {}
824
+ local COUNTER, META, SPLIT = KEYS[3], KEYS[4], KEYS[6]
825
+ ${META_LUA}
826
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
827
+ local total = tonumber(counts[1] or '0')
828
+ use_split(counts[2], counts[1], counts[3])
829
+ local pinned = {}
497
830
  local offset = 4
831
+ for i = 1, tonumber(ARGV[3]) do
832
+ local pin_count = tonumber(ARGV[offset + 2])
833
+ for p = 1, pin_count do
834
+ local event_index = tonumber(ARGV[offset + 2 + p])
835
+ if event_index > boundary and event_index <= total then
836
+ pinned[#pinned + 1] = tostring(event_index)
837
+ end
838
+ end
839
+ offset = offset + 3 + pin_count
840
+ end
841
+ local status = read_fields(pinned, { 'p', 'f' })
842
+ local snapshots = {}
843
+ offset = 4
498
844
  for i = 1, tonumber(ARGV[3]) do
499
845
  local index = tonumber(ARGV[offset])
500
846
  local snapshot = ARGV[offset + 1]
@@ -502,23 +848,40 @@ for i = 1, tonumber(ARGV[3]) do
502
848
  local ref = cjson.encode({ KEYS[2], tostring(index) })
503
849
  for p = 1, pin_count do
504
850
  local event_index = tonumber(ARGV[offset + 2 + p])
505
- if event_index >= 1 and event_index <= total and
506
- redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':p') == 0 and
507
- redis.call('HEXISTS', KEYS[4], tostring(event_index) .. ':f') == 0 then
851
+ local row = status[tostring(event_index)]
852
+ if index >= boundary and event_index > boundary and event_index <= total and
853
+ not row[1] and not row[2] then
508
854
  local pin_key = ARGV[2] .. tostring(event_index)
509
855
  if redis.call('SADD', pin_key, ref) == 1 then
510
856
  redis.call('HINCRBY', KEYS[5], ref, 1)
511
857
  end
512
858
  end
513
859
  end
514
- writes[#writes + 1] = { index = index, snapshot = snapshot, ref = ref }
860
+ if index >= boundary then
861
+ snapshots[#snapshots + 1] = { index = index, snapshot = snapshot, ref = ref }
862
+ end
515
863
  offset = offset + 3 + pin_count
516
864
  end
865
+ if #snapshots == 0 then return 1 end
517
866
 
518
867
  local head = redis.call('GET', KEYS[1])
519
868
  local head_index = nil
520
- if head then head_index = tonumber(cjson.decode(head)['index']) end
521
- local latest = writes[#writes]
869
+ local registered = false
870
+ if head then
871
+ local record = cjson.decode(head)
872
+ head_index = tonumber(record['index'])
873
+ registered = record['r'] == 1
874
+ end
875
+ if head_index and head_index < boundary then
876
+ head, head_index, registered = nil, nil, false
877
+ redis.call('ZREMRANGEBYSCORE', KEYS[2], '-inf', '(' .. boundary)
878
+ end
879
+ -- Deletion finds each reducer's keys through this set instead of a scan.
880
+ -- Heads written since carry the mark, so a reducer registers once.
881
+ if not registered then
882
+ redis.call('SADD', string.sub(COUNTER, 1, -6) .. 'snap-reducers', ARGV[#ARGV])
883
+ end
884
+ local latest = snapshots[#snapshots]
522
885
  local writes_head = not head_index or latest.index >= head_index
523
886
  local advances = not head_index or latest.index > head_index
524
887
  local final_head_index = advances and latest.index or head_index
@@ -528,7 +891,7 @@ if advances and head and tonumber(redis.call('HGET', KEYS[5], cjson.encode({ KEY
528
891
  redis.call('ZADD', KEYS[2], head_index, head)
529
892
  end
530
893
 
531
- for _, write in ipairs(writes) do
894
+ for _, write in ipairs(snapshots) do
532
895
  if write.index < final_head_index then
533
896
  redis.call('ZREMRANGEBYSCORE', KEYS[2], write.index, write.index)
534
897
  if tonumber(redis.call('HGET', KEYS[5], write.ref) or '0') > 0 then
@@ -607,30 +970,242 @@ return live
607
970
 
608
971
  /**
609
972
  * KEYS: head, history, event counter. ARGV: snapshot frontier or ''.
610
- * Returns one atomic multiversion-cache-plus-log-frontier read.
973
+ * Returns one atomic multiversion-cache-plus-log-frontier read, with the
974
+ * clear boundary; checkpoints from before it are ignored.
611
975
  */
612
976
  const READ_STATE_LUA = `
977
+ local counts = redis.call('MGET', KEYS[3], string.sub(KEYS[3], 1, -6) .. 'clear')
978
+ local boundary = tonumber(counts[2] or '0')
613
979
  local head = redis.call('GET', KEYS[1])
614
980
  local head_index = head and cjson.decode(head)['index'] or nil
615
981
  if head and (type(head_index) ~= 'number' or head_index < 0 or head_index > 9007199254740991 or head_index ~= math.floor(head_index)) then
616
982
  return redis.error_reply('invalid snapshot head index')
617
983
  end
984
+ if head_index and head_index < boundary then
985
+ head = nil
986
+ head_index = nil
987
+ end
618
988
  local cap = ARGV[1]
619
989
  local snapshot = nil
620
990
  if head_index and (cap == '' or head_index <= tonumber(cap)) then
621
991
  snapshot = head
622
992
  else
623
993
  local upper = cap == '' and '+inf' or cap
624
- local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, '-inf', 'LIMIT', 0, 1)
994
+ local historical = redis.call('ZREVRANGEBYSCORE', KEYS[2], upper, boundary, 'LIMIT', 0, 1)
625
995
  if #historical > 0 then snapshot = historical[1] end
626
996
  end
627
- local frontier = redis.call('GET', KEYS[3]) or '0'
628
- return { head_index or '', snapshot or '', frontier }
997
+ return { head_index or '', snapshot or '', counts[1] or '0', tostring(boundary) }
998
+ `
999
+
1000
+ /**
1001
+ * Removes every registered reducer's checkpoints below the latest clear, and
1002
+ * the pin counts that name them. Snapshot writes register each reducer in
1003
+ * {prefix}:{session}:snap-reducers, so no key scan is needed.
1004
+ */
1005
+ const SWEEP_CHECKPOINTS_LUA = `
1006
+ local function sweep_checkpoints(session, boundary)
1007
+ for _, reducer in ipairs(redis.call('SMEMBERS', session .. 'snap-reducers')) do
1008
+ local head_key = session .. 'snap:' .. reducer
1009
+ local head = redis.call('GET', head_key)
1010
+ if head and tonumber(cjson.decode(head)['index']) < boundary then
1011
+ redis.call('DEL', head_key)
1012
+ end
1013
+ redis.call('ZREMRANGEBYSCORE', session .. 'snap-history:' .. reducer, '-inf', '(' .. boundary)
1014
+ end
1015
+ local counts_key = session .. 'snapshot-pin-counts'
1016
+ local counts = redis.call('HGETALL', counts_key)
1017
+ local stale = {}
1018
+ for i = 1, #counts, 2 do
1019
+ if tonumber(cjson.decode(counts[i])[2]) < boundary then stale[#stale + 1] = counts[i] end
1020
+ end
1021
+ if #stale > 0 then redis.call('HDEL', counts_key, unpack(stale)) end
1022
+ end
1023
+ `
1024
+
1025
+ /**
1026
+ * KEYS: store stream, counter, metadata, metadata split, marker id ownership
1027
+ * key, pending, ready.
1028
+ * ARGV: sessionId, nowMs, ifLastIndex or '', cause index or '', cause
1029
+ * attempt, marker id, notify channel.
1030
+ * Returns ['ok', cleared, head, boundary] | ['no_parent', idx] |
1031
+ * ['superseded', idx] | ['foreign']. The old lifetime's log moves aside in one
1032
+ * RENAME, so every read of the log starts at the marker, and its queues are
1033
+ * unlinked, so claims and pending checks see only the new lifetime; deletion
1034
+ * drains the moved log.
1035
+ */
1036
+ const CLEAR_LUA = `
1037
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
1038
+ ${META_LUA}
1039
+ ${WAKE_LUA}
1040
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR, KEYS[5])
1041
+ local head = tonumber(counts[1] or '0')
1042
+ use_split(counts[2], counts[1], counts[3])
1043
+ if ARGV[4] ~= '' then
1044
+ local field = ARGV[4]
1045
+ local pidx = tonumber(field)
1046
+ if pidx <= boundary then return { 'superseded', field } end
1047
+ if pidx < 1 or pidx > head then return { 'no_parent', field } end
1048
+ local row = read_fields({ field }, { 'd', 'f' })[field]
1049
+ if tonumber(row[1] or '0') ~= tonumber(ARGV[5]) or row[2] then
1050
+ return { 'superseded', field }
1051
+ end
1052
+ end
1053
+ local expected = ARGV[3]
1054
+ if expected ~= '' and boundary == tonumber(expected) + 1 then
1055
+ return { 'ok', '1', tostring(head), tostring(boundary) }
1056
+ end
1057
+ if expected ~= '' and tonumber(expected) ~= head then
1058
+ return { 'ok', '0', tostring(head), tostring(boundary) }
1059
+ end
1060
+ if head == 0 or boundary == head then
1061
+ return { 'ok', '1', tostring(head), tostring(boundary) }
1062
+ end
1063
+ if counts[4] then return { 'foreign' } end
1064
+ local field = tostring(head + 1)
1065
+ local session = string.sub(COUNTER, 1, -6)
1066
+ local moved = session .. 'cleared:' .. field
1067
+ redis.call('RENAME', KEYS[1], moved)
1068
+ redis.call('UNLINK', KEYS[6], KEYS[7])
1069
+ redis.call('SADD', session .. 'cleared', moved)
1070
+ redis.call('XADD', KEYS[1], field .. '-0',
1071
+ 'id', ARGV[6], 'type', '${CLEAR_EVENT_TYPE}', 'payload', 'null',
1072
+ 'created_at', ARGV[2], 'cause', '', 'lane', '')
1073
+ local writes = { KEYS[5], cjson.encode({ ARGV[1], head + 1 }), COUNTER, field, CLEAR, field }
1074
+ split_write(writes)
1075
+ redis.call('MSET', unpack(writes))
1076
+ put(mkey(field), field .. ':p', ARGV[2])
1077
+ flush()
1078
+ wake_add(field, ARGV[6], '${CLEAR_EVENT_TYPE}', 'null')
1079
+ wake(ARGV[7], field, ARGV[2])
1080
+ return { 'ok', '1', field, field }
1081
+ `
1082
+
1083
+ /**
1084
+ * KEYS: counter. ARGV: page size.
1085
+ * The next deletion batch: a moved log and up to a page of its [index, id]
1086
+ * pairs, without payloads. Empty moved logs are dropped; with none left it
1087
+ * sweeps the old checkpoints and returns nothing, so a retry finishes an
1088
+ * interrupted deletion.
1089
+ */
1090
+ const RECLAIM_BATCH_LUA = `
1091
+ ${SWEEP_CHECKPOINTS_LUA}
1092
+ local session = string.sub(KEYS[1], 1, -6)
1093
+ local boundary = tonumber(redis.call('GET', session .. 'clear') or '0')
1094
+ if boundary == 0 then return {} end
1095
+ for _, log in ipairs(redis.call('SMEMBERS', session .. 'cleared')) do
1096
+ local entries = redis.call('XRANGE', log, '-', '+', 'COUNT', ARGV[1])
1097
+ if #entries > 0 then
1098
+ local batch = { log }
1099
+ for _, entry in ipairs(entries) do
1100
+ local fields = entry[2]
1101
+ for i = 1, #fields, 2 do
1102
+ if fields[i] == 'id' then
1103
+ batch[#batch + 1] = string.match(entry[1], '^(%d+)')
1104
+ batch[#batch + 1] = fields[i + 1]
1105
+ break
1106
+ end
1107
+ end
1108
+ end
1109
+ return batch
1110
+ end
1111
+ redis.call('DEL', log)
1112
+ redis.call('SREM', session .. 'cleared', log)
1113
+ end
1114
+ sweep_checkpoints(session, boundary)
1115
+ return {}
1116
+ `
1117
+
1118
+ /**
1119
+ * KEYS: moved log, counter, metadata, metadata split, then one id ownership
1120
+ * key per entry.
1121
+ * ARGV: sessionId, event pin key prefix, n, then per entry: index, id.
1122
+ * Deletes the listed entries below the latest clear and everything that
1123
+ * names them. An id record survives when it names another event, so an id
1124
+ * reused in the new lifetime keeps its owner. Returns [deleted, done].
1125
+ */
1126
+ const RECLAIM_LUA = `
1127
+ local COUNTER, META, SPLIT = KEYS[2], KEYS[3], KEYS[4]
1128
+ ${META_LUA}
1129
+ ${SWEEP_CHECKPOINTS_LUA}
1130
+ local function lane_field(lane, suffix)
1131
+ return '__lane:' .. tostring(string.len(lane)) .. ':' .. lane .. ':' .. suffix
1132
+ end
1133
+ local session = string.sub(COUNTER, 1, -6)
1134
+ local counts = redis.call('MGET', COUNTER, SPLIT, CLEAR)
1135
+ use_split(counts[2], counts[1], counts[3])
1136
+ local fields, id_keys, indexes, stream_ids, pin_keys, deleted = {}, {}, {}, {}, {}, {}
1137
+ -- A concurrent reclaim may list the same batch; only XDEL says who removed it.
1138
+ local removed = 0
1139
+ for i = 1, tonumber(ARGV[3]) do
1140
+ local field = ARGV[3 + (i - 1) * 2 + 1]
1141
+ if tonumber(field) < boundary then
1142
+ fields[#fields + 1] = field
1143
+ id_keys[#fields] = KEYS[4 + i]
1144
+ indexes[#fields] = tonumber(field)
1145
+ stream_ids[#fields] = field .. '-0'
1146
+ pin_keys[#fields] = ARGV[2] .. field
1147
+ deleted[field] = true
1148
+ end
1149
+ end
1150
+ if #fields > 0 then
1151
+ local lanes, seen, pointers = read_fields(fields, { 'l' }), {}, {}
1152
+ for _, field in ipairs(fields) do
1153
+ local lane = lanes[field][1]
1154
+ if lane and lane ~= '' and not seen[lane] then
1155
+ seen[lane] = true
1156
+ pointers[#pointers + 1] = lane_field(lane, 'head')
1157
+ pointers[#pointers + 1] = lane_field(lane, 'tail')
1158
+ end
1159
+ end
1160
+ if #pointers > 0 then
1161
+ local values = redis.call('HMGET', META, unpack(pointers))
1162
+ local stale = {}
1163
+ for i = 1, #pointers do
1164
+ if values[i] and deleted[values[i]] then stale[#stale + 1] = pointers[i] end
1165
+ end
1166
+ if #stale > 0 then redis.call('HDEL', META, unpack(stale)) end
1167
+ end
1168
+ local suffixes = { 'l', 'next', 'p', 'pa', 'ri', 'ch', 'ce', 'f', 'fc', 'lc', 'lf', 'lfa', 'd', 'a', 'e' }
1169
+ local columns, order = {}, {}
1170
+ for _, field in ipairs(fields) do
1171
+ local key = mkey(field)
1172
+ if not columns[key] then
1173
+ columns[key] = {}
1174
+ order[#order + 1] = key
1175
+ end
1176
+ for _, suffix in ipairs(suffixes) do
1177
+ columns[key][#columns[key] + 1] = field .. ':' .. suffix
1178
+ end
1179
+ end
1180
+ for _, key in ipairs(order) do sliced('HDEL', key, columns[key]) end
1181
+ sliced('DEL', nil, pin_keys)
1182
+ local owners, stale_ids = sliced('MGET', nil, id_keys), {}
1183
+ for i = 1, #fields do
1184
+ if owners[i] then
1185
+ local rec = cjson.decode(owners[i])
1186
+ if rec[1] == ARGV[1] and tonumber(rec[2]) == indexes[i] then
1187
+ stale_ids[#stale_ids + 1] = id_keys[i]
1188
+ end
1189
+ end
1190
+ end
1191
+ if #stale_ids > 0 then sliced('DEL', nil, stale_ids) end
1192
+ removed = redis.call('XDEL', KEYS[1], unpack(stream_ids))
1193
+ end
1194
+ local done = 0
1195
+ if redis.call('XLEN', KEYS[1]) == 0 then
1196
+ redis.call('DEL', KEYS[1])
1197
+ redis.call('SREM', session .. 'cleared', KEYS[1])
1198
+ if redis.call('SCARD', session .. 'cleared') == 0 then
1199
+ sweep_checkpoints(session, boundary)
1200
+ done = 1
1201
+ end
1202
+ end
1203
+ return { removed, done }
629
1204
  `
630
1205
 
631
1206
  // ── wire helpers ─────────────────────────────────────────────────────
632
1207
 
633
- type StreamEntry = [id: string, fields: string[]]
1208
+ export type StreamEntry = [id: string, fields: string[]]
634
1209
 
635
1210
  const fieldMap = (fields: string[]): Record<string, string> => {
636
1211
  const map: Record<string, string> = {}
@@ -640,22 +1215,6 @@ const fieldMap = (fields: string[]): Record<string, string> => {
640
1215
  return map
641
1216
  }
642
1217
 
643
- const STORED_EVENT_META_SUFFIXES = [
644
- 'p',
645
- 'pa',
646
- 'ri',
647
- 'ch',
648
- 'ce',
649
- 'f',
650
- 'fc',
651
- 'lc',
652
- 'lf',
653
- 'lfa',
654
- 'd',
655
- 'a',
656
- 'e',
657
- ] as const
658
-
659
1218
  const entryIndex = (entryId: string): number =>
660
1219
  Number(entryId.slice(0, entryId.indexOf('-')))
661
1220
 
@@ -671,6 +1230,66 @@ const toEvent = (sessionId: string, entry: StreamEntry): Event => {
671
1230
  }
672
1231
  }
673
1232
 
1233
+ type ByteStreamEntry = [Uint8Array, Uint8Array[]]
1234
+
1235
+ const fieldBytes = (name: string): Uint8Array => new TextEncoder().encode(name)
1236
+ const ID_FIELD = fieldBytes('id')
1237
+ const TYPE_FIELD = fieldBytes('type')
1238
+ const PAYLOAD_FIELD = fieldBytes('payload')
1239
+ const CREATED_AT_FIELD = fieldBytes('created_at')
1240
+ // A leading U+FEFF is part of a stored id or type, never a byte-order mark.
1241
+ const utf8 = new TextDecoder('utf-8', { ignoreBOM: true })
1242
+
1243
+ const isField = (name: Uint8Array, field: Uint8Array): boolean => {
1244
+ if (name.length !== field.length) return false
1245
+ for (let at = 0; at < field.length; at++)
1246
+ if (name[at] !== field[at]) return false
1247
+ return true
1248
+ }
1249
+
1250
+ const bytesToSerializedEvent = (
1251
+ sessionId: string,
1252
+ entry: ByteStreamEntry,
1253
+ ): SerializedEvent => {
1254
+ const values = entry[1]
1255
+ let id: string | undefined
1256
+ let type: string | undefined
1257
+ let payloadJson: string | undefined
1258
+ let createdAt: string | undefined
1259
+ for (let at = 0; at + 1 < values.length; at += 2) {
1260
+ const name = values[at]!
1261
+ if (isField(name, ID_FIELD)) id = utf8.decode(values[at + 1])
1262
+ else if (isField(name, TYPE_FIELD)) type = utf8.decode(values[at + 1])
1263
+ else if (isField(name, PAYLOAD_FIELD))
1264
+ payloadJson = utf8.decode(values[at + 1])
1265
+ else if (isField(name, CREATED_AT_FIELD))
1266
+ createdAt = utf8.decode(values[at + 1])
1267
+ }
1268
+ return {
1269
+ id: id!,
1270
+ type: type!,
1271
+ payloadJson: payloadJson!,
1272
+ index: entryIndex(utf8.decode(entry[0])),
1273
+ sessionId,
1274
+ createdAtMs: Number(createdAt),
1275
+ }
1276
+ }
1277
+
1278
+ const toSerializedEvent = (
1279
+ sessionId: string,
1280
+ entry: StreamEntry,
1281
+ ): SerializedEvent => {
1282
+ const fields = fieldMap(entry[1])
1283
+ return {
1284
+ id: fields['id']!,
1285
+ type: fields['type']!,
1286
+ payloadJson: fields['payload']!,
1287
+ index: entryIndex(entry[0]),
1288
+ sessionId,
1289
+ createdAtMs: Number(fields['created_at']),
1290
+ }
1291
+ }
1292
+
674
1293
  const json = (value: unknown): string => JSON.stringify(value) ?? 'null'
675
1294
 
676
1295
  const toCause = (raw: string | undefined): EventCause | null => {
@@ -703,6 +1322,46 @@ const toCause = (raw: string | undefined): EventCause | null => {
703
1322
  }
704
1323
  }
705
1324
 
1325
+ type MetaSuffix = (typeof STORED_EVENT_META_SUFFIXES)[number]
1326
+
1327
+ /** A stream entry joined with its metadata columns; absent columns are unset. */
1328
+ const toStoredEvent = (
1329
+ sessionId: string,
1330
+ entry: StreamEntry,
1331
+ column: (suffix: MetaSuffix) => string | undefined,
1332
+ ): StoredEvent => {
1333
+ const fields = fieldMap(entry[1])
1334
+ const date = (suffix: MetaSuffix): Date | null => {
1335
+ const value = column(suffix)
1336
+ return value === undefined ? null : new Date(Number(value))
1337
+ }
1338
+ const count = (suffix: MetaSuffix): number | null => {
1339
+ const value = column(suffix)
1340
+ return value === undefined ? null : Number(value)
1341
+ }
1342
+ const returnedEventIds = column('ri')
1343
+ return Object.assign(toEvent(sessionId, entry), {
1344
+ cause: toCause(fields['cause']),
1345
+ lane: fields['lane'] === '' ? null : fields['lane']!,
1346
+ processedAt: date('p'),
1347
+ processedByAttempt: count('pa'),
1348
+ returnedEventIds:
1349
+ returnedEventIds === undefined
1350
+ ? null
1351
+ : decodeReturnedEventIds(returnedEventIds),
1352
+ firstClaimedAt: date('fc'),
1353
+ lastClaimedAt: date('lc'),
1354
+ attemptCount: count('d') ?? 0,
1355
+ claimHolder: column('ch') ?? null,
1356
+ claimExpiresAt: date('ce'),
1357
+ failureCount: count('a') ?? 0,
1358
+ lastFailedAt: date('lf'),
1359
+ lastFailedAttempt: count('lfa'),
1360
+ lastError: column('e') ?? null,
1361
+ failedAt: date('f'),
1362
+ })
1363
+ }
1364
+
706
1365
  const toClaimAvailableResult = (
707
1366
  sessionId: string,
708
1367
  reply: unknown[],
@@ -744,6 +1403,9 @@ const toClaimAvailableResult = (
744
1403
  }),
745
1404
  )
746
1405
  }
1406
+ const clearIndex = Number(reply[2 + count * 10] ?? 0)
1407
+ if (clearIndex > 0)
1408
+ for (const event of events) event.clearIndex = clearIndex
747
1409
  return {
748
1410
  outcome: 'claimed',
749
1411
  events,
@@ -758,6 +1420,57 @@ const toClaimAvailableResult = (
758
1420
  )
759
1421
  }
760
1422
 
1423
+ const toIndex = ({
1424
+ raw,
1425
+ sessionId,
1426
+ name,
1427
+ }: {
1428
+ raw: unknown
1429
+ sessionId: string
1430
+ name: string
1431
+ }): number => {
1432
+ const index = Number(raw ?? 0)
1433
+ if (!Number.isSafeInteger(index) || index < 0) {
1434
+ throw new A2Error(
1435
+ 'STORE_UNAVAILABLE',
1436
+ `redis ${name} for session '${sessionId}' is invalid`,
1437
+ )
1438
+ }
1439
+ return index
1440
+ }
1441
+
1442
+ /**
1443
+ * A range read that met a gap. A clear moves the old lifetime's log aside, so
1444
+ * a read whose bounds predate it finds nothing where it expected events;
1445
+ * anywhere else a gap is corruption.
1446
+ */
1447
+ const rangeGap = (sessionId: string, message: string): A2Error =>
1448
+ Object.assign(
1449
+ new A2Error(
1450
+ 'STORE_UNAVAILABLE',
1451
+ `redis event range for session '${sessionId}' ${message}`,
1452
+ ),
1453
+ { rangeGap: true },
1454
+ )
1455
+
1456
+ const isRangeGap = (error: unknown): boolean =>
1457
+ error instanceof A2Error &&
1458
+ (error as { rangeGap?: boolean }).rangeGap === true
1459
+
1460
+ /**
1461
+ * Run a whole read, bounds included. A gap means a clear raced it, so the
1462
+ * read runs once more from fresh bounds. A second gap, from another clear or
1463
+ * from corruption, surfaces as the retryable STORE_UNAVAILABLE.
1464
+ */
1465
+ const retryAfterGap = async <T>(read: () => Promise<T>): Promise<T> => {
1466
+ try {
1467
+ return await read()
1468
+ } catch (error) {
1469
+ if (!isRangeGap(error)) throw error
1470
+ return read()
1471
+ }
1472
+ }
1473
+
761
1474
  const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
762
1475
  try {
763
1476
  return await fn()
@@ -772,13 +1485,16 @@ const wrap = async <T>(fn: () => Promise<T>): Promise<T> => {
772
1485
  export function createRedisStoreCore(
773
1486
  options: RedisStoreCoreOptions,
774
1487
  ): RedisStoreCore {
775
- const { call, clock } = options
1488
+ const { call, callBytes, clock } = options
776
1489
  const generateId = options.ids
777
1490
  const prefix = options.keyPrefix
778
1491
 
779
1492
  const storeKey = (s: string): string => `${prefix}:${s}:log`
780
1493
  const countKey = (s: string): string => `${prefix}:${s}:count`
781
1494
  const metaKey = (s: string): string => `${prefix}:${s}:meta`
1495
+ const metaChunkKey = (s: string, index: number): string =>
1496
+ `${metaKey(s)}:${Math.floor((index - 1) / META_CHUNK_SIZE)}`
1497
+ const metaSplitKey = (s: string): string => `${prefix}:${s}:meta-split`
782
1498
  const pendingKey = (s: string): string => `${prefix}:${s}:pending`
783
1499
  const readyKey = (s: string): string => `${prefix}:${s}:ready`
784
1500
  const snapKey = (s: string, r: string): string => `${prefix}:${s}:snap:${r}`
@@ -790,9 +1506,15 @@ export function createRedisStoreCore(
790
1506
  `${prefix}:${s}:snapshot-pins:`
791
1507
  const snapshotPinCountsKey = (s: string): string =>
792
1508
  `${prefix}:${s}:snapshot-pin-counts`
793
- const watchedKey = (s: string): string => `${prefix}:${s}:watched`
794
1509
  const presenceKey = (s: string): string => `${prefix}:${s}:presence`
795
- const idsKey = `${prefix}:ids`
1510
+ // Scripts derive this from the counter key; keep the two in step.
1511
+ const clearKey = (s: string): string => `${prefix}:${s}:clear`
1512
+ // One ownership key per event id; reclaim deletes those naming cleared events.
1513
+ // Session ids are raw and may contain colons, so a raw id could name a
1514
+ // session key (`ids:count`). A JSON string with escaped colons starts with
1515
+ // a quote and has no colon, so it never matches a session key's suffix.
1516
+ const idKey = (id: string): string =>
1517
+ `${prefix}:ids:${JSON.stringify(id).replaceAll(':', '\\u003a')}`
796
1518
 
797
1519
  const evalScript = (
798
1520
  script: string,
@@ -800,23 +1522,37 @@ export function createRedisStoreCore(
800
1522
  args: Array<string | number>,
801
1523
  ): Promise<unknown> => call('EVAL', script, keys.length, ...keys, ...args)
802
1524
 
803
- const readFrontier = async (sessionId: string): Promise<number> => {
804
- const frontier = Number((await call('GET', countKey(sessionId))) ?? 0)
805
- if (!Number.isSafeInteger(frontier) || frontier < 0) {
806
- throw new A2Error(
807
- 'STORE_UNAVAILABLE',
808
- `redis event count for session '${sessionId}' is invalid`,
809
- )
1525
+ const readRangeBounds = async (
1526
+ sessionId: string,
1527
+ ): Promise<{ frontier: number; metaSplit: number; clearIndex: number }> => {
1528
+ const [count, split, cleared] = (await call(
1529
+ 'MGET',
1530
+ countKey(sessionId),
1531
+ metaSplitKey(sessionId),
1532
+ clearKey(sessionId),
1533
+ )) as [unknown, unknown, unknown]
1534
+ const frontier = toIndex({ raw: count, sessionId, name: 'event count' })
1535
+ return {
1536
+ frontier,
1537
+ metaSplit:
1538
+ split === null
1539
+ ? frontier
1540
+ : toIndex({ raw: split, sessionId, name: 'metadata split' }),
1541
+ clearIndex: toIndex({ raw: cleared, sessionId, name: 'clear boundary' }),
810
1542
  }
811
- return frontier
812
1543
  }
813
1544
 
814
- const readRangePage = async (
815
- sessionId: string,
816
- afterIndex: number,
817
- throughIndex: number,
818
- limit: number,
819
- ): Promise<StoredEvent[]> => {
1545
+ const readRangePage = async ({
1546
+ sessionId,
1547
+ afterIndex,
1548
+ throughIndex,
1549
+ metaSplit,
1550
+ }: {
1551
+ sessionId: string
1552
+ afterIndex: number
1553
+ throughIndex: number
1554
+ metaSplit: number
1555
+ }): Promise<StoredEvent[]> => {
820
1556
  if (throughIndex <= afterIndex) return []
821
1557
  const entries = (await call(
822
1558
  'XRANGE',
@@ -824,70 +1560,45 @@ export function createRedisStoreCore(
824
1560
  `${afterIndex + 1}-0`,
825
1561
  `${throughIndex}-0`,
826
1562
  'COUNT',
827
- limit,
1563
+ READ_PAGE_SIZE,
828
1564
  )) as StreamEntry[]
829
1565
  if (entries.length === 0) return []
830
1566
 
831
- const metaFields = entries.flatMap((entry) => {
1567
+ const fieldsByKey = new Map<string, string[]>()
1568
+ for (const entry of entries) {
832
1569
  const index = entryIndex(entry[0])
833
- return STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`)
834
- })
835
- const metaValues =
836
- ((await call('HMGET', metaKey(sessionId), ...metaFields)) as Array<
837
- string | null
838
- > | null) ?? []
1570
+ const key =
1571
+ index <= metaSplit ? metaKey(sessionId) : metaChunkKey(sessionId, index)
1572
+ const fields = fieldsByKey.get(key) ?? []
1573
+ fields.push(
1574
+ ...STORED_EVENT_META_SUFFIXES.map((suffix) => `${index}:${suffix}`),
1575
+ )
1576
+ fieldsByKey.set(key, fields)
1577
+ }
1578
+ const lookups = [...fieldsByKey]
1579
+ const results = await Promise.allSettled(
1580
+ lookups.map(([key, fields]) => call('HMGET', key, ...fields)),
1581
+ )
839
1582
  const meta: Record<string, string> = {}
840
- for (let i = 0; i < metaFields.length; i += 1) {
841
- const value = metaValues[i]
842
- if (value !== null && value !== undefined) {
843
- meta[metaFields[i]!] = value
1583
+ for (let position = 0; position < lookups.length; position += 1) {
1584
+ const result = results[position]!
1585
+ if (result.status === 'rejected') throw result.reason
1586
+ const fields = lookups[position]![1]
1587
+ const values = (result.value as Array<string | null> | null) ?? []
1588
+ for (let i = 0; i < fields.length; i += 1) {
1589
+ const value = values[i]
1590
+ if (value !== null && value !== undefined) {
1591
+ meta[fields[i]!] = value
1592
+ }
844
1593
  }
845
1594
  }
846
1595
  return entries.map((entry) => {
847
- const event = toEvent(sessionId, entry)
848
- const idx = event.index
849
- const fields = fieldMap(entry[1])
850
- const processedAt = meta[`${idx}:p`]
851
- const processedByAttempt = meta[`${idx}:pa`]
852
- const returnedEventIds = meta[`${idx}:ri`]
853
- const claimHolder = meta[`${idx}:ch`]
854
- const claimExpiresAt = meta[`${idx}:ce`]
855
- const failedAt = meta[`${idx}:f`]
856
- const firstClaimedAt = meta[`${idx}:fc`]
857
- const lastClaimedAt = meta[`${idx}:lc`]
858
- const lastFailedAt = meta[`${idx}:lf`]
859
- const lastFailedAttempt = meta[`${idx}:lfa`]
860
- return Object.assign(event, {
861
- cause: toCause(fields['cause']),
862
- lane: fields['lane'] === '' ? null : fields['lane']!,
863
- processedAt:
864
- processedAt === undefined ? null : new Date(Number(processedAt)),
865
- processedByAttempt:
866
- processedByAttempt === undefined ? null : Number(processedByAttempt),
867
- returnedEventIds:
868
- returnedEventIds === undefined
869
- ? null
870
- : decodeReturnedEventIds(returnedEventIds),
871
- firstClaimedAt:
872
- firstClaimedAt === undefined
873
- ? null
874
- : new Date(Number(firstClaimedAt)),
875
- lastClaimedAt:
876
- lastClaimedAt === undefined ? null : new Date(Number(lastClaimedAt)),
877
- attemptCount: Number(meta[`${idx}:d`] ?? 0),
878
- claimHolder: claimHolder ?? null,
879
- claimExpiresAt:
880
- claimExpiresAt === undefined
881
- ? null
882
- : new Date(Number(claimExpiresAt)),
883
- failureCount: Number(meta[`${idx}:a`] ?? 0),
884
- lastFailedAt:
885
- lastFailedAt === undefined ? null : new Date(Number(lastFailedAt)),
886
- lastFailedAttempt:
887
- lastFailedAttempt === undefined ? null : Number(lastFailedAttempt),
888
- lastError: meta[`${idx}:e`] ?? null,
889
- failedAt: failedAt === undefined ? null : new Date(Number(failedAt)),
890
- })
1596
+ const idx = entryIndex(entry[0])
1597
+ return toStoredEvent(
1598
+ sessionId,
1599
+ entry,
1600
+ (suffix) => meta[`${idx}:${suffix}`],
1601
+ )
891
1602
  })
892
1603
  }
893
1604
 
@@ -910,18 +1621,12 @@ export function createRedisStoreCore(
910
1621
  READ_PAGE_SIZE,
911
1622
  )) as StreamEntry[]
912
1623
  if (entries.length === 0) {
913
- throw new A2Error(
914
- 'STORE_UNAVAILABLE',
915
- `redis event range for session '${sessionId}' ended before index ${throughIndex}`,
916
- )
1624
+ throw rangeGap(sessionId, `ended before index ${throughIndex}`)
917
1625
  }
918
1626
  for (let position = 0; position < entries.length; position += 1) {
919
1627
  const index = entryIndex(entries[position]![0])
920
1628
  if (index !== nextIndex + position || index > throughIndex) {
921
- throw new A2Error(
922
- 'STORE_UNAVAILABLE',
923
- `redis event range for session '${sessionId}' is not contiguous`,
924
- )
1629
+ throw rangeGap(sessionId, 'is not contiguous')
925
1630
  }
926
1631
  }
927
1632
  events.push(...entries.map((entry) => toEvent(sessionId, entry)))
@@ -972,34 +1677,43 @@ export function createRedisStoreCore(
972
1677
  sessionId: string,
973
1678
  afterIndex: number,
974
1679
  throughIndex?: number,
975
- ): Promise<StoredEvent[]> => {
976
- if (throughIndex !== undefined && throughIndex <= afterIndex) return []
977
- const currentFrontier = await readFrontier(sessionId)
1680
+ ): Promise<StoredEvent[]> =>
1681
+ (await readBoundedRange(sessionId, afterIndex, throughIndex)).rows
1682
+
1683
+ /** `readRange`, with the clear boundary its rows start at. */
1684
+ const readBoundedRange = async (
1685
+ sessionId: string,
1686
+ afterIndex: number,
1687
+ throughIndex?: number,
1688
+ ): Promise<{ rows: StoredEvent[]; clearIndex: number }> => {
1689
+ if (throughIndex !== undefined && throughIndex <= afterIndex)
1690
+ return { rows: [], clearIndex: 0 }
1691
+ const {
1692
+ frontier: currentFrontier,
1693
+ metaSplit,
1694
+ clearIndex,
1695
+ } = await readRangeBounds(sessionId)
978
1696
  const frontier = Math.min(throughIndex ?? currentFrontier, currentFrontier)
979
- if (frontier <= afterIndex) return []
1697
+ // Reads start at the latest clear marker.
1698
+ const after = Math.max(afterIndex, clearIndex - 1)
1699
+ if (frontier <= after) return { rows: [], clearIndex }
980
1700
 
981
1701
  const rows: StoredEvent[] = []
982
- let nextIndex = afterIndex + 1
1702
+ let nextIndex = after + 1
983
1703
  while (nextIndex <= frontier) {
984
1704
  // oxlint-disable-next-line no-await-in-loop -- each page advances the dense frozen range
985
- const page = await readRangePage(
1705
+ const page = await readRangePage({
986
1706
  sessionId,
987
- nextIndex - 1,
988
- frontier,
989
- READ_PAGE_SIZE,
990
- )
1707
+ afterIndex: nextIndex - 1,
1708
+ throughIndex: frontier,
1709
+ metaSplit,
1710
+ })
991
1711
  if (page.length === 0) {
992
- throw new A2Error(
993
- 'STORE_UNAVAILABLE',
994
- `redis event range for session '${sessionId}' ended before index ${frontier}`,
995
- )
1712
+ throw rangeGap(sessionId, `ended before index ${frontier}`)
996
1713
  }
997
1714
  for (let position = 0; position < page.length; position += 1) {
998
1715
  if (page[position]!.index !== nextIndex + position) {
999
- throw new A2Error(
1000
- 'STORE_UNAVAILABLE',
1001
- `redis event range for session '${sessionId}' is not contiguous`,
1002
- )
1716
+ throw rangeGap(sessionId, 'is not contiguous')
1003
1717
  }
1004
1718
  }
1005
1719
  const lastIndex = page.at(-1)!.index
@@ -1012,11 +1726,45 @@ export function createRedisStoreCore(
1012
1726
  rows.push(...page)
1013
1727
  nextIndex = lastIndex + 1
1014
1728
  }
1015
- return rows
1729
+ return { rows, clearIndex }
1016
1730
  }
1017
1731
 
1732
+ const readStream = options.readStream
1018
1733
  return {
1019
- async append(sessionId, events) {
1734
+ ...(readStream
1735
+ ? {
1736
+ streamEvents: async function* ({
1737
+ sessionId,
1738
+ afterIndex,
1739
+ signal,
1740
+ }: {
1741
+ sessionId: string
1742
+ afterIndex: number
1743
+ signal: AbortSignal
1744
+ }): AsyncGenerator<SerializedEvent[], void> {
1745
+ try {
1746
+ for await (const entries of readStream({
1747
+ key: storeKey(sessionId),
1748
+ from: `${afterIndex + 1}-0`,
1749
+ signal,
1750
+ })) {
1751
+ signal.throwIfAborted()
1752
+ yield entries.map((entry) =>
1753
+ toSerializedEvent(sessionId, entry),
1754
+ )
1755
+ }
1756
+ } catch (cause) {
1757
+ if (cause instanceof A2Error) throw cause
1758
+ throw new A2Error(
1759
+ 'STORE_UNAVAILABLE',
1760
+ 'redis stream read failed',
1761
+ { cause },
1762
+ )
1763
+ }
1764
+ },
1765
+ }
1766
+ : {}),
1767
+ async append(sessionId, events, appendOptions) {
1020
1768
  return wrap(async () => {
1021
1769
  const supplied = events.filter((e) => e.id !== undefined)
1022
1770
  const suppliedIds = supplied.map((e) => e.id as string)
@@ -1048,17 +1796,18 @@ export function createRedisStoreCore(
1048
1796
  e.settled ? '1' : '0',
1049
1797
  ]),
1050
1798
  `${prefix}:${sessionId}:notify`,
1799
+ appendOptions?.originIndex ?? '',
1051
1800
  ]
1052
1801
  const reply = (await evalScript(
1053
1802
  APPEND_LUA,
1054
1803
  [
1055
1804
  storeKey(sessionId),
1056
1805
  countKey(sessionId),
1057
- idsKey,
1058
1806
  metaKey(sessionId),
1059
1807
  pendingKey(sessionId),
1060
1808
  readyKey(sessionId),
1061
- watchedKey(sessionId),
1809
+ metaSplitKey(sessionId),
1810
+ ...withIds.map((e) => idKey(e.id)),
1062
1811
  ],
1063
1812
  args,
1064
1813
  )) as string[]
@@ -1078,6 +1827,11 @@ export function createRedisStoreCore(
1078
1827
  `no event at index ${reply[1]} in session '${sessionId}'`,
1079
1828
  )
1080
1829
  }
1830
+ if (verdict === 'cleared') {
1831
+ throw sessionClearedError(
1832
+ `scheduled work from event ${reply[1]} was cleared in session '${sessionId}'`,
1833
+ )
1834
+ }
1081
1835
  if (verdict === 'superseded') {
1082
1836
  throw new A2Error(
1083
1837
  'SUPERSEDED_ATTEMPT',
@@ -1091,13 +1845,23 @@ export function createRedisStoreCore(
1091
1845
  )
1092
1846
  }
1093
1847
  if (verdict === 'dup') {
1094
- // A retry of a committed batch whose ack was lost — return
1095
- // the original rows, ordered by index like every adapter.
1096
1848
  const hasPending = reply[1] === '1'
1097
- const wanted = new Set(reply.slice(2).map(Number))
1098
- const min = Math.min(...wanted)
1099
- const rows = await readRange(sessionId, min - 1)
1100
- const existing = rows.filter((row) => wanted.has(row.index))
1849
+ const firstIndex = Math.min(...reply.slice(2).map(Number))
1850
+ // A valid retry occupies exactly events.length consecutive indexes.
1851
+ const { rows: existing, clearIndex } = await retryAfterGap(() =>
1852
+ readBoundedRange(
1853
+ sessionId,
1854
+ firstIndex - 1,
1855
+ firstIndex + events.length - 1,
1856
+ ),
1857
+ )
1858
+ if (firstIndex < clearIndex) {
1859
+ // Cleared after the script matched the retry: the ids are free.
1860
+ throw new A2Error(
1861
+ 'STORE_UNAVAILABLE',
1862
+ `session '${sessionId}' was cleared while an append retry replayed; retry it`,
1863
+ )
1864
+ }
1101
1865
  return {
1102
1866
  events: idempotentReplay(events, existing),
1103
1867
  hasPending,
@@ -1134,28 +1898,42 @@ export function createRedisStoreCore(
1134
1898
  },
1135
1899
 
1136
1900
  async read(sessionId, opts) {
1137
- return wrap(async () => {
1138
- const rows = await readRange(
1139
- sessionId,
1140
- opts?.afterIndex ?? 0,
1141
- opts?.throughIndex,
1142
- )
1143
- return rows
1144
- })
1901
+ return wrap(() =>
1902
+ retryAfterGap(() =>
1903
+ readRange(sessionId, opts?.afterIndex ?? 0, opts?.throughIndex),
1904
+ ),
1905
+ )
1145
1906
  },
1146
1907
 
1147
1908
  async readHistory(sessionId, opts) {
1148
- return wrap(async () => {
1149
- const after = opts?.afterIndex ?? 0
1150
- if (opts?.throughIndex !== undefined && opts.throughIndex <= after)
1151
- return []
1152
- const frontier = await readFrontier(sessionId)
1153
- return readEventRange(
1154
- sessionId,
1155
- after,
1156
- Math.min(opts?.throughIndex ?? frontier, frontier),
1157
- )
1158
- })
1909
+ const after = opts?.afterIndex ?? 0
1910
+ if (opts?.throughIndex !== undefined && opts.throughIndex <= after)
1911
+ return []
1912
+ return wrap(() =>
1913
+ retryAfterGap(async () => {
1914
+ const [count, cleared] = (await call(
1915
+ 'MGET',
1916
+ countKey(sessionId),
1917
+ clearKey(sessionId),
1918
+ )) as [unknown, unknown]
1919
+ const frontier = toIndex({
1920
+ raw: count,
1921
+ sessionId,
1922
+ name: 'event count',
1923
+ })
1924
+ const clearIndex = toIndex({
1925
+ raw: cleared,
1926
+ sessionId,
1927
+ name: 'clear boundary',
1928
+ })
1929
+ // Reads start at the latest clear marker.
1930
+ return readEventRange(
1931
+ sessionId,
1932
+ Math.max(after, clearIndex - 1),
1933
+ Math.min(opts?.throughIndex ?? frontier, frontier),
1934
+ )
1935
+ }),
1936
+ )
1159
1937
  },
1160
1938
 
1161
1939
  async claimAvailable({
@@ -1177,6 +1955,7 @@ export function createRedisStoreCore(
1177
1955
  metaKey(sessionId),
1178
1956
  pendingKey(sessionId),
1179
1957
  readyKey(sessionId),
1958
+ metaSplitKey(sessionId),
1180
1959
  ],
1181
1960
  [holder, now, expiresAt, excluded.length, ...excluded],
1182
1961
  )) as unknown[]
@@ -1190,7 +1969,7 @@ export function createRedisStoreCore(
1190
1969
  const expiresAt = expiresAtMs ?? now + ttlMs
1191
1970
  const reply = (await evalScript(
1192
1971
  RENEW_CLAIMS_LUA,
1193
- [countKey(sessionId), metaKey(sessionId)],
1972
+ [countKey(sessionId), metaKey(sessionId), metaSplitKey(sessionId)],
1194
1973
  [
1195
1974
  holder,
1196
1975
  now,
@@ -1223,13 +2002,13 @@ export function createRedisStoreCore(
1223
2002
  [
1224
2003
  storeKey(sessionId),
1225
2004
  countKey(sessionId),
1226
- idsKey,
1227
2005
  metaKey(sessionId),
1228
2006
  pendingKey(sessionId),
1229
2007
  readyKey(sessionId),
1230
- watchedKey(sessionId),
1231
2008
  snapshotEventPinsKey(sessionId, index),
1232
2009
  snapshotPinCountsKey(sessionId),
2010
+ metaSplitKey(sessionId),
2011
+ ...ids.map((id) => idKey(id)),
1233
2012
  ],
1234
2013
  [
1235
2014
  sessionId,
@@ -1295,13 +2074,16 @@ export function createRedisStoreCore(
1295
2074
  if (events.length === 0) {
1296
2075
  return { outcome: 'completed', events: [] }
1297
2076
  }
1298
- const wanted = new Set(reply.slice(1).map(Number))
1299
- const min = Math.min(...wanted)
1300
- const rows = await readRange(sessionId, min - 1)
1301
- const byIndex = new Map(rows.map((row) => [row.index, row]))
1302
- const existing = reply
1303
- .slice(1)
1304
- .map((value) => byIndex.get(Number(value))!)
2077
+ const firstIndex = Math.min(...reply.slice(1).map(Number))
2078
+ const { rows: existing, clearIndex } = await retryAfterGap(() =>
2079
+ readBoundedRange(
2080
+ sessionId,
2081
+ firstIndex - 1,
2082
+ firstIndex + events.length - 1,
2083
+ ),
2084
+ )
2085
+ // Cleared after the script matched the retry.
2086
+ if (index <= clearIndex) return { outcome: 'superseded' }
1305
2087
  return {
1306
2088
  outcome: 'completed',
1307
2089
  events: idempotentReplay(events, existing),
@@ -1322,10 +2104,13 @@ export function createRedisStoreCore(
1322
2104
  readyKey(sessionId),
1323
2105
  snapshotEventPinsKey(sessionId, index),
1324
2106
  snapshotPinCountsKey(sessionId),
2107
+ metaSplitKey(sessionId),
1325
2108
  ],
1326
2109
  [index, attempt, error, maxFailures, clock.now().getTime()],
1327
2110
  )) as string[]
1328
2111
  const outcome = reply[0]
2112
+ if (outcome === 'cleared')
2113
+ return { outcome: 'superseded', failureCount: 0 }
1329
2114
  if (outcome === 'missing') {
1330
2115
  throw new TypeError(
1331
2116
  `no event at index ${index} in session '${sessionId}'`,
@@ -1338,62 +2123,114 @@ export function createRedisStoreCore(
1338
2123
  })
1339
2124
  },
1340
2125
 
1341
- async readState(sessionId, reducerName, stateOptions) {
2126
+ async deadLetters(sessionId) {
1342
2127
  return wrap(async () => {
1343
2128
  const reply = (await evalScript(
1344
- READ_STATE_LUA,
2129
+ DEAD_LETTERS_LUA,
1345
2130
  [
1346
- snapKey(sessionId, reducerName),
1347
- snapHistoryKey(sessionId, reducerName),
2131
+ storeKey(sessionId),
1348
2132
  countKey(sessionId),
2133
+ metaKey(sessionId),
2134
+ pendingKey(sessionId),
2135
+ metaSplitKey(sessionId),
1349
2136
  ],
1350
- [
1351
- stateOptions?.snapshotThroughIndex ??
1352
- stateOptions?.throughIndex ??
1353
- '',
1354
- ],
1355
- )) as [number | string, string, string]
1356
- const rawHead = reply[0]
1357
- const raw = reply[1]
1358
- const headIndex = rawHead === '' ? null : Number(rawHead)
1359
- const parsed =
1360
- raw === ''
1361
- ? null
1362
- : (JSON.parse(raw) as { index: number; state: unknown })
1363
- const currentFrontier = Number(reply[2])
1364
- if (
1365
- !Number.isSafeInteger(currentFrontier) ||
1366
- currentFrontier < 0 ||
1367
- (headIndex !== null &&
1368
- (!Number.isSafeInteger(headIndex) || headIndex < 0)) ||
1369
- (parsed !== null &&
1370
- (!Number.isSafeInteger(parsed.index) || parsed.index < 0))
1371
- ) {
1372
- throw new A2Error(
1373
- 'STORE_UNAVAILABLE',
1374
- `redis state frontier for session '${sessionId}' is invalid`,
2137
+ [],
2138
+ )) as unknown[]
2139
+ const events: StoredEvent[] = []
2140
+ for (let i = 0; i + 1 < reply.length; i += 2) {
2141
+ const columns = reply[i + 1] as Array<string | null>
2142
+ events.push(
2143
+ toStoredEvent(
2144
+ sessionId,
2145
+ reply[i] as StreamEntry,
2146
+ (suffix) =>
2147
+ columns[STORED_EVENT_META_SUFFIXES.indexOf(suffix)] ??
2148
+ undefined,
2149
+ ),
1375
2150
  )
1376
2151
  }
1377
- const frontier = Math.min(
1378
- currentFrontier,
1379
- stateOptions?.throughIndex ?? currentFrontier,
1380
- )
1381
- const snapshot = parsed
1382
- const events = await readEventRange(
1383
- sessionId,
1384
- snapshot?.index ?? 0,
1385
- frontier,
1386
- )
1387
- return {
1388
- headIndex,
1389
- snapshot: snapshot
1390
- ? { index: snapshot.index, state: snapshot.state }
1391
- : null,
1392
- events,
1393
- }
2152
+ return events
2153
+ })
2154
+ },
2155
+
2156
+ async redrive({ sessionId, index, action }) {
2157
+ return wrap(async () => {
2158
+ const [redriven, hasPending] = (await evalScript(
2159
+ REDRIVE_LUA,
2160
+ [
2161
+ countKey(sessionId),
2162
+ metaKey(sessionId),
2163
+ pendingKey(sessionId),
2164
+ readyKey(sessionId),
2165
+ metaSplitKey(sessionId),
2166
+ ],
2167
+ [index, action, clock.now().getTime()],
2168
+ )) as [string, string]
2169
+ return { redriven: redriven === '1', hasPending: hasPending === '1' }
1394
2170
  })
1395
2171
  },
1396
2172
 
2173
+ async readState(sessionId, reducerName, stateOptions) {
2174
+ return wrap(() =>
2175
+ retryAfterGap(async () => {
2176
+ const reply = (await evalScript(
2177
+ READ_STATE_LUA,
2178
+ [
2179
+ snapKey(sessionId, reducerName),
2180
+ snapHistoryKey(sessionId, reducerName),
2181
+ countKey(sessionId),
2182
+ ],
2183
+ [
2184
+ stateOptions?.snapshotThroughIndex ??
2185
+ stateOptions?.throughIndex ??
2186
+ '',
2187
+ ],
2188
+ )) as [number | string, string, string, string]
2189
+ const rawHead = reply[0]
2190
+ const raw = reply[1]
2191
+ const headIndex = rawHead === '' ? null : Number(rawHead)
2192
+ const parsed =
2193
+ raw === ''
2194
+ ? null
2195
+ : (JSON.parse(raw) as { index: number; state: unknown })
2196
+ const currentFrontier = Number(reply[2])
2197
+ if (
2198
+ !Number.isSafeInteger(currentFrontier) ||
2199
+ currentFrontier < 0 ||
2200
+ (headIndex !== null &&
2201
+ (!Number.isSafeInteger(headIndex) || headIndex < 0)) ||
2202
+ (parsed !== null &&
2203
+ (!Number.isSafeInteger(parsed.index) || parsed.index < 0))
2204
+ ) {
2205
+ throw new A2Error(
2206
+ 'STORE_UNAVAILABLE',
2207
+ `redis state frontier for session '${sessionId}' is invalid`,
2208
+ )
2209
+ }
2210
+ const frontier = Math.min(
2211
+ currentFrontier,
2212
+ stateOptions?.throughIndex ?? currentFrontier,
2213
+ )
2214
+ const snapshot = parsed
2215
+ const clearIndex = Number(reply[3] ?? 0)
2216
+ // The fold starts at the latest clear marker when no checkpoint is newer.
2217
+ const events = await readEventRange(
2218
+ sessionId,
2219
+ Math.max(snapshot?.index ?? 0, clearIndex - 1),
2220
+ frontier,
2221
+ )
2222
+ return {
2223
+ ...(clearIndex > 0 ? { clearIndex } : {}),
2224
+ headIndex,
2225
+ snapshot: snapshot
2226
+ ? { index: snapshot.index, state: snapshot.state }
2227
+ : null,
2228
+ events,
2229
+ }
2230
+ }),
2231
+ )
2232
+ },
2233
+
1397
2234
  async putSnapshots(sessionId, reducerName, writes) {
1398
2235
  const byIndex = new Map<
1399
2236
  number,
@@ -1431,6 +2268,8 @@ export function createRedisStoreCore(
1431
2268
  index: write.index,
1432
2269
  state: write.state,
1433
2270
  updatedAt: clock.now().getTime(),
2271
+ // This reducer is in the session's registry (PUT_SNAPSHOTS_LUA).
2272
+ r: 1,
1434
2273
  }),
1435
2274
  write.pinEventIndexes.size,
1436
2275
  ...write.pinEventIndexes,
@@ -1444,8 +2283,9 @@ export function createRedisStoreCore(
1444
2283
  countKey(sessionId),
1445
2284
  metaKey(sessionId),
1446
2285
  snapshotPinCountsKey(sessionId),
2286
+ metaSplitKey(sessionId),
1447
2287
  ],
1448
- args,
2288
+ [...args, reducerName],
1449
2289
  )
1450
2290
  })
1451
2291
  },
@@ -1540,6 +2380,105 @@ export function createRedisStoreCore(
1540
2380
  return wrap(() => readRange(sessionId, afterIndex, throughIndex))
1541
2381
  },
1542
2382
 
2383
+ async readTail(sessionId, fromIndex, throughIndex) {
2384
+ return wrap(async () => {
2385
+ const newest = (await call(
2386
+ 'XREVRANGE',
2387
+ storeKey(sessionId),
2388
+ throughIndex === undefined ? '+' : `${throughIndex}-0`,
2389
+ `${fromIndex}-0`,
2390
+ 'COUNT',
2391
+ READ_PAGE_SIZE,
2392
+ )) as StreamEntry[]
2393
+ const page = newest
2394
+ .toReversed()
2395
+ .map((entry) => toEvent(sessionId, entry))
2396
+ for (let position = 1; position < page.length; position += 1) {
2397
+ if (page[position]!.index !== page[position - 1]!.index + 1) {
2398
+ throw new A2Error(
2399
+ 'STORE_UNAVAILABLE',
2400
+ `redis event range for session '${sessionId}' is not contiguous`,
2401
+ )
2402
+ }
2403
+ }
2404
+ const oldest = page[0]?.index
2405
+ return oldest === undefined || oldest <= fromIndex ? page : null
2406
+ })
2407
+ },
2408
+
2409
+ async clear({ sessionId, ifLastIndex, cause }) {
2410
+ return wrap(async () => {
2411
+ const markerId = generateId()
2412
+ const reply = (await evalScript(
2413
+ CLEAR_LUA,
2414
+ [
2415
+ storeKey(sessionId),
2416
+ countKey(sessionId),
2417
+ metaKey(sessionId),
2418
+ metaSplitKey(sessionId),
2419
+ idKey(markerId),
2420
+ pendingKey(sessionId),
2421
+ readyKey(sessionId),
2422
+ ],
2423
+ [
2424
+ sessionId,
2425
+ clock.now().getTime(),
2426
+ ifLastIndex ?? '',
2427
+ cause?.index ?? '',
2428
+ cause?.attempt ?? '',
2429
+ markerId,
2430
+ `${prefix}:${sessionId}:notify`,
2431
+ ],
2432
+ )) as string[]
2433
+ if (reply[0] === 'superseded') {
2434
+ throw supersededAttemptError(sessionId, cause!)
2435
+ }
2436
+ if (reply[0] === 'no_parent') {
2437
+ throw new TypeError(
2438
+ `no event at index ${reply[1]} in session '${sessionId}'`,
2439
+ )
2440
+ }
2441
+ if (reply[0] === 'foreign') {
2442
+ throw new A2Error(
2443
+ 'PARTIAL_DUPLICATE_BATCH',
2444
+ `the clear marker id '${markerId}' already exists`,
2445
+ )
2446
+ }
2447
+ return {
2448
+ cleared: reply[1] === '1',
2449
+ index: Number(reply[2]),
2450
+ clearIndex: Number(reply[3]),
2451
+ }
2452
+ })
2453
+ },
2454
+
2455
+ async reclaim(sessionId) {
2456
+ return wrap(async () => {
2457
+ const [log, ...pairs] = (await evalScript(
2458
+ RECLAIM_BATCH_LUA,
2459
+ [countKey(sessionId)],
2460
+ [READ_PAGE_SIZE],
2461
+ )) as string[]
2462
+ if (log === undefined) return { deleted: 0, done: true }
2463
+ // Id keys are built here, like append's: they use JSON.stringify,
2464
+ // whose escaping cjson does not reproduce.
2465
+ const ids: string[] = []
2466
+ for (let i = 1; i < pairs.length; i += 2) ids.push(pairs[i]!)
2467
+ const [deleted, done] = (await evalScript(
2468
+ RECLAIM_LUA,
2469
+ [
2470
+ log,
2471
+ countKey(sessionId),
2472
+ metaKey(sessionId),
2473
+ metaSplitKey(sessionId),
2474
+ ...ids.map((id) => idKey(id)),
2475
+ ],
2476
+ [sessionId, snapshotEventPinsPrefix(sessionId), ids.length, ...pairs],
2477
+ )) as [number, number]
2478
+ return { deleted: Number(deleted), done: Number(done) === 1 }
2479
+ })
2480
+ },
2481
+
1543
2482
  async readEvents(sessionId, afterIndex) {
1544
2483
  return wrap(async () => {
1545
2484
  const entries = (await call(
@@ -1553,9 +2492,27 @@ export function createRedisStoreCore(
1553
2492
  return entries.map((entry) => toEvent(sessionId, entry))
1554
2493
  })
1555
2494
  },
1556
-
1557
- async markWatched(sessionId, ttlMs) {
1558
- await wrap(() => call('SET', watchedKey(sessionId), '1', 'PX', ttlMs))
2495
+ async readFeedPage(sessionId, afterIndex) {
2496
+ return wrap(async () => {
2497
+ const range = [
2498
+ storeKey(sessionId),
2499
+ `${afterIndex + 1}-0`,
2500
+ '+',
2501
+ 'COUNT',
2502
+ FEED_PAGE_SIZE,
2503
+ ] as const
2504
+ if (callBytes) {
2505
+ const entries = (await callBytes(
2506
+ 'XRANGE',
2507
+ ...range,
2508
+ )) as ByteStreamEntry[]
2509
+ return entries.map((entry) =>
2510
+ bytesToSerializedEvent(sessionId, entry),
2511
+ )
2512
+ }
2513
+ const entries = (await call('XRANGE', ...range)) as StreamEntry[]
2514
+ return entries.map((entry) => toSerializedEvent(sessionId, entry))
2515
+ })
1559
2516
  },
1560
2517
  }
1561
2518
  }