@adonis-agora/durable 0.49.1 → 0.50.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 (324) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/README.md +53 -22
  3. package/dist/assets/spa/assets/index-DupjrK7N.js +16 -0
  4. package/dist/assets/spa/assets/index-DupjrK7N.js.map +1 -0
  5. package/dist/assets/spa/index.html +1 -1
  6. package/dist/commands/durable_worker.d.ts.map +1 -1
  7. package/dist/commands/durable_worker.js +9 -8
  8. package/dist/commands/durable_worker.js.map +1 -1
  9. package/dist/configure.d.ts.map +1 -1
  10. package/dist/configure.js +1 -0
  11. package/dist/configure.js.map +1 -1
  12. package/dist/providers/dashboard_provider.d.ts +2 -0
  13. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  14. package/dist/providers/dashboard_provider.js +50 -6
  15. package/dist/providers/dashboard_provider.js.map +1 -1
  16. package/dist/providers/durable_provider.d.ts.map +1 -1
  17. package/dist/providers/durable_provider.js +143 -34
  18. package/dist/providers/durable_provider.js.map +1 -1
  19. package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
  20. package/dist/src/admission-redis/redis-admission-backend.js +3 -1
  21. package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
  22. package/dist/src/ambient-step.d.ts +24 -0
  23. package/dist/src/ambient-step.d.ts.map +1 -0
  24. package/dist/src/ambient-step.js +42 -0
  25. package/dist/src/ambient-step.js.map +1 -0
  26. package/dist/src/base-workflow.d.ts +10 -1
  27. package/dist/src/base-workflow.d.ts.map +1 -1
  28. package/dist/src/base-workflow.js.map +1 -1
  29. package/dist/src/codec-state-store.d.ts +28 -2
  30. package/dist/src/codec-state-store.d.ts.map +1 -1
  31. package/dist/src/codec-state-store.js +61 -3
  32. package/dist/src/codec-state-store.js.map +1 -1
  33. package/dist/src/commands/worker.d.ts +4 -1
  34. package/dist/src/commands/worker.d.ts.map +1 -1
  35. package/dist/src/commands/worker.js +27 -3
  36. package/dist/src/commands/worker.js.map +1 -1
  37. package/dist/src/config_types.d.ts +6 -0
  38. package/dist/src/config_types.d.ts.map +1 -1
  39. package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
  40. package/dist/src/control-plane-redis/redis-control-plane.js +22 -6
  41. package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
  42. package/dist/src/control-planes/factory.d.ts +6 -0
  43. package/dist/src/control-planes/factory.d.ts.map +1 -1
  44. package/dist/src/control-planes/factory.js +1 -0
  45. package/dist/src/control-planes/factory.js.map +1 -1
  46. package/dist/src/dashboard/gateway-adapter.d.ts +15 -5
  47. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
  48. package/dist/src/dashboard/gateway-adapter.js +71 -7
  49. package/dist/src/dashboard/gateway-adapter.js.map +1 -1
  50. package/dist/src/dashboard/handlers.d.ts +53 -1
  51. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  52. package/dist/src/dashboard/handlers.js +200 -20
  53. package/dist/src/dashboard/handlers.js.map +1 -1
  54. package/dist/src/dashboard/index.d.ts +2 -2
  55. package/dist/src/dashboard/index.d.ts.map +1 -1
  56. package/dist/src/dashboard/index.js +2 -2
  57. package/dist/src/dashboard/index.js.map +1 -1
  58. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  59. package/dist/src/dashboard/openapi.js +71 -1
  60. package/dist/src/dashboard/openapi.js.map +1 -1
  61. package/dist/src/define_config.d.ts +122 -8
  62. package/dist/src/define_config.d.ts.map +1 -1
  63. package/dist/src/define_config.js.map +1 -1
  64. package/dist/src/diagnostics-bridge.d.ts +16 -3
  65. package/dist/src/diagnostics-bridge.d.ts.map +1 -1
  66. package/dist/src/diagnostics-bridge.js +9 -3
  67. package/dist/src/diagnostics-bridge.js.map +1 -1
  68. package/dist/src/discovery-roots.d.ts +92 -0
  69. package/dist/src/discovery-roots.d.ts.map +1 -0
  70. package/dist/src/discovery-roots.js +150 -0
  71. package/dist/src/discovery-roots.js.map +1 -0
  72. package/dist/src/engine.d.ts +418 -20
  73. package/dist/src/engine.d.ts.map +1 -1
  74. package/dist/src/engine.js +1523 -159
  75. package/dist/src/engine.js.map +1 -1
  76. package/dist/src/entities.d.ts +36 -1
  77. package/dist/src/entities.d.ts.map +1 -1
  78. package/dist/src/entities.js +65 -4
  79. package/dist/src/entities.js.map +1 -1
  80. package/dist/src/errors.d.ts +36 -0
  81. package/dist/src/errors.d.ts.map +1 -1
  82. package/dist/src/errors.js +44 -0
  83. package/dist/src/errors.js.map +1 -1
  84. package/dist/src/event-trigger-bridge.d.ts +8 -0
  85. package/dist/src/event-trigger-bridge.d.ts.map +1 -1
  86. package/dist/src/event-trigger-bridge.js +24 -3
  87. package/dist/src/event-trigger-bridge.js.map +1 -1
  88. package/dist/src/handshake/announced.d.ts +27 -0
  89. package/dist/src/handshake/announced.d.ts.map +1 -1
  90. package/dist/src/handshake/announced.js +62 -0
  91. package/dist/src/handshake/announced.js.map +1 -1
  92. package/dist/src/handshake/directory.d.ts +92 -0
  93. package/dist/src/handshake/directory.d.ts.map +1 -0
  94. package/dist/src/handshake/directory.js +139 -0
  95. package/dist/src/handshake/directory.js.map +1 -0
  96. package/dist/src/hooks/barrel-sources.d.ts +28 -0
  97. package/dist/src/hooks/barrel-sources.d.ts.map +1 -0
  98. package/dist/src/hooks/barrel-sources.js +92 -0
  99. package/dist/src/hooks/barrel-sources.js.map +1 -0
  100. package/dist/src/hooks/steps.d.ts +12 -3
  101. package/dist/src/hooks/steps.d.ts.map +1 -1
  102. package/dist/src/hooks/steps.js +7 -11
  103. package/dist/src/hooks/steps.js.map +1 -1
  104. package/dist/src/hooks/workflows.d.ts +12 -3
  105. package/dist/src/hooks/workflows.d.ts.map +1 -1
  106. package/dist/src/hooks/workflows.js +7 -13
  107. package/dist/src/hooks/workflows.js.map +1 -1
  108. package/dist/src/index.d.ts +12 -3
  109. package/dist/src/index.d.ts.map +1 -1
  110. package/dist/src/index.js +12 -3
  111. package/dist/src/index.js.map +1 -1
  112. package/dist/src/interfaces.d.ts +309 -21
  113. package/dist/src/interfaces.d.ts.map +1 -1
  114. package/dist/src/interfaces.js +10 -0
  115. package/dist/src/interfaces.js.map +1 -1
  116. package/dist/src/metrics.d.ts.map +1 -1
  117. package/dist/src/metrics.js +5 -0
  118. package/dist/src/metrics.js.map +1 -1
  119. package/dist/src/namespaces.d.ts +17 -0
  120. package/dist/src/namespaces.d.ts.map +1 -0
  121. package/dist/src/namespaces.js +24 -0
  122. package/dist/src/namespaces.js.map +1 -0
  123. package/dist/src/otel/durable-metrics.d.ts.map +1 -1
  124. package/dist/src/otel/durable-metrics.js +5 -0
  125. package/dist/src/otel/durable-metrics.js.map +1 -1
  126. package/dist/src/otel/durable-otel.d.ts.map +1 -1
  127. package/dist/src/otel/durable-otel.js +5 -0
  128. package/dist/src/otel/durable-otel.js.map +1 -1
  129. package/dist/src/pollers.d.ts +10 -1
  130. package/dist/src/pollers.d.ts.map +1 -1
  131. package/dist/src/pollers.js +15 -5
  132. package/dist/src/pollers.js.map +1 -1
  133. package/dist/src/protocol.d.ts +12 -0
  134. package/dist/src/protocol.d.ts.map +1 -1
  135. package/dist/src/protocol.js +27 -1
  136. package/dist/src/protocol.js.map +1 -1
  137. package/dist/src/remote-event.d.ts +6 -0
  138. package/dist/src/remote-event.d.ts.map +1 -0
  139. package/dist/src/remote-event.js +19 -0
  140. package/dist/src/remote-event.js.map +1 -0
  141. package/dist/src/remote-workflow-executor.d.ts +34 -12
  142. package/dist/src/remote-workflow-executor.d.ts.map +1 -1
  143. package/dist/src/remote-workflow-executor.js +42 -40
  144. package/dist/src/remote-workflow-executor.js.map +1 -1
  145. package/dist/src/run-detail-summary.d.ts +90 -3
  146. package/dist/src/run-detail-summary.d.ts.map +1 -1
  147. package/dist/src/run-detail-summary.js +360 -2
  148. package/dist/src/run-detail-summary.js.map +1 -1
  149. package/dist/src/run-facets.d.ts +48 -0
  150. package/dist/src/run-facets.d.ts.map +1 -0
  151. package/dist/src/run-facets.js +100 -0
  152. package/dist/src/run-facets.js.map +1 -0
  153. package/dist/src/run-gateway/interface.d.ts +27 -1
  154. package/dist/src/run-gateway/interface.d.ts.map +1 -1
  155. package/dist/src/run-gateway/proxy-run-gateway.d.ts +32 -4
  156. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
  157. package/dist/src/run-gateway/proxy-run-gateway.js +101 -5
  158. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
  159. package/dist/src/run-gateway/run-request-responder.d.ts +18 -5
  160. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
  161. package/dist/src/run-gateway/run-request-responder.js +155 -32
  162. package/dist/src/run-gateway/run-request-responder.js.map +1 -1
  163. package/dist/src/run-gateway/store-run-gateway.d.ts +23 -1
  164. package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -1
  165. package/dist/src/run-gateway/store-run-gateway.js +38 -0
  166. package/dist/src/run-gateway/store-run-gateway.js.map +1 -1
  167. package/dist/src/search-attributes-schema.d.ts +44 -0
  168. package/dist/src/search-attributes-schema.d.ts.map +1 -0
  169. package/dist/src/search-attributes-schema.js +51 -0
  170. package/dist/src/search-attributes-schema.js.map +1 -0
  171. package/dist/src/singleton-admission.d.ts +75 -0
  172. package/dist/src/singleton-admission.d.ts.map +1 -0
  173. package/dist/src/singleton-admission.js +156 -0
  174. package/dist/src/singleton-admission.js.map +1 -0
  175. package/dist/src/singleton-gate.d.ts +23 -8
  176. package/dist/src/singleton-gate.d.ts.map +1 -1
  177. package/dist/src/singleton-gate.js +59 -12
  178. package/dist/src/singleton-gate.js.map +1 -1
  179. package/dist/src/standard-schema.d.ts +83 -0
  180. package/dist/src/standard-schema.d.ts.map +1 -0
  181. package/dist/src/standard-schema.js +31 -0
  182. package/dist/src/standard-schema.js.map +1 -0
  183. package/dist/src/step-discovery.d.ts +21 -11
  184. package/dist/src/step-discovery.d.ts.map +1 -1
  185. package/dist/src/step-discovery.js +86 -45
  186. package/dist/src/step-discovery.js.map +1 -1
  187. package/dist/src/step-ref.d.ts +38 -12
  188. package/dist/src/step-ref.d.ts.map +1 -1
  189. package/dist/src/step-ref.js +5 -13
  190. package/dist/src/step-ref.js.map +1 -1
  191. package/dist/src/stores/factory.d.ts +6 -0
  192. package/dist/src/stores/factory.d.ts.map +1 -1
  193. package/dist/src/stores/factory.js +4 -1
  194. package/dist/src/stores/factory.js.map +1 -1
  195. package/dist/src/stores/lucid-helpers.d.ts +2 -0
  196. package/dist/src/stores/lucid-helpers.d.ts.map +1 -1
  197. package/dist/src/stores/lucid-helpers.js +7 -0
  198. package/dist/src/stores/lucid-helpers.js.map +1 -1
  199. package/dist/src/stores/lucid-mappers.d.ts +25 -0
  200. package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
  201. package/dist/src/stores/lucid-mappers.js +81 -18
  202. package/dist/src/stores/lucid-mappers.js.map +1 -1
  203. package/dist/src/stores/lucid-schema.d.ts +59 -1
  204. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  205. package/dist/src/stores/lucid-schema.js +271 -25
  206. package/dist/src/stores/lucid-schema.js.map +1 -1
  207. package/dist/src/stores/lucid.d.ts +61 -2
  208. package/dist/src/stores/lucid.d.ts.map +1 -1
  209. package/dist/src/stores/lucid.js +346 -41
  210. package/dist/src/stores/lucid.js.map +1 -1
  211. package/dist/src/telescope/dashboard.d.ts.map +1 -1
  212. package/dist/src/telescope/dashboard.js +30 -1
  213. package/dist/src/telescope/dashboard.js.map +1 -1
  214. package/dist/src/telescope/data-providers.d.ts +19 -1
  215. package/dist/src/telescope/data-providers.d.ts.map +1 -1
  216. package/dist/src/telescope/data-providers.js +119 -3
  217. package/dist/src/telescope/data-providers.js.map +1 -1
  218. package/dist/src/telescope/extension.d.ts.map +1 -1
  219. package/dist/src/telescope/extension.js +2 -1
  220. package/dist/src/telescope/extension.js.map +1 -1
  221. package/dist/src/telescope/index.d.ts +1 -1
  222. package/dist/src/telescope/index.d.ts.map +1 -1
  223. package/dist/src/telescope/index.js +1 -1
  224. package/dist/src/telescope/index.js.map +1 -1
  225. package/dist/src/testing/in-memory-state-store.d.ts +44 -15
  226. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  227. package/dist/src/testing/in-memory-state-store.js +204 -41
  228. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  229. package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts +16 -0
  230. package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts.map +1 -0
  231. package/dist/src/testing-kit/checkpoint-aggregation-contract.js +228 -0
  232. package/dist/src/testing-kit/checkpoint-aggregation-contract.js.map +1 -0
  233. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  234. package/dist/src/testing-kit/state-store-conformance.js +355 -3
  235. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  236. package/dist/src/transport-pool.d.ts +15 -1
  237. package/dist/src/transport-pool.d.ts.map +1 -1
  238. package/dist/src/transport-pool.js +50 -4
  239. package/dist/src/transport-pool.js.map +1 -1
  240. package/dist/src/transports/adaptive-concurrency.d.ts +168 -0
  241. package/dist/src/transports/adaptive-concurrency.d.ts.map +1 -0
  242. package/dist/src/transports/adaptive-concurrency.js +353 -0
  243. package/dist/src/transports/adaptive-concurrency.js.map +1 -0
  244. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts +1 -165
  245. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -1
  246. package/dist/src/transports/bullmq/adaptive-concurrency.js +3 -351
  247. package/dist/src/transports/bullmq/adaptive-concurrency.js.map +1 -1
  248. package/dist/src/transports/bullmq/bullmq-transport.d.ts +49 -1
  249. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  250. package/dist/src/transports/bullmq/bullmq-transport.js +330 -38
  251. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  252. package/dist/src/transports/bullmq/deps.d.ts +18 -2
  253. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  254. package/dist/src/transports/bullmq/deps.js +44 -2
  255. package/dist/src/transports/bullmq/deps.js.map +1 -1
  256. package/dist/src/transports/db-helpers.d.ts +39 -0
  257. package/dist/src/transports/db-helpers.d.ts.map +1 -1
  258. package/dist/src/transports/db-helpers.js +73 -0
  259. package/dist/src/transports/db-helpers.js.map +1 -1
  260. package/dist/src/transports/db-remote.d.ts +53 -0
  261. package/dist/src/transports/db-remote.d.ts.map +1 -0
  262. package/dist/src/transports/db-remote.js +147 -0
  263. package/dist/src/transports/db-remote.js.map +1 -0
  264. package/dist/src/transports/db-schema.d.ts +44 -1
  265. package/dist/src/transports/db-schema.d.ts.map +1 -1
  266. package/dist/src/transports/db-schema.js +227 -88
  267. package/dist/src/transports/db-schema.js.map +1 -1
  268. package/dist/src/transports/db.d.ts +189 -6
  269. package/dist/src/transports/db.d.ts.map +1 -1
  270. package/dist/src/transports/db.js +1291 -137
  271. package/dist/src/transports/db.js.map +1 -1
  272. package/dist/src/transports/event-emitter.d.ts +8 -0
  273. package/dist/src/transports/event-emitter.d.ts.map +1 -1
  274. package/dist/src/transports/event-emitter.js +36 -3
  275. package/dist/src/transports/event-emitter.js.map +1 -1
  276. package/dist/src/transports/factory.d.ts +110 -5
  277. package/dist/src/transports/factory.d.ts.map +1 -1
  278. package/dist/src/transports/factory.js +56 -9
  279. package/dist/src/transports/factory.js.map +1 -1
  280. package/dist/src/transports/queue-mock-adapter.d.ts +7 -2
  281. package/dist/src/transports/queue-mock-adapter.d.ts.map +1 -1
  282. package/dist/src/transports/queue-mock-adapter.js +18 -2
  283. package/dist/src/transports/queue-mock-adapter.js.map +1 -1
  284. package/dist/src/transports/queue-remote.d.ts +146 -0
  285. package/dist/src/transports/queue-remote.d.ts.map +1 -0
  286. package/dist/src/transports/queue-remote.js +398 -0
  287. package/dist/src/transports/queue-remote.js.map +1 -0
  288. package/dist/src/transports/queue.d.ts +159 -6
  289. package/dist/src/transports/queue.d.ts.map +1 -1
  290. package/dist/src/transports/queue.js +747 -37
  291. package/dist/src/transports/queue.js.map +1 -1
  292. package/dist/src/transports/task-failure.d.ts +31 -0
  293. package/dist/src/transports/task-failure.d.ts.map +1 -0
  294. package/dist/src/transports/task-failure.js +50 -0
  295. package/dist/src/transports/task-failure.js.map +1 -0
  296. package/dist/src/workflow-als.d.ts +1 -1
  297. package/dist/src/workflow-als.d.ts.map +1 -1
  298. package/dist/src/workflow-ctx.d.ts +10 -0
  299. package/dist/src/workflow-ctx.d.ts.map +1 -1
  300. package/dist/src/workflow-ctx.js +97 -15
  301. package/dist/src/workflow-ctx.js.map +1 -1
  302. package/dist/src/workflow-discovery.d.ts +32 -41
  303. package/dist/src/workflow-discovery.d.ts.map +1 -1
  304. package/dist/src/workflow-discovery.js +65 -88
  305. package/dist/src/workflow-discovery.js.map +1 -1
  306. package/dist/src/workflow-ref.d.ts +24 -0
  307. package/dist/src/workflow-ref.d.ts.map +1 -1
  308. package/dist/src/workflow-ref.js.map +1 -1
  309. package/dist/stubs/config/durable.stub +6 -0
  310. package/dist/stubs/config/durable_dashboard.stub +7 -3
  311. package/dist/stubs/database/migrations/create_durable_tables.stub +2 -1
  312. package/dist/stubs/database/migrations/create_durable_transport_messages.stub +43 -0
  313. package/dist/stubs/database/migrations/create_durable_transport_tables.stub +12 -9
  314. package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +1 -1
  315. package/dist/stubs/make/workflow/main.stub +1 -1
  316. package/package.json +6 -4
  317. package/skills/durable-cluster/SKILL.md +80 -11
  318. package/skills/durable-determinism/SKILL.md +11 -5
  319. package/skills/durable-reliability/SKILL.md +77 -37
  320. package/skills/durable-setup/SKILL.md +51 -22
  321. package/skills/durable-transports-stores/SKILL.md +128 -18
  322. package/skills/durable-workflows/SKILL.md +71 -18
  323. package/dist/assets/spa/assets/index-DdB9zxuX.js +0 -16
  324. package/dist/assets/spa/assets/index-DdB9zxuX.js.map +0 -1
@@ -1,8 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { Pollers } from '../pollers.js';
3
- import { runStepHandler } from '../protocol.js';
2
+ import { atLeastOne, Pollers } from '../pollers.js';
3
+ import { runStepHandler, withTurnHeartbeat } from '../protocol.js';
4
4
  import { sanitizeQueueToken, tenantGroup } from '../tenant-group.js';
5
- import { createDurableTransportTables, TRANSPORT_TABLES } from './db-schema.js';
5
+ import { AdaptiveController } from './adaptive-concurrency.js';
6
+ import { effectivePrefix, workerHeartbeatKey } from './bullmq/naming.js';
7
+ import { heartbeatKeyValue, parseHeartbeatValue, WORKER_HEARTBEAT_INTERVAL_MS, WORKER_HEARTBEAT_TTL_SECONDS, } from './bullmq/serialization.js';
8
+ import { DbInboxRegistry, DEFAULT_DB_INBOX_SWEEP_INTERVAL_MS, DEFAULT_DB_INBOX_TTL_MS, DEFAULT_DB_TENANT_SUBSCRIBERS_CACHE_MS, } from './db-remote.js';
9
+ import { createDurableTransportTables, MESSAGE_KINDS, TRANSPORT_TABLES, } from './db-schema.js';
10
+ import { fanOutDecision, infrastructureFailure } from './task-failure.js';
6
11
  import { cachedLiveKeys, descriptorsOn, liveGroupsOf, workerKeyWriter, } from './worker-keys.js';
7
12
  import { lucidWorkerKeys } from './worker-keys-db.js';
8
13
  /**
@@ -25,8 +30,48 @@ function fromJson(value) {
25
30
  const DEFAULT_POLL_INTERVAL_MS = 200;
26
31
  /** Default lease window (ms): a claimed-but-not-deleted row is reclaimable after this (crash recovery). */
27
32
  const DEFAULT_LEASE_MS = 30_000;
28
- /** Default max rows claimed per poll. */
33
+ /** Default max rows claimed per poll (results / heartbeats / control; the task loop claims only as
34
+ * many rows as it has free slots, never more than this). */
29
35
  const DEFAULT_BATCH_SIZE = 20;
36
+ /** Idle backoff for the task, heartbeat and control loops (the results loop is exempt): after
37
+ * consecutive empty polls the sleep doubles up to this many poll intervals. Same default as the
38
+ * queue transport. */
39
+ const DEFAULT_IDLE_BACKOFF_MAX = 4;
40
+ /** How many times a task row's lapsed claim may be taken over before the row is treated as poison
41
+ * (its workers keep dying on it) and failed instead of re-run. Same default as the queue transport. */
42
+ const DEFAULT_MAX_STALLED_COUNT = 3;
43
+ /** Key prefix of the worker liveness keys — the one a `WorkerRuntime` uses by default, so both kinds of
44
+ * beat land in the same keyspace and `groupHealth` reads them alike. */
45
+ const WORKER_KEY_PREFIX = 'durable';
46
+ /** Default budget (ms) `close()` waits for in-flight work before handing the rest back. */
47
+ const DEFAULT_CLOSE_TIMEOUT_MS = 5_000;
48
+ /** The `priority` column is a 32-bit integer on every dialect. */
49
+ const PRIORITY_MIN = -2_147_483_648;
50
+ const PRIORITY_MAX = 2_147_483_647;
51
+ /**
52
+ * The `priority` column value for the engine's dispatch `priority` (higher = more urgent): rounded and
53
+ * clamped to the column's range, and `0` — "normal" — when absent, which is where the queue
54
+ * transport's broker mapping puts an unprioritised job too.
55
+ */
56
+ export function toDbPriority(priority) {
57
+ if (priority == null || Number.isNaN(priority))
58
+ return 0;
59
+ return Math.min(PRIORITY_MAX, Math.max(PRIORITY_MIN, Math.round(priority)));
60
+ }
61
+ const MISSING_COLUMN = {
62
+ reclaims: {
63
+ effect: 'poison tasks are not bounded (maxStalledCount is off)',
64
+ ddl: 'integer, not null, default 0',
65
+ },
66
+ priority: {
67
+ effect: 'dispatch priority is ignored and tasks are claimed oldest first',
68
+ ddl: 'integer, not null, default 0',
69
+ },
70
+ kind: {
71
+ effect: 'remote workflow turns cannot be dispatched on it',
72
+ ddl: "string, not null, default 'step'",
73
+ },
74
+ };
30
75
  const DEFAULT_LOG = (err) => console.error('[DbTransport] poll failed', err);
31
76
  /**
32
77
  * A poll-based, DB-table-backed {@link Transport} (and best-effort {@link ControlPlane}) over
@@ -45,6 +90,12 @@ const DEFAULT_LOG = (err) => console.error('[DbTransport] poll failed', err);
45
90
  * {@link ControlPlane} here is single-consumer (one row → one poller), correct for a single engine
46
91
  * instance but NOT a true broadcast.
47
92
  *
93
+ * It also carries remote workflow turns (`dispatchWorkflowTask` / `handleWorkflow` / `onDecision`,
94
+ * streamed step events) and the store-less tenant-pod protocol (start-run, run-request, run replies,
95
+ * tenant events), so a `tenant` pod can reach a `control-plane` over the database alone. Those ride the
96
+ * `durable_transport_messages` table: run replies go to the requester only, tenant events to every
97
+ * live subscriber of the tenant, decisions to the engine that dispatched the turn — see `db-remote.ts`.
98
+ *
48
99
  * Run one instance engine-side (`onResult` + `dispatch`) and one per worker process (`handle()` for
49
100
  * its group). Call {@link start} to begin the engine-side pollers; `handle()` auto-starts the task
50
101
  * loop. The wire payloads are the documented `RemoteTask`/`StepResult` JSON.
@@ -80,8 +131,63 @@ export class DbTransport {
80
131
  #readWorkerKeys;
81
132
  #handlers = new Map();
82
133
  #pollers;
134
+ /** When set (see {@link deferConsumers}), {@link #startLoop} parks its loop until
135
+ * {@link startConsumers}. Dispatching is never deferred; only consumption is. */
136
+ #consumersDeferred = false;
137
+ #deferredStarts = [];
83
138
  #schemaReady;
84
139
  #taskLoop;
140
+ /** Fixed or adaptive task concurrency, plus the in-flight/latency/RSS/CPU telemetry the liveness
141
+ * key carries. One controller for the task loop of this instance, as on the queue transport. */
142
+ #controller;
143
+ /** A task loop parked on "every slot busy", woken when an adaptive move raises the limit. */
144
+ #limitWaiters = new Set();
145
+ /** Every task row still in a handler — the slots in use, and what `stop()` waits for (bounded). */
146
+ #runningTasks = new Set();
147
+ #resultConcurrency;
148
+ #idleBackoffMax;
149
+ #maxStalledCount;
150
+ #onError;
151
+ /** Claims this instance holds, per table: claim token → the row ids still held under it. Every one
152
+ * is renewed on {@link #renewTimer}, so no held row ages into another instance's reclaim. */
153
+ #held = new Map();
154
+ #renewTimer;
155
+ /** The renewal round in flight, so a slow database never stacks rounds. */
156
+ #renewal;
157
+ /** Whether the tasks table has each adaptive column (resolved once per column). */
158
+ #taskColumns = new Map();
159
+ /** Claim tokens `close()` handed back — a handler still running under one learns why it lost it. */
160
+ #handedBack = new Set();
161
+ #closeTimeoutMs;
162
+ /** Routing tokens this instance beats a liveness key for (one per handled step name). */
163
+ #heartbeatTokens = new Set();
164
+ #workerHeartbeatIntervalMs;
165
+ #workerHeartbeatTimer;
166
+ /** Liveness-key writes still in flight — `close()` lets them land before withdrawing the keys. */
167
+ #livenessWrites = new Set();
168
+ /** Routing tokens whose liveness-key write is still in flight (see `#startWorkerHeartbeat`). */
169
+ #beatsInFlight = new Set();
170
+ /** Workflow turn consumers by workflow name — a `workflow` task row routes here. */
171
+ #workflowTurns = new Map();
172
+ /** Every `onDecision` handler (an engine subscribes one; it applies a decision by run id). */
173
+ #decisionHandlers = new Set();
174
+ /** Which message loops run (each starts once). */
175
+ #messageLoops = new Set();
176
+ #runReplyHandler;
177
+ /** Control-plane side: correlation id (requestId, or a start-run's runId) → the requester's instance
178
+ * id, held while the request's handler runs so {@link publishRunReply} knows whom to address. */
179
+ #replyRoutes = new Map();
180
+ #tenantEventHandlers = new Map();
181
+ /** Serializes {@link publishTenantEvent} so each subscriber gets one publisher's events in order. */
182
+ #tenantPublishChain = Promise.resolve();
183
+ #inboxes;
184
+ #inboxTtlMs;
185
+ #inboxSweepIntervalMs;
186
+ #sweepTimer;
187
+ #sweeping;
188
+ /** Whether the messages table exists (resolved once), and whether its absence was reported. */
189
+ #messagesTable;
190
+ #messagesTableReported = false;
85
191
  constructor(options) {
86
192
  this.#db = options.db;
87
193
  this.#partition = options.partition;
@@ -94,7 +200,41 @@ export class DbTransport {
94
200
  this.#autoCreate = options.autoCreate ?? true;
95
201
  this.#instanceId = options.instanceId ?? randomUUID();
96
202
  this.#workerKeysCacheMs = options.workerKeysCacheMs ?? 5_000;
97
- this.#pollers = new Pollers(this.#pollIntervalMs, DEFAULT_LOG);
203
+ this.#onError = options.onError ?? DEFAULT_LOG;
204
+ this.#closeTimeoutMs =
205
+ options.closeTimeoutMs !== undefined && options.closeTimeoutMs >= 0
206
+ ? options.closeTimeoutMs
207
+ : DEFAULT_CLOSE_TIMEOUT_MS;
208
+ this.#pollers = new Pollers(this.#pollIntervalMs, this.#onError);
209
+ this.#controller = new AdaptiveController({
210
+ ...(options.concurrency !== undefined ? { concurrency: options.concurrency } : {}),
211
+ apply: () => {
212
+ for (const wake of this.#limitWaiters)
213
+ wake();
214
+ },
215
+ });
216
+ this.#resultConcurrency = atLeastOne(options.resultConcurrency, 1);
217
+ this.#idleBackoffMax = atLeastOne(options.idleBackoffMax, DEFAULT_IDLE_BACKOFF_MAX);
218
+ const maxStalled = options.maxStalledCount;
219
+ this.#maxStalledCount =
220
+ maxStalled !== undefined && !Number.isNaN(maxStalled) && maxStalled >= 0
221
+ ? Math.floor(maxStalled)
222
+ : DEFAULT_MAX_STALLED_COUNT;
223
+ this.#workerHeartbeatIntervalMs = atLeastOne(options.workerHeartbeatIntervalMs, WORKER_HEARTBEAT_INTERVAL_MS);
224
+ this.#inboxTtlMs = atLeastOne(options.inboxTtlMs, DEFAULT_DB_INBOX_TTL_MS);
225
+ this.#inboxSweepIntervalMs =
226
+ options.inboxSweepIntervalMs !== undefined && Number.isFinite(options.inboxSweepIntervalMs)
227
+ ? Math.max(0, options.inboxSweepIntervalMs)
228
+ : DEFAULT_DB_INBOX_SWEEP_INTERVAL_MS;
229
+ this.#inboxes = new DbInboxRegistry({
230
+ store: () => this.#workerKeys(),
231
+ prefix: () => this.#keyPrefix(),
232
+ instanceId: this.#instanceId,
233
+ ttlMs: this.#inboxTtlMs,
234
+ subscribersCacheMs: options.tenantSubscribersCacheMs ?? DEFAULT_DB_TENANT_SUBSCRIBERS_CACHE_MS,
235
+ onError: this.#onError,
236
+ now: () => this.#now(),
237
+ });
98
238
  }
99
239
  /** Stable id stamped on heartbeats, control `from`, and `claimed_by`. */
100
240
  get instanceId() {
@@ -133,6 +273,7 @@ export class DbTransport {
133
273
  // ───────────────────────────────────────────────────────────────────────────
134
274
  async dispatch(task) {
135
275
  await this.#ensureSchema();
276
+ const withPriority = await this.#hasTaskColumn('priority');
136
277
  // Idempotent: a redelivered dispatch for the same step_id is ignored (the PK already exists).
137
278
  // `onConflict().ignore()` is a Knex feature not surfaced on Lucid's insert contract, so we use
138
279
  // the underlying Knex builder (`.knexQuery`).
@@ -151,6 +292,7 @@ export class DbTransport {
151
292
  context: toJson(task.context),
152
293
  transport: task.transport ?? null,
153
294
  attempt: task.attempt,
295
+ ...(withPriority ? { priority: toDbPriority(task.priority) } : {}),
154
296
  claimed_by: null,
155
297
  claimed_at: null,
156
298
  created_at: this.#now(),
@@ -161,26 +303,100 @@ export class DbTransport {
161
303
  // ───────────────────────────────────────────────────────────────────────────
162
304
  // worker side — register a handler, claim + run tasks, write results
163
305
  // ───────────────────────────────────────────────────────────────────────────
306
+ /**
307
+ * Park every consumer loop registered from now on ({@link handle}, {@link onResult},
308
+ * {@link onHeartbeat}, {@link onControl}) until {@link startConsumers}. Task, result, heartbeat and
309
+ * control rows are claimed by ONE consumer each, so a process not built to be a worker — a
310
+ * `node ace` command, a REPL — would otherwise claim rows it then dies holding (re-delivered only
311
+ * after the lease expires) and steal results addressed to the long-lived engine. The same gate as
312
+ * the `queue` and `bullmq` transports; the provider calls it per the `consumers` config.
313
+ */
314
+ deferConsumers() {
315
+ this.#consumersDeferred = true;
316
+ }
317
+ /** Start every loop parked by {@link deferConsumers} and stop deferring (`durable:work` calls it via
318
+ * `engine.startConsumers()`). Idempotent; a no-op when consumption was never deferred. */
319
+ startConsumers() {
320
+ if (!this.#consumersDeferred)
321
+ return;
322
+ this.#consumersDeferred = false;
323
+ for (const start of this.#deferredStarts.splice(0))
324
+ start();
325
+ }
326
+ /** `#pollers.start`, unless consumption is deferred: then park it, returning a handle that still
327
+ * stops the loop whether it is started later or never. */
328
+ #startLoop(tick, idleBackoffMax) {
329
+ if (!this.#consumersDeferred)
330
+ return this.#pollers.start(tick, { idleBackoffMax });
331
+ let cancelled = false;
332
+ let live;
333
+ this.#deferredStarts.push(() => {
334
+ if (!cancelled)
335
+ live = this.#pollers.start(tick, { idleBackoffMax });
336
+ });
337
+ return {
338
+ stop: () => {
339
+ cancelled = true;
340
+ live?.stop();
341
+ },
342
+ };
343
+ }
344
+ /** Run `start` once this instance really consumes: now, or at {@link startConsumers} when deferred. */
345
+ #whenConsuming(start) {
346
+ if (this.#consumersDeferred)
347
+ this.#deferredStarts.push(start);
348
+ else
349
+ start();
350
+ }
164
351
  /**
165
352
  * Register a step handler (worker side). Records this name's routing token
166
353
  * (`tenantGroup(sanitizeQueueToken(name), partition)`) as served and starts the shared task poll
167
354
  * loop on the first call — it claims task rows for EVERY served token (matching the token the
168
- * engine dispatches by), runs each through {@link runStepHandler}, and writes the result.
355
+ * engine dispatches by), runs each through {@link runStepHandler}, and writes the result. The loop
356
+ * keeps up to {@link DbTransportOptions.concurrency} rows in flight, and each served token gets a
357
+ * liveness key once the loop consumes (a deferred, producer-only process advertises nothing).
169
358
  */
170
359
  handle(name, fn) {
171
360
  this.#handlers.set(name, fn);
172
- this.#servedTokens.add(tenantGroup(sanitizeQueueToken(name), this.#partition));
361
+ this.#serve(name);
362
+ }
363
+ /**
364
+ * Register a WORKFLOW turn consumer (worker side). Shares the task loop — and the routing token —
365
+ * of a step of the same name: {@link dispatchWorkflowTask} writes a task row of kind `workflow`, which
366
+ * runs `turn(task)` in a slot like any step, and its {@link WorkflowDecision} is written to the
367
+ * messages table, addressed to the engine instance that dispatched the turn. Re-registering swaps
368
+ * the turn fn.
369
+ */
370
+ handleWorkflow(name, turn) {
371
+ this.#workflowTurns.set(name, turn);
372
+ this.#serve(name);
373
+ }
374
+ /** Serve `name`'s routing token on the shared task loop (started on the first call). */
375
+ #serve(name) {
376
+ const token = tenantGroup(sanitizeQueueToken(name), this.#partition);
377
+ const isNew = !this.#servedTokens.has(token);
378
+ this.#servedTokens.add(token);
173
379
  if (!this.#taskLoop) {
174
- this.#taskLoop = this.#pollers.start(async () => (await this.#drainTasks()) > 0);
380
+ this.#taskLoop = this.#startLoop(() => this.#taskTick(), this.#idleBackoffMax);
381
+ this.#whenConsuming(() => {
382
+ for (const served of this.#servedTokens)
383
+ this.#beginServing(served);
384
+ });
385
+ }
386
+ else if (isNew) {
387
+ this.#whenConsuming(() => this.#beginServing(token));
175
388
  }
176
389
  }
177
390
  /** Worker side: publish a liveness heartbeat for an in-flight long step. */
178
391
  async heartbeat(beat) {
179
392
  await this.#ensureSchema();
180
- await this.#client().table(TRANSPORT_TABLES.heartbeats).insert({
393
+ await this.#client()
394
+ .table(TRANSPORT_TABLES.heartbeats)
395
+ .insert({
181
396
  run_id: beat.runId,
182
397
  seq: beat.seq,
183
- step_id: beat.stepId,
398
+ // '' for a run-scoped (workflow-turn) beat: the column is NOT NULL.
399
+ step_id: beat.stepId ?? '',
184
400
  grp: beat.group,
185
401
  namespace: this.#namespaceValue(),
186
402
  claimed_by: null,
@@ -188,56 +404,279 @@ export class DbTransport {
188
404
  created_at: this.#now(),
189
405
  });
190
406
  }
191
- async #drainTasks() {
407
+ /**
408
+ * One task-loop round: claim as many rows as there are free slots (never more — a row claimed but
409
+ * not running is a row an idle sibling can't run, and one whose lease nobody is using), hand each
410
+ * to {@link #runTask}, and report work so the loop re-ticks at once. With every slot busy it waits
411
+ * for one to free (or the adaptive limit to move) instead of polling.
412
+ */
413
+ async #taskTick() {
192
414
  if (this.#servedTokens.size === 0)
193
- return 0;
194
- const { rows, token } = await this.#claim(TRANSPORT_TABLES.tasks, [
195
- ...this.#servedTokens,
415
+ return false;
416
+ const free = this.#controller.limit - this.#runningTasks.size;
417
+ if (free <= 0) {
418
+ await this.#slotFreed();
419
+ return true;
420
+ }
421
+ const [countReclaims, byPriority] = await Promise.all([
422
+ this.#hasTaskColumn('reclaims'),
423
+ this.#hasTaskColumn('priority'),
196
424
  ]);
425
+ const { rows, token } = await this.#claim(TRANSPORT_TABLES.tasks, Math.min(free, this.#batchSize), [...this.#servedTokens], 'step_id', { countReclaims, byPriority });
426
+ if (rows.length === 0)
427
+ return false;
428
+ if (this.#pollers.closed) {
429
+ // close() began while this round was claiming: don't start work it would only abandon.
430
+ await this.#handBack(TRANSPORT_TABLES.tasks, [token]);
431
+ return false;
432
+ }
433
+ // `free` was read before the claim's round trips. The adaptive limit may have dropped meanwhile
434
+ // (the RAM brake fires on its own timer), and a round sized for the old limit would run past the
435
+ // new one. Un-claim what no longer fits, so the slot loop never runs more than the LIVE limit.
436
+ // (A lapsed row handed back this way keeps the reclaim its takeover counted: rare — a limit drop
437
+ // inside one claim round — and only ever early toward the poison bound, never past it unnoticed.)
438
+ const fits = Math.max(0, this.#controller.limit - this.#runningTasks.size);
439
+ if (rows.length > fits) {
440
+ const excess = rows.splice(fits);
441
+ await this.#client()
442
+ .knexQuery()
443
+ .from(TRANSPORT_TABLES.tasks)
444
+ .where('claimed_by', token)
445
+ .whereIn('step_id', excess.map((row) => row.step_id))
446
+ .update({ claimed_by: null, claimed_at: null });
447
+ if (rows.length === 0)
448
+ return true;
449
+ }
450
+ this.#hold(TRANSPORT_TABLES.tasks, token, rows.map((row) => row.step_id));
197
451
  for (const row of rows) {
198
- const task = {
199
- runId: row.run_id,
200
- seq: Number(row.seq),
201
- stepId: row.step_id,
202
- name: row.name,
203
- group: row.grp,
204
- input: fromJson(row.input),
205
- attempt: Number(row.attempt),
206
- ...(row.traceparent != null ? { traceparent: row.traceparent } : {}),
207
- ...(row.context != null ? { context: fromJson(row.context) } : {}),
208
- ...(row.transport != null ? { transport: row.transport } : {}),
209
- };
210
- // Renew this row's claim NOW (rows after the first have aged while earlier ones ran) and
211
- // KEEP renewing while the handler runs — the claim used to be stamped once for the whole
212
- // batch and never refreshed, so any step longer than `leaseMs` (30s default) was reclaimed
213
- // by a sibling worker mid-flight and GUARANTEED to double-run. A merely-slow worker now
214
- // holds its claim for the step's whole duration; a crashed one still expires and is
215
- // reclaimed.
216
- const renew = () => {
217
- void this.#client()
218
- .knexQuery()
219
- .from(TRANSPORT_TABLES.tasks)
220
- .where('step_id', task.stepId)
221
- .where('claimed_by', token)
222
- .update({ claimed_at: this.#now() })
223
- .catch(() => undefined);
224
- };
225
- renew();
226
- const renewTimer = setInterval(renew, Math.max(1_000, Math.floor(this.#leaseMs / 2)));
227
- renewTimer.unref?.();
452
+ const run = this.#runTask(row, token);
453
+ this.#runningTasks.add(run);
454
+ void run.finally(() => this.#runningTasks.delete(run));
455
+ }
456
+ return true;
457
+ }
458
+ /** Resolve when a running task settles or the adaptive controller moves the limit. */
459
+ async #slotFreed() {
460
+ let wake = () => { };
461
+ const moved = new Promise((resolve) => {
462
+ wake = resolve;
463
+ });
464
+ this.#limitWaiters.add(wake);
465
+ try {
466
+ await Promise.race([...this.#runningTasks, moved]);
467
+ }
468
+ finally {
469
+ this.#limitWaiters.delete(wake);
470
+ }
471
+ }
472
+ /**
473
+ * Run one claimed task row and settle it. A row whose claim lapsed more than
474
+ * {@link DbTransportOptions.maxStalledCount} times is bridged to a failed result instead of run.
475
+ * Otherwise the claim is re-checked first (a row is run only while this instance holds it), and the
476
+ * result is published only by the instance still holding the claim (see {@link #settleTask}).
477
+ * The claim is renewed for the whole run with every other held claim (see {@link #renewHeld}).
478
+ */
479
+ async #runTask(row, token) {
480
+ if (row.kind === 'workflow')
481
+ return this.#runWorkflowRow(row, token);
482
+ const task = {
483
+ runId: row.run_id,
484
+ seq: Number(row.seq),
485
+ stepId: row.step_id,
486
+ name: row.name,
487
+ group: row.grp,
488
+ input: fromJson(row.input),
489
+ attempt: Number(row.attempt),
490
+ ...(row.traceparent != null ? { traceparent: row.traceparent } : {}),
491
+ ...(row.context != null ? { context: fromJson(row.context) } : {}),
492
+ ...(row.transport != null ? { transport: row.transport } : {}),
493
+ };
494
+ try {
495
+ if (row.reclaims != null && Number(row.reclaims) > this.#maxStalledCount) {
496
+ // Its workers keep dying on it (or blocking past the lease): running it again would just
497
+ // stall the next one. Fail it RETRYABLE so the engine's durable retry decides.
498
+ this.#release(TRANSPORT_TABLES.tasks, token, task.stepId);
499
+ await this.#settleTask(task.stepId, token, infrastructureFailure(task, `claim lapsed more than ${this.#maxStalledCount} times`));
500
+ return;
501
+ }
502
+ if (!(await this.#stillHolds(task.stepId, token))) {
503
+ this.#release(TRANSPORT_TABLES.tasks, token, task.stepId);
504
+ this.#onError(new Error(`[DbTransport] lost the claim on task ${task.stepId} before running it — another worker took it over; not running it here`));
505
+ return;
506
+ }
507
+ const startedAt = Date.now();
508
+ this.#controller.onStart();
509
+ let ok = false;
228
510
  try {
229
511
  const result = await runStepHandler(task, this.#handlers.get(task.name), (beat) => this.heartbeat(beat));
230
- await this.#insertResult(result);
512
+ ok = result.status === 'completed';
513
+ // Stop renewing before settling: the settle deletes the row, and a renewal racing it would
514
+ // count the row as lost.
515
+ this.#release(TRANSPORT_TABLES.tasks, token, task.stepId);
516
+ if (!(await this.#settleTask(task.stepId, token, result))) {
517
+ if (this.#handedBack.has(token)) {
518
+ this.#onError(new Error(`[DbTransport] task ${task.stepId} outlived close()'s ${this.#closeTimeoutMs}ms drain and was handed back, so another worker may have run it too; this run's result was dropped`));
519
+ return;
520
+ }
521
+ this.#onError(new Error(`[DbTransport] lost the claim on task ${task.stepId} while running it (this worker went silent past leaseMs) — another worker took it over and may run it too; this run's result was dropped. Raise leaseMs if steps block the event loop`));
522
+ }
231
523
  }
232
524
  finally {
233
- clearInterval(renewTimer);
525
+ this.#controller.onSettle(Date.now() - startedAt, ok, 'step');
234
526
  }
235
- await this.#client().from(TRANSPORT_TABLES.tasks).where('step_id', task.stepId).delete();
236
527
  }
237
- return rows.length;
528
+ catch (err) {
529
+ // A database error settling the row: it stays claimed, unrenewed, and is reclaimed after the
530
+ // lease — at-least-once, like a crash.
531
+ this.#onError(err);
532
+ }
533
+ finally {
534
+ this.#release(TRANSPORT_TABLES.tasks, token, task.stepId);
535
+ }
536
+ }
537
+ /**
538
+ * Run one claimed `workflow` task row: replay the turn and settle it — delete the row and write its
539
+ * {@link WorkflowDecision}, addressed to the engine that dispatched it, in ONE transaction and only
540
+ * while this round still holds the claim (as a step's result). An unknown workflow or a throwing turn
541
+ * still yields a `failed` decision so the run settles. A row past the poison bound is deleted without
542
+ * a decision, as on the other transports: a turn carries no step to fail, and the turn's liveness
543
+ * window (`remoteAdvanceSilenceMs` / the executor's `timeoutMs`) re-drives it.
544
+ */
545
+ async #runWorkflowRow(row, token) {
546
+ const id = row.step_id;
547
+ try {
548
+ let task;
549
+ let replyTo;
550
+ try {
551
+ const payload = fromJson(row.input);
552
+ if (!payload)
553
+ throw new Error('empty payload');
554
+ ({ replyTo, ...task } = payload);
555
+ }
556
+ catch (err) {
557
+ this.#release(TRANSPORT_TABLES.tasks, token, id);
558
+ await this.#deleteClaimed(TRANSPORT_TABLES.tasks, 'step_id', id, token);
559
+ this.#onError(new Error(`[DbTransport] dropped workflow task ${id}: unreadable payload (${String(err)})`));
560
+ return;
561
+ }
562
+ if (row.reclaims != null && Number(row.reclaims) > this.#maxStalledCount) {
563
+ this.#release(TRANSPORT_TABLES.tasks, token, id);
564
+ await this.#deleteClaimed(TRANSPORT_TABLES.tasks, 'step_id', id, token);
565
+ this.#onError(new Error(`[DbTransport] dropped workflow task ${id} (run ${task.runId}): its claim lapsed more than ${this.#maxStalledCount} times. No decision is written: as on bullmq and queue, the turn is re-driven by the executor's timeoutMs or by recovery`));
566
+ return;
567
+ }
568
+ if (!(await this.#stillHolds(id, token))) {
569
+ this.#release(TRANSPORT_TABLES.tasks, token, id);
570
+ this.#onError(new Error(`[DbTransport] lost the claim on workflow task ${id} before running it — another worker took it over; not running it here`));
571
+ return;
572
+ }
573
+ const startedAt = Date.now();
574
+ this.#controller.onStart();
575
+ try {
576
+ const decision = await this.#replayTurn(task);
577
+ this.#release(TRANSPORT_TABLES.tasks, token, id);
578
+ if (!(await this.#settleWorkflow(id, token, decision, replyTo))) {
579
+ this.#onError(new Error(this.#handedBack.has(token)
580
+ ? `[DbTransport] workflow task ${id} outlived close()'s ${this.#closeTimeoutMs}ms drain and was handed back; this turn's decision was dropped`
581
+ : `[DbTransport] lost the claim on workflow task ${id} while running it — another worker took it over; this turn's decision was dropped`));
582
+ }
583
+ }
584
+ finally {
585
+ // Only step durations feed the latency gradient; a turn still counts as in-flight work.
586
+ this.#controller.onSettle(Date.now() - startedAt, true, 'workflow');
587
+ }
588
+ }
589
+ catch (err) {
590
+ this.#onError(err);
591
+ }
592
+ finally {
593
+ this.#release(TRANSPORT_TABLES.tasks, token, id);
594
+ }
238
595
  }
239
- async #insertResult(result) {
596
+ /** Replay one turn. Never throws: an unknown workflow or a throwing turn is a `failed` decision. */
597
+ async #replayTurn(task) {
598
+ const turn = this.#workflowTurns.get(task.workflow);
599
+ if (!turn) {
600
+ return {
601
+ taskId: task.taskId,
602
+ runId: task.runId,
603
+ status: 'failed',
604
+ commands: [],
605
+ error: { message: `no workflow registered for '${task.workflow}'`, code: 'no_workflow' },
606
+ };
607
+ }
608
+ try {
609
+ return await withTurnHeartbeat(task, (beat) => this.heartbeat(beat), () => turn(task));
610
+ }
611
+ catch (err) {
612
+ this.#onError(err);
613
+ return {
614
+ taskId: task.taskId,
615
+ runId: task.runId,
616
+ status: 'failed',
617
+ commands: [],
618
+ error: { message: err instanceof Error ? err.message : String(err) },
619
+ };
620
+ }
621
+ }
622
+ /** Delete the workflow row and write its decision in ONE transaction, only while `token` holds it. */
623
+ async #settleWorkflow(id, token, decision, replyTo) {
624
+ const settled = await this.#client().transaction(async (trx) => {
625
+ const deleted = await trx
626
+ .knexQuery()
627
+ .from(TRANSPORT_TABLES.tasks)
628
+ .where('step_id', id)
629
+ .where('claimed_by', token)
630
+ .delete();
631
+ if (Number(deleted) === 0)
632
+ return false;
633
+ await this.#insertMessages(MESSAGE_KINDS.decision, [{ recipient: replyTo ?? '', payload: decision }], trx);
634
+ return true;
635
+ });
636
+ if (settled && replyTo)
637
+ this.#noteAddressed();
638
+ return settled;
639
+ }
640
+ /** Delete one row of `table` while `token` still holds its claim. */
641
+ async #deleteClaimed(table, idCol, id, token) {
240
642
  await this.#client()
643
+ .knexQuery()
644
+ .from(table)
645
+ .where(idCol, id)
646
+ .where('claimed_by', token)
647
+ .delete();
648
+ }
649
+ /** Re-stamp the row's claim if this round still holds it; `false` once another instance took it. */
650
+ async #stillHolds(stepId, token) {
651
+ const updated = await this.#client()
652
+ .knexQuery()
653
+ .from(TRANSPORT_TABLES.tasks)
654
+ .where('step_id', stepId)
655
+ .where('claimed_by', token)
656
+ .update({ claimed_at: this.#now() });
657
+ return Number(updated) > 0;
658
+ }
659
+ /**
660
+ * Delete the task row and publish its result in ONE transaction, and only while `token` still holds
661
+ * the claim: an instance whose claim was taken over neither deletes the new holder's row nor writes
662
+ * a second result. Resolves whether this instance settled it.
663
+ */
664
+ async #settleTask(stepId, token, result) {
665
+ return this.#client().transaction(async (trx) => {
666
+ const deleted = await trx
667
+ .knexQuery()
668
+ .from(TRANSPORT_TABLES.tasks)
669
+ .where('step_id', stepId)
670
+ .where('claimed_by', token)
671
+ .delete();
672
+ if (Number(deleted) === 0)
673
+ return false;
674
+ await this.#insertResult(result, trx);
675
+ return true;
676
+ });
677
+ }
678
+ async #insertResult(result, client = this.#client()) {
679
+ await client
241
680
  .insertQuery()
242
681
  .table(TRANSPORT_TABLES.results)
243
682
  .knexQuery.insert({
@@ -260,57 +699,63 @@ export class DbTransport {
260
699
  // ───────────────────────────────────────────────────────────────────────────
261
700
  // worker → engine — the engine polls results + heartbeats
262
701
  // ───────────────────────────────────────────────────────────────────────────
702
+ /** Engine side: settle step results, up to {@link DbTransportOptions.resultConcurrency} at once. The
703
+ * results loop never backs off when idle — a result is on the critical path of every step. */
263
704
  onResult(handler) {
264
- this.#pollers.start(async () => (await this.#drainResults(handler)) > 0);
705
+ this.#startLoop(async () => (await this.#drain(TRANSPORT_TABLES.results, 'step_id', this.#resultConcurrency, async (row) => {
706
+ await handler({
707
+ runId: row.run_id,
708
+ seq: Number(row.seq),
709
+ stepId: row.step_id,
710
+ status: row.status,
711
+ output: fromJson(row.output),
712
+ error: fromJson(row.error),
713
+ startedAt: row.started_at == null ? undefined : Number(row.started_at),
714
+ events: fromJson(row.events),
715
+ });
716
+ })) > 0, 1);
265
717
  }
266
718
  onHeartbeat(handler) {
267
- this.#pollers.start(async () => (await this.#drainHeartbeats(handler)) > 0);
268
- }
269
- async #drainResults(handler) {
270
- const { rows } = await this.#claim(TRANSPORT_TABLES.results);
271
- // Delete each row only AFTER its handler ran (at-least-once), but batch the deletes of a round
272
- // into one `WHERE IN` — one round trip instead of one per row.
273
- const done = [];
274
- try {
275
- for (const row of rows) {
276
- const result = {
277
- runId: row.run_id,
278
- seq: Number(row.seq),
279
- stepId: row.step_id,
280
- status: row.status,
281
- output: fromJson(row.output),
282
- error: fromJson(row.error),
283
- startedAt: row.started_at == null ? undefined : Number(row.started_at),
284
- events: fromJson(row.events),
285
- };
286
- await handler(result);
287
- done.push(result.stepId);
288
- }
289
- }
290
- finally {
291
- if (done.length) {
292
- await this.#client().from(TRANSPORT_TABLES.results).whereIn('step_id', done).delete();
293
- }
294
- }
295
- return rows.length;
719
+ this.#startLoop(async () => (await this.#drain(TRANSPORT_TABLES.heartbeats, 'id', 1, async (row) => {
720
+ await handler({
721
+ runId: row.run_id,
722
+ seq: Number(row.seq),
723
+ ...(row.step_id ? { stepId: row.step_id } : {}),
724
+ group: row.grp,
725
+ });
726
+ })) > 0, this.#idleBackoffMax);
296
727
  }
297
- async #drainHeartbeats(handler) {
298
- const { rows } = await this.#claim(TRANSPORT_TABLES.heartbeats, undefined, 'id');
728
+ /**
729
+ * Claim a batch of `table` rows, hand each to `each` (up to `concurrency` at once), then delete the
730
+ * handled rows in one `WHERE IN` — only AFTER their handler ran (at-least-once), and only while this
731
+ * round still holds their claim. The batch's claims are renewed until it is done, so a slow handler
732
+ * never lets the rows behind it age into another instance's reclaim. A throwing `each` stops the
733
+ * batch: the rows not handled stay claimed, unrenewed, and are re-delivered after the lease.
734
+ */
735
+ async #drain(table, idCol, concurrency, each, scope) {
736
+ const { rows, token } = await this.#claim(table, this.#batchSize, undefined, idCol, {
737
+ scope,
738
+ });
739
+ if (rows.length === 0)
740
+ return 0;
741
+ const idOf = (row) => row[idCol];
742
+ this.#hold(table, token, rows.map(idOf));
299
743
  const done = [];
300
744
  try {
301
- for (const row of rows) {
302
- await handler({
303
- runId: row.run_id,
304
- seq: Number(row.seq),
305
- stepId: row.step_id,
306
- group: row.grp,
307
- });
308
- done.push(row.id);
309
- }
745
+ await eachLimited(rows, concurrency, async (row) => {
746
+ await each(row);
747
+ done.push(idOf(row));
748
+ });
310
749
  }
311
750
  finally {
751
+ this.#releaseToken(table, token);
312
752
  if (done.length) {
313
- await this.#client().from(TRANSPORT_TABLES.heartbeats).whereIn('id', done).delete();
753
+ await this.#client()
754
+ .knexQuery()
755
+ .from(table)
756
+ .whereIn(idCol, done)
757
+ .where('claimed_by', token)
758
+ .delete();
314
759
  }
315
760
  }
316
761
  return rows.length;
@@ -332,45 +777,434 @@ export class DbTransport {
332
777
  });
333
778
  }
334
779
  onControl(handler) {
335
- this.#pollers.start(async () => (await this.#drainControl(handler)) > 0);
780
+ this.#startLoop(async () => (await this.#drain(TRANSPORT_TABLES.control, 'id', 1, async (row) => {
781
+ const msg = fromJson(row.payload);
782
+ if (msg)
783
+ handler(msg);
784
+ })) > 0, this.#idleBackoffMax);
336
785
  }
337
- async #drainControl(handler) {
338
- const { rows } = await this.#claim(TRANSPORT_TABLES.control, undefined, 'id');
339
- const done = [];
786
+ // ───────────────────────────────────────────────────────────────────────────
787
+ // remote workflows + the store-less (tenant pod) protocol — the messages table
788
+ //
789
+ // Workflow turns ride the task table (kind `workflow`, next to the steps of the same token). Every
790
+ // other message is a row of `durable_transport_messages`, claimed and deleted like the other
791
+ // channels and told apart by `kind`. A row is either for ANY consumer of its kind (`recipient = ''`:
792
+ // step events, start-run, run-request) or addressed to ONE instance (`recipient = <instanceId>`: the
793
+ // reply to the requester, a tenant event to each live subscriber, a decision to the engine that
794
+ // dispatched the turn). See `db-remote.ts` for the subscriber registry and the janitor.
795
+ // ───────────────────────────────────────────────────────────────────────────
796
+ /**
797
+ * Whether the messages table exists — `autoCreate` creates it; a deployment that runs its own
798
+ * migrations needs `create_durable_transport_messages`. Resolved once. With `report`, its absence is
799
+ * said ONCE through `onError` (the consumer loops ask silently, so a deployment that never uses the
800
+ * protocol hears nothing).
801
+ */
802
+ async #messagesReady(report) {
803
+ let known = this.#messagesTable;
804
+ if (!known) {
805
+ known = this.#ensureSchema()
806
+ .then(() => this.#client().schema.hasTable(TRANSPORT_TABLES.messages))
807
+ .catch((err) => {
808
+ this.#messagesTable = undefined; // unknown yet (a transient error): ask again next time
809
+ this.#onError(err);
810
+ return false;
811
+ });
812
+ this.#messagesTable = known;
813
+ }
814
+ const has = await known;
815
+ if (!has && report && !this.#messagesTableReported) {
816
+ this.#messagesTableReported = true;
817
+ this.#onError(new Error(this.#messagesMissing()));
818
+ }
819
+ return has;
820
+ }
821
+ #messagesMissing() {
822
+ return `[DbTransport] the ${TRANSPORT_TABLES.messages} table does not exist, so remote workflows and store-less tenant pods do not work on this transport. Run the create_durable_transport_messages migration (node ace configure @adonis-agora/durable publishes it), or enable autoCreate`;
823
+ }
824
+ /** Insert `rows` of `kind` in one statement. */
825
+ async #insertMessages(kind, rows, client = this.#client()) {
826
+ if (rows.length === 0)
827
+ return;
828
+ const at = this.#now();
829
+ await client
830
+ .insertQuery()
831
+ .table(TRANSPORT_TABLES.messages)
832
+ .knexQuery.insert(rows.map((row) => ({
833
+ kind,
834
+ namespace: this.#namespaceValue(),
835
+ recipient: row.recipient ?? '',
836
+ reply_to: row.replyTo ?? null,
837
+ payload: JSON.stringify(row.payload),
838
+ claimed_by: null,
839
+ claimed_at: null,
840
+ created_at: at,
841
+ })));
842
+ }
843
+ /**
844
+ * Start the consumer loop of `kind` (once). `recipients` is re-read every poll. A loop over rows
845
+ * anyone may take is `shared`: parked while consumers are deferred, like every other consumer. One
846
+ * over rows addressed to this instance is not — nobody else could take them. A payload that does not
847
+ * parse is reported and dropped (never re-delivered forever).
848
+ */
849
+ #startMessageLoop(kind, { shared, recipients, idleBackoffMax, each, }) {
850
+ if (this.#messageLoops.has(kind))
851
+ return;
852
+ this.#messageLoops.add(kind);
853
+ const tick = async () => {
854
+ if (!(await this.#messagesReady(false)))
855
+ return false;
856
+ const handled = await this.#drain(TRANSPORT_TABLES.messages, 'id', 1, async (row) => {
857
+ let payload;
858
+ try {
859
+ payload = JSON.parse(row.payload);
860
+ }
861
+ catch (err) {
862
+ this.#onError(new Error(`[DbTransport] dropped ${kind} message ${row.id}: ${String(err)}`));
863
+ return;
864
+ }
865
+ await each(payload, row);
866
+ }, { kind, recipients: recipients() });
867
+ return handled > 0;
868
+ };
869
+ if (shared)
870
+ this.#startLoop(tick, idleBackoffMax);
871
+ else
872
+ this.#pollers.start(tick, { idleBackoffMax });
873
+ }
874
+ /** This instance consumes rows addressed to it: keep its inbox key live (and delete what is left
875
+ * addressed to it on close). */
876
+ #consumeInbox() {
877
+ void this.#inboxes.activateInbox();
878
+ }
879
+ /** This instance addressed a row to another one: run the janitor (once). */
880
+ #noteAddressed() {
881
+ if (this.#inboxSweepIntervalMs <= 0 || this.#sweepTimer)
882
+ return;
883
+ this.#sweepTimer = setInterval(() => {
884
+ if (this.#sweeping)
885
+ return;
886
+ const round = this.sweepInboxes()
887
+ .catch((err) => this.#onError(err))
888
+ .finally(() => {
889
+ if (this.#sweeping === round)
890
+ this.#sweeping = undefined;
891
+ });
892
+ this.#sweeping = round;
893
+ }, this.#inboxSweepIntervalMs);
894
+ this.#sweepTimer.unref?.();
895
+ }
896
+ /**
897
+ * One janitor round: delete the addressed rows older than the inbox TTL whose recipient has no live
898
+ * inbox key — a requester or subscriber that died, an engine that crashed awaiting a decision. Rows
899
+ * younger than the TTL are never touched, so an instance that is just starting (or whose key write is
900
+ * a refresh late) keeps its rows. Reads everything from the database, so it needs no memory of what
901
+ * was addressed — any instance that addressed a row runs it, and a restart forgets nothing.
902
+ */
903
+ async sweepInboxes() {
904
+ if (!(await this.#messagesReady(false)))
905
+ return;
906
+ const cutoff = this.#now() - this.#inboxTtlMs;
907
+ const namespace = this.#namespaceValue();
908
+ const recipients = (await this.#client()
909
+ .knexQuery()
910
+ .from(TRANSPORT_TABLES.messages)
911
+ .distinct('recipient')
912
+ .where('namespace', namespace)
913
+ .whereNot('recipient', '')
914
+ .where('created_at', '<', cutoff)).map((r) => r.recipient);
915
+ if (recipients.length === 0)
916
+ return;
917
+ const alive = await this.#inboxes.liveInboxOwners();
918
+ const dead = recipients.filter((r) => !alive.has(r));
919
+ if (dead.length === 0)
920
+ return;
921
+ await this.#client()
922
+ .knexQuery()
923
+ .from(TRANSPORT_TABLES.messages)
924
+ .where('namespace', namespace)
925
+ .whereIn('recipient', dead)
926
+ .where('created_at', '<', cutoff)
927
+ .delete();
928
+ }
929
+ /**
930
+ * engine → workflow worker: a {@link WorkflowTask} as a task row of kind `workflow` on the workflow's
931
+ * routing token, where a {@link handleWorkflow} consumer claims it (with the token's steps, by
932
+ * priority). Stamped with this instance as the decision's recipient when it consumes decisions, so
933
+ * the decision comes back to the engine that dispatched the turn (any replica could apply it — the
934
+ * engine applies decisions durably by run id — but the dispatcher is the one known to be live).
935
+ * Idempotent per `taskId`. Throws when the schema lacks the tasks `kind` column or the messages table.
936
+ */
937
+ async dispatchWorkflowTask(task) {
938
+ await this.#ensureSchema();
939
+ const [hasKind, hasMessages, withPriority] = await Promise.all([
940
+ this.#hasTaskColumn('kind'),
941
+ this.#messagesReady(true),
942
+ this.#hasTaskColumn('priority'),
943
+ ]);
944
+ if (!hasMessages)
945
+ throw new Error(this.#messagesMissing());
946
+ if (!hasKind) {
947
+ throw new Error(`[DbTransport] ${TRANSPORT_TABLES.tasks} has no "kind" column, so remote workflow turns cannot be dispatched on it. Run the create_durable_transport_messages migration, or enable autoCreate`);
948
+ }
949
+ const payload = {
950
+ ...task,
951
+ ...(this.#decisionHandlers.size > 0 ? { replyTo: this.#instanceId } : {}),
952
+ };
953
+ await this.#client()
954
+ .insertQuery()
955
+ .table(TRANSPORT_TABLES.tasks)
956
+ .knexQuery.insert({
957
+ step_id: task.taskId,
958
+ run_id: task.runId,
959
+ seq: 0,
960
+ name: task.workflow,
961
+ grp: task.group,
962
+ namespace: this.#namespaceValue(),
963
+ input: toJson(payload),
964
+ traceparent: task.traceparent ?? null,
965
+ context: null,
966
+ transport: task.transport ?? null,
967
+ attempt: task.attempt,
968
+ kind: 'workflow',
969
+ ...(withPriority ? { priority: toDbPriority(task.priority) } : {}),
970
+ claimed_by: null,
971
+ claimed_at: null,
972
+ created_at: this.#now(),
973
+ })
974
+ .onConflict('step_id')
975
+ .ignore();
976
+ }
977
+ /**
978
+ * engine ← workflow worker: consume {@link WorkflowDecision}s. ONE loop per transport; every handler
979
+ * sees every decision (the engine applies one by run id, dropping any its run no longer awaits). It claims the
980
+ * decisions addressed to this instance — consumed even while consumers are deferred, as nobody else
981
+ * may take them — and, once consuming, the unaddressed ones (a worker that did not know whom to
982
+ * reply to). Never backs off: a decision is on the critical path of every remote workflow turn.
983
+ */
984
+ onDecision(handler) {
985
+ this.#decisionHandlers.add(handler);
986
+ if (this.#messageLoops.has(MESSAGE_KINDS.decision))
987
+ return;
988
+ this.#consumeInbox();
989
+ this.#startMessageLoop(MESSAGE_KINDS.decision, {
990
+ shared: false,
991
+ recipients: () => (this.#consumersDeferred ? [this.#instanceId] : [this.#instanceId, '']),
992
+ idleBackoffMax: 1,
993
+ each: async (decision) => {
994
+ // Every handler, concurrently; all settle before a failure redelivers the decision.
995
+ await fanOutDecision(this.#decisionHandlers, decision, this.#onError);
996
+ },
997
+ });
998
+ }
999
+ /** workflow worker → engine: a local step's lifecycle, for ONE engine instance to checkpoint. A
1000
+ * deployment without the messages table drops it (reported once): it is observability only. */
1001
+ async dispatchStepEvent(event) {
1002
+ if (!(await this.#messagesReady(true)))
1003
+ return;
1004
+ await this.#insertMessages(MESSAGE_KINDS.stepEvent, [{ payload: event }]);
1005
+ }
1006
+ /** engine ← workflow worker: consume streamed step events. Idempotent — a second call is a no-op. */
1007
+ onStepEvent(handler) {
1008
+ this.#startMessageLoop(MESSAGE_KINDS.stepEvent, {
1009
+ shared: true,
1010
+ recipients: () => [''],
1011
+ idleBackoffMax: this.#idleBackoffMax,
1012
+ each: (event) => handler(event),
1013
+ });
1014
+ }
1015
+ /** tenant → control plane: a {@link StartRunMessage} for any control plane, with this instance as
1016
+ * the reply's recipient. Throws when the messages table is missing. */
1017
+ async dispatchStartRun(msg) {
1018
+ this.#ensureRunRepliesLoop();
1019
+ if (!(await this.#messagesReady(true)))
1020
+ throw new Error(this.#messagesMissing());
1021
+ await this.#insertMessages(MESSAGE_KINDS.startRun, [
1022
+ { replyTo: this.#instanceId, payload: msg },
1023
+ ]);
1024
+ }
1025
+ /** control plane ← tenant: consume {@link StartRunMessage}s. Idempotent — a second call is a no-op. */
1026
+ onStartRun(handler) {
1027
+ this.#startMessageLoop(MESSAGE_KINDS.startRun, {
1028
+ shared: true,
1029
+ recipients: () => [''],
1030
+ idleBackoffMax: this.#idleBackoffMax,
1031
+ each: (msg, row) => this.#withReplyRoute(msg.runId, row.reply_to, () => handler(msg)),
1032
+ });
1033
+ }
1034
+ /** tenant → control plane: a {@link RunRequest} for any control plane, with this instance as the
1035
+ * reply's recipient. Throws when the messages table is missing. */
1036
+ async dispatchRunRequest(msg) {
1037
+ this.#ensureRunRepliesLoop();
1038
+ if (!(await this.#messagesReady(true)))
1039
+ throw new Error(this.#messagesMissing());
1040
+ await this.#insertMessages(MESSAGE_KINDS.runRequest, [
1041
+ { replyTo: this.#instanceId, payload: msg },
1042
+ ]);
1043
+ }
1044
+ /** control plane ← tenant: consume {@link RunRequest}s. Idempotent — a second call is a no-op. */
1045
+ onRunRequest(handler) {
1046
+ this.#startMessageLoop(MESSAGE_KINDS.runRequest, {
1047
+ shared: true,
1048
+ recipients: () => [''],
1049
+ idleBackoffMax: this.#idleBackoffMax,
1050
+ each: (msg, row) => this.#withReplyRoute(msg.requestId, row.reply_to, () => handler(msg)),
1051
+ });
1052
+ }
1053
+ /** Remember who asked for `correlationId` while `work` (the responder, which replies inside it) runs. */
1054
+ async #withReplyRoute(correlationId, replyTo, work) {
1055
+ if (correlationId === undefined || !replyTo)
1056
+ return work();
1057
+ this.#replyRoutes.set(correlationId, replyTo);
340
1058
  try {
341
- for (const row of rows) {
342
- const msg = fromJson(row.payload);
343
- if (msg)
344
- handler(msg);
345
- done.push(row.id);
346
- }
1059
+ await work();
347
1060
  }
348
1061
  finally {
349
- if (done.length) {
350
- await this.#client().from(TRANSPORT_TABLES.control).whereIn('id', done).delete();
351
- }
1062
+ if (this.#replyRoutes.get(correlationId) === replyTo)
1063
+ this.#replyRoutes.delete(correlationId);
352
1064
  }
353
- return rows.length;
1065
+ }
1066
+ /**
1067
+ * control plane → tenant: a correlated {@link RunReply}, addressed to the instance that sent the
1068
+ * request. Only the instance handling that request knows the requester, so this must run while it
1069
+ * is handled — which is what the responder does. A reply with no known requester is dropped and
1070
+ * reported, never broadcast.
1071
+ */
1072
+ async publishRunReply(reply) {
1073
+ const owner = this.#replyRoutes.get(reply.requestId);
1074
+ if (owner === undefined) {
1075
+ this.#onError(new Error(`[DbTransport] dropped the run reply for "${reply.requestId}": no requester is known for it on this instance`));
1076
+ return;
1077
+ }
1078
+ if (!(await this.#messagesReady(true)))
1079
+ return;
1080
+ await this.#insertMessages(MESSAGE_KINDS.runReply, [{ recipient: owner, payload: reply }]);
1081
+ this.#noteAddressed();
1082
+ }
1083
+ /**
1084
+ * tenant ← control plane: consume the replies addressed to this instance. Idempotent — a second call
1085
+ * is a no-op (one gateway per transport matches every reply by id). Consumed even while consumers are
1086
+ * deferred: nobody else may take them.
1087
+ */
1088
+ onRunReply(handler) {
1089
+ if (this.#runReplyHandler)
1090
+ return;
1091
+ this.#runReplyHandler = handler;
1092
+ this.#ensureRunRepliesLoop();
1093
+ }
1094
+ /** Start consuming this instance's replies (once) — also on the first request, so a reply is never
1095
+ * left behind because the gateway subscribed late. Never backs off: a reply is on the critical path
1096
+ * of every proxied call. */
1097
+ #ensureRunRepliesLoop() {
1098
+ if (this.#messageLoops.has(MESSAGE_KINDS.runReply))
1099
+ return;
1100
+ this.#consumeInbox();
1101
+ this.#startMessageLoop(MESSAGE_KINDS.runReply, {
1102
+ shared: false,
1103
+ recipients: () => [this.#instanceId],
1104
+ idleBackoffMax: 1,
1105
+ each: async (reply) => {
1106
+ try {
1107
+ this.#runReplyHandler?.(reply);
1108
+ }
1109
+ catch (err) {
1110
+ this.#onError(err); // a throwing listener must not re-deliver the reply
1111
+ }
1112
+ },
1113
+ });
1114
+ }
1115
+ /**
1116
+ * control plane → tenant: one row of the {@link TenantEvent} per instance with a live subscription to
1117
+ * `evt.tenant` (none: nothing is written), in one INSERT. Publishes are serialized, so each subscriber
1118
+ * receives one publisher's events in publish order.
1119
+ */
1120
+ publishTenantEvent(evt) {
1121
+ const run = this.#tenantPublishChain.then(async () => {
1122
+ const owners = await this.#inboxes.subscribersOf(evt.tenant);
1123
+ if (owners.length === 0)
1124
+ return;
1125
+ if (!(await this.#messagesReady(true)))
1126
+ return;
1127
+ await this.#insertMessages(MESSAGE_KINDS.tenantEvent, owners.map((recipient) => ({ recipient, payload: evt })));
1128
+ this.#noteAddressed();
1129
+ });
1130
+ this.#tenantPublishChain = run.catch(() => undefined);
1131
+ return run;
1132
+ }
1133
+ /**
1134
+ * tenant ← control plane: subscribe to `tenant`'s events. Writes this instance's subscriber key
1135
+ * (kept live every third of {@link DbTransportOptions.inboxTtlMs}) and consumes the events addressed
1136
+ * to it — never deferred, nobody else may take them. Returns an unsubscribe fn that detaches only
1137
+ * this handler; the last one removes the subscription.
1138
+ */
1139
+ onTenantEvent(tenant, handler) {
1140
+ let handlers = this.#tenantEventHandlers.get(tenant);
1141
+ if (!handlers) {
1142
+ handlers = new Set();
1143
+ this.#tenantEventHandlers.set(tenant, handlers);
1144
+ }
1145
+ handlers.add(handler);
1146
+ void this.#inboxes.subscribe(tenant);
1147
+ // Keeps running after the last unsubscribe, so events written before the key went away are
1148
+ // consumed rather than left behind.
1149
+ this.#startMessageLoop(MESSAGE_KINDS.tenantEvent, {
1150
+ shared: false,
1151
+ recipients: () => [this.#instanceId],
1152
+ idleBackoffMax: this.#idleBackoffMax,
1153
+ each: async (evt) => {
1154
+ for (const h of this.#tenantEventHandlers.get(evt.tenant) ?? []) {
1155
+ try {
1156
+ h(evt);
1157
+ }
1158
+ catch (err) {
1159
+ this.#onError(err); // a throwing listener must not re-deliver (and reorder) the event
1160
+ }
1161
+ }
1162
+ },
1163
+ });
1164
+ return () => {
1165
+ const set = this.#tenantEventHandlers.get(tenant);
1166
+ if (!set?.delete(handler) || set.size > 0)
1167
+ return;
1168
+ this.#tenantEventHandlers.delete(tenant);
1169
+ this.#inboxes.unsubscribe(tenant);
1170
+ };
1171
+ }
1172
+ /** On close: remove this instance's inbox and subscriber keys, then delete what is still addressed
1173
+ * to it — replies, events and decisions for a process going away have no one else to go to. */
1174
+ async #closeInboxes() {
1175
+ await this.#inboxes.close();
1176
+ const own = [MESSAGE_KINDS.decision, MESSAGE_KINDS.runReply, MESSAGE_KINDS.tenantEvent].filter((kind) => this.#messageLoops.has(kind));
1177
+ if (own.length === 0)
1178
+ return;
1179
+ await this.#client()
1180
+ .knexQuery()
1181
+ .from(TRANSPORT_TABLES.messages)
1182
+ .where('namespace', this.#namespaceValue())
1183
+ .where('recipient', this.#instanceId)
1184
+ .whereIn('kind', own)
1185
+ .delete()
1186
+ .catch((err) => this.#onError(err));
354
1187
  }
355
1188
  // ───────────────────────────────────────────────────────────────────────────
356
1189
  // atomic claim — portable compare-and-set (no FOR UPDATE SKIP LOCKED)
357
1190
  // ───────────────────────────────────────────────────────────────────────────
358
1191
  /**
359
- * Atomically claim up to `batchSize` un-leased rows (optionally narrowed by `where`) and stamp them
360
- * with this instance's id + a fresh per-round claim timestamp, then SELECT exactly the rows this
361
- * round stamped. The single conditional `UPDATE … WHERE claimed_at IS NULL OR claimed_at < stale`
362
- * is the concurrency guard: two instances racing the same rows can't both stamp them (the loser's
363
- * UPDATE matches nothing for those rows). A crashed claimer's rows are reclaimed once their lease
364
- * expires (`claimed_at < now - leaseMs`).
1192
+ * Atomically claim up to `limit` un-leased rows (optionally narrowed to the routing tokens `groups`)
1193
+ * and stamp them with this instance's id + a fresh per-round claim timestamp, then SELECT exactly the
1194
+ * rows this round stamped. The conditional `UPDATE … WHERE claimed_at IS NULL` /
1195
+ * `… WHERE claimed_at < stale` is the concurrency guard: two instances racing the same rows can't
1196
+ * both stamp them (the loser's UPDATE matches nothing for those rows). A crashed claimer's rows are
1197
+ * reclaimed once their lease expires (`claimed_at < now - leaseMs`) — and with `countReclaims`, each
1198
+ * such takeover bumps the row's `reclaims` count (the poison-task bound).
365
1199
  *
366
- * `claimed_at` is set to a unique, strictly-increasing-per-round value so the SELECT-back is exact
367
- * even when the same instance claims several rounds in quick succession.
1200
+ * Fresh and stale candidates are stamped by separate UPDATEs so the increment never depends on how a
1201
+ * dialect orders the `SET` list (MySQL evaluates it left to right against the NEW values).
368
1202
  */
369
- async #claim(table, groups, idCol = 'step_id') {
1203
+ async #claim(table, limit, groups, idCol = 'step_id', { countReclaims = false, byPriority = false, scope, } = {}) {
370
1204
  await this.#ensureSchema();
371
1205
  // The raw Knex builder — `onConflict`, the `where(cb)` grouping and `whereIn` are cleaner there
372
1206
  // than through Lucid's stricter query-builder typings, and we only need plain SQL here.
373
- const knex = this.#client().knexQuery();
1207
+ const client = this.#client();
374
1208
  const stale = this.#now() - this.#leaseMs;
375
1209
  // Every claim is namespace-scoped: this instance only ever sees rows stamped with its own
376
1210
  // namespace, so two engines/workers on different namespaces sharing one table set never
@@ -381,32 +1215,61 @@ export class DbTransport {
381
1215
  // the same instance).
382
1216
  const token = `${this.#instanceId}:${process.hrtime.bigint().toString(36)}`;
383
1217
  const at = this.#now();
384
- // 1) Pick candidate ids (unclaimed or stale-lease), oldest first, bounded by batchSize.
385
- const candidates = (await knex
1218
+ // 1) Pick candidate ids (unclaimed or stale-lease) — highest priority first when the table has
1219
+ // one, then oldest — bounded by `limit`.
1220
+ const candidates = (await client
1221
+ .knexQuery()
386
1222
  .from(table)
387
- .select(idCol)
1223
+ .select(idCol, 'claimed_at')
388
1224
  .where('namespace', namespace)
389
1225
  .modify((q) => {
390
1226
  if (groups !== undefined)
391
1227
  q.whereIn('grp', groups);
1228
+ if (scope !== undefined)
1229
+ q.where('kind', scope.kind).whereIn('recipient', scope.recipients);
392
1230
  })
393
1231
  .where((q) => q.whereNull('claimed_at').orWhere('claimed_at', '<', stale))
1232
+ .modify((q) => {
1233
+ if (byPriority)
1234
+ q.orderBy('priority', 'desc');
1235
+ })
394
1236
  .orderBy(idCol === 'id' ? 'id' : 'created_at', 'asc')
395
- .limit(this.#batchSize));
1237
+ .limit(limit));
396
1238
  if (candidates.length === 0)
397
1239
  return { rows: [], token };
398
- const ids = candidates.map((c) => c[idCol]);
1240
+ const fresh = [];
1241
+ const lapsed = [];
1242
+ for (const c of candidates) {
1243
+ (c.claimed_at == null ? fresh : lapsed).push(c[idCol]);
1244
+ }
399
1245
  // 2) Conditionally claim them — only rows STILL un-leased flip to this round's token. A racing
400
1246
  // instance's UPDATE matches nothing for the rows it lost, so no row is claimed twice.
401
- await this.#client()
402
- .knexQuery()
403
- .from(table)
404
- .where('namespace', namespace)
405
- .whereIn(idCol, ids)
406
- .where((q) => q.whereNull('claimed_at').orWhere('claimed_at', '<', stale))
407
- .update({ claimed_by: token, claimed_at: at });
1247
+ if (fresh.length) {
1248
+ await client
1249
+ .knexQuery()
1250
+ .from(table)
1251
+ .where('namespace', namespace)
1252
+ .whereIn(idCol, fresh)
1253
+ .whereNull('claimed_at')
1254
+ .update({ claimed_by: token, claimed_at: at });
1255
+ }
1256
+ if (lapsed.length) {
1257
+ await client
1258
+ .knexQuery()
1259
+ .from(table)
1260
+ .where('namespace', namespace)
1261
+ .whereIn(idCol, lapsed)
1262
+ .where('claimed_at', '<', stale)
1263
+ .update({
1264
+ claimed_by: token,
1265
+ claimed_at: at,
1266
+ ...(countReclaims
1267
+ ? { reclaims: client.getWriteClient().raw('?? + 1', ['reclaims']) }
1268
+ : {}),
1269
+ });
1270
+ }
408
1271
  // 3) SELECT back exactly the rows this round won.
409
- const rows = (await this.#client()
1272
+ const rows = (await client
410
1273
  .knexQuery()
411
1274
  .from(table)
412
1275
  .select('*')
@@ -414,15 +1277,238 @@ export class DbTransport {
414
1277
  .where('claimed_at', at));
415
1278
  return { rows, token };
416
1279
  }
1280
+ /**
1281
+ * Whether the tasks table has `column` — `autoCreate` adds both, but a table from an older migration
1282
+ * run with `autoCreate: false` may lack them. Without `reclaims` the poison bound is off; without
1283
+ * `priority` dispatch priority is ignored and rows are claimed oldest first. Either is said once
1284
+ * through `onError`, and everything else behaves as before.
1285
+ */
1286
+ #hasTaskColumn(column) {
1287
+ let known = this.#taskColumns.get(column);
1288
+ if (!known) {
1289
+ known = this.#ensureSchema()
1290
+ .then(() => this.#client().schema.hasColumn(TRANSPORT_TABLES.tasks, column))
1291
+ .then((has) => {
1292
+ if (!has) {
1293
+ this.#onError(new Error(`[DbTransport] ${TRANSPORT_TABLES.tasks} has no "${column}" column, so ${MISSING_COLUMN[column].effect}. Add it — ${MISSING_COLUMN[column].ddl} — or enable autoCreate`));
1294
+ }
1295
+ return has;
1296
+ })
1297
+ .catch((err) => {
1298
+ // Unknown yet (a transient error): go without it this time, ask again next time.
1299
+ this.#taskColumns.delete(column);
1300
+ this.#onError(err);
1301
+ return false;
1302
+ });
1303
+ this.#taskColumns.set(column, known);
1304
+ }
1305
+ return known;
1306
+ }
1307
+ // ───────────────────────────────────────────────────────────────────────────
1308
+ // claim renewal — every claim held, not only the row being run
1309
+ // ───────────────────────────────────────────────────────────────────────────
1310
+ /** Start renewing `ids` of `table`, claimed under `token`. */
1311
+ #hold(table, token, ids) {
1312
+ let byToken = this.#held.get(table);
1313
+ if (!byToken) {
1314
+ byToken = new Map();
1315
+ this.#held.set(table, byToken);
1316
+ }
1317
+ byToken.set(token, new Set(ids));
1318
+ if (this.#renewTimer)
1319
+ return;
1320
+ const intervalMs = Math.max(10, Math.floor(this.#leaseMs / 3));
1321
+ this.#renewTimer = setInterval(() => {
1322
+ if (this.#renewal)
1323
+ return;
1324
+ const round = this.#renewHeld()
1325
+ .catch((err) => this.#onError(err))
1326
+ .finally(() => {
1327
+ if (this.#renewal === round)
1328
+ this.#renewal = undefined;
1329
+ });
1330
+ this.#renewal = round;
1331
+ }, intervalMs);
1332
+ this.#renewTimer.unref?.();
1333
+ }
1334
+ /** Stop renewing one row (settled, or no longer ours). Idempotent. */
1335
+ #release(table, token, id) {
1336
+ const ids = this.#held.get(table)?.get(token);
1337
+ if (!ids)
1338
+ return;
1339
+ ids.delete(id);
1340
+ if (ids.size === 0)
1341
+ this.#releaseToken(table, token);
1342
+ }
1343
+ /** Stop renewing every row of a claim round. Stops the renewal timer once nothing is held. */
1344
+ #releaseToken(table, token) {
1345
+ const byToken = this.#held.get(table);
1346
+ byToken?.delete(token);
1347
+ if (byToken?.size === 0)
1348
+ this.#held.delete(table);
1349
+ if (this.#held.size === 0 && this.#renewTimer) {
1350
+ clearInterval(this.#renewTimer);
1351
+ this.#renewTimer = undefined;
1352
+ }
1353
+ }
1354
+ /**
1355
+ * Hand `tokens`' rows of `table` back: clear `claimed_by`/`claimed_at` where the claim is still ours,
1356
+ * so the next poll anywhere claims them as fresh rows — NOT as lapsed claims, so a hand-back never
1357
+ * counts toward the poison bound — and stop renewing them.
1358
+ */
1359
+ async #handBack(table, tokens) {
1360
+ for (const token of tokens) {
1361
+ this.#handedBack.add(token);
1362
+ this.#releaseToken(table, token);
1363
+ }
1364
+ await this.#client()
1365
+ .knexQuery()
1366
+ .from(table)
1367
+ .whereIn('claimed_by', tokens)
1368
+ .update({ claimed_by: null, claimed_at: null });
1369
+ }
1370
+ /**
1371
+ * Re-stamp `claimed_at` on every row this instance holds — one UPDATE per table, matched on the
1372
+ * rounds' claim tokens, every `leaseMs / 3`. A slow but alive instance therefore keeps every row it
1373
+ * claimed (running or queued behind a slow one); only one that stopped renewing (crashed, lost the
1374
+ * database, or blocked its event loop past the lease) has its rows reclaimed. Fewer rows renewed
1375
+ * than still held means exactly that happened — reported, since the step may now run twice.
1376
+ */
1377
+ async #renewHeld() {
1378
+ for (const [table, byToken] of [...this.#held]) {
1379
+ const tokens = [...byToken.keys()];
1380
+ if (tokens.length === 0)
1381
+ continue;
1382
+ const renewed = await this.#client()
1383
+ .knexQuery()
1384
+ .from(table)
1385
+ .whereIn('claimed_by', tokens)
1386
+ .update({ claimed_at: this.#now() });
1387
+ // Rows settled during the round-trip were released, not lost: count only what is still held.
1388
+ let stillHeld = 0;
1389
+ for (const token of tokens)
1390
+ stillHeld += this.#held.get(table)?.get(token)?.size ?? 0;
1391
+ if (Number(renewed) < stillHeld) {
1392
+ this.#onError(new Error(`[DbTransport] lost ${stillHeld - Number(renewed)} of ${stillHeld} claim(s) on ${table} before renewing them — another instance reclaimed them and may process them too; raise leaseMs if this process blocks its event loop`));
1393
+ }
1394
+ }
1395
+ }
1396
+ // ───────────────────────────────────────────────────────────────────────────
1397
+ // worker liveness + health — `${P}-worker-heartbeat:<token>:<instanceId>` in the worker-key table
1398
+ // ───────────────────────────────────────────────────────────────────────────
1399
+ /** The prefix the liveness keys are built from (`durable`, `durable-<namespace>`). */
1400
+ #keyPrefix() {
1401
+ return effectivePrefix(WORKER_KEY_PREFIX, this.#namespace);
1402
+ }
1403
+ /** The task loop serves `token`: run the concurrency controller and beat its liveness key. */
1404
+ #beginServing(token) {
1405
+ this.#controller.start();
1406
+ this.#startWorkerHeartbeat(token);
1407
+ }
1408
+ /**
1409
+ * Refresh every served routing token's liveness key on one shared interval (default 10s, live 35s),
1410
+ * stamped with the controller's {@link WorkerStatus} snapshot — the same `{ ts, status }` value and
1411
+ * key the BullMQ and queue transports write, so the dashboard's worker cards read all three alike.
1412
+ * A new token beats at once. Best-effort: a failed write goes to `onError` and the key expires.
1413
+ */
1414
+ #startWorkerHeartbeat(token) {
1415
+ const ttlMs = Math.max(WORKER_HEARTBEAT_TTL_SECONDS * 1000, this.#workerHeartbeatIntervalMs * 3);
1416
+ const beatOne = (t) => {
1417
+ // One write per token at a time: a beat due while the last one is still in flight is skipped.
1418
+ // Beats are not queued behind a slow store — every interval used to start a write regardless,
1419
+ // so a store slower than the interval piled up writes without bound, each holding a pool
1420
+ // connection the reads (groupHealth, the claim loop) then waited behind.
1421
+ if (this.#beatsInFlight.has(t))
1422
+ return;
1423
+ this.#beatsInFlight.add(t);
1424
+ const now = this.#now();
1425
+ const key = workerHeartbeatKey(this.#keyPrefix(), t, this.#instanceId);
1426
+ const value = heartbeatKeyValue(now, this.#controller.snapshot());
1427
+ const write = Promise.resolve()
1428
+ .then(() => this.#workerKeys().put(key, value, now + ttlMs))
1429
+ .catch((err) => this.#onError(err))
1430
+ .finally(() => this.#beatsInFlight.delete(t));
1431
+ this.#livenessWrites.add(write);
1432
+ void write.finally(() => this.#livenessWrites.delete(write));
1433
+ };
1434
+ if (!this.#heartbeatTokens.has(token)) {
1435
+ this.#heartbeatTokens.add(token);
1436
+ beatOne(token);
1437
+ }
1438
+ if (this.#workerHeartbeatTimer)
1439
+ return;
1440
+ this.#workerHeartbeatTimer = setInterval(() => {
1441
+ for (const t of this.#heartbeatTokens)
1442
+ beatOne(t);
1443
+ }, this.#workerHeartbeatIntervalMs);
1444
+ this.#workerHeartbeatTimer.unref?.();
1445
+ }
1446
+ /**
1447
+ * Per-group worker health, for `engine.workerHealth()` and the dashboard/Telescope worker panels.
1448
+ *
1449
+ * - `depth` is every task row of `group` in this namespace — pending AND claimed (running, or held
1450
+ * by a worker that died and not yet reclaimed): the DB can count both, so it is closer to BullMQ's
1451
+ * waiting+active than the queue transport's pending-only count.
1452
+ * - `liveWorkers` are the unexpired `${P}-worker-heartbeat:<group>:<instance>` keys in the
1453
+ * worker-key table, each with the worker's {@link WorkerStatus} when it carries one (this
1454
+ * transport's own beats do; a `WorkerRuntime`'s compact beat reads as liveness only).
1455
+ */
1456
+ async groupHealth(group) {
1457
+ await this.#ensureSchema();
1458
+ const [counted, entries] = await Promise.all([
1459
+ this.#client()
1460
+ .knexQuery()
1461
+ .from(TRANSPORT_TABLES.tasks)
1462
+ .where('namespace', this.#namespaceValue())
1463
+ .where('grp', group)
1464
+ .count({ n: '*' })
1465
+ .first(),
1466
+ this.#liveWorkerKeys(),
1467
+ ]);
1468
+ const marker = workerHeartbeatKey(this.#keyPrefix(), group, '');
1469
+ const liveWorkers = [];
1470
+ for (const { key, value } of entries) {
1471
+ if (!key.startsWith(marker))
1472
+ continue;
1473
+ const parsed = parseHeartbeatValue(value);
1474
+ liveWorkers.push({
1475
+ group,
1476
+ instanceId: key.slice(marker.length),
1477
+ lastBeatAt: parsed.lastBeatAt,
1478
+ ...(parsed.status !== undefined
1479
+ ? { status: parsed.status }
1480
+ : {}),
1481
+ });
1482
+ }
1483
+ return { group, depth: Number(counted?.n) || 0, liveWorkers };
1484
+ }
417
1485
  // ───────────────────────────────────────────────────────────────────────────
418
1486
  // worker advertisement (design §7.2) — a key/value table with an expiry per key
419
1487
  // ───────────────────────────────────────────────────────────────────────────
420
1488
  #workerKeys() {
421
- this.#workerKeyStore ??= lucidWorkerKeys(this.#db, {
422
- connectionName: this.#connectionName,
423
- autoCreate: this.#autoCreate,
424
- now: () => this.#now(),
425
- });
1489
+ if (!this.#workerKeyStore) {
1490
+ // The table is created with the others by #ensureSchema — waited for first, so a key written
1491
+ // while the schema is still being created never races its CREATE.
1492
+ const keys = lucidWorkerKeys(this.#db, {
1493
+ connectionName: this.#connectionName,
1494
+ autoCreate: false,
1495
+ now: () => this.#now(),
1496
+ });
1497
+ this.#workerKeyStore = {
1498
+ put: async (key, value, expiresAt) => {
1499
+ await this.#ensureSchema();
1500
+ await keys.put(key, value, expiresAt);
1501
+ },
1502
+ remove: async (removed) => {
1503
+ await this.#ensureSchema();
1504
+ await keys.remove(removed);
1505
+ },
1506
+ live: async (at) => {
1507
+ await this.#ensureSchema();
1508
+ return keys.live(at);
1509
+ },
1510
+ };
1511
+ }
426
1512
  return this.#workerKeyStore;
427
1513
  }
428
1514
  /** The writer a worker runtime's registry advertises its descriptor and heartbeat through. */
@@ -455,17 +1541,85 @@ export class DbTransport {
455
1541
  this.#pollers.reopen();
456
1542
  await this.#ensureSchema();
457
1543
  }
458
- /** Stop every poll loop and wait (bounded) for in-flight ticks — a claimed task mid-handler gets
459
- * to finish + write its result instead of being abandoned to the lease expiry. Does not close
460
- * the shared `Database`. */
1544
+ /**
1545
+ * Stop every poll loop and wait — bounded by {@link DbTransportOptions.closeTimeoutMs}, 5s by
1546
+ * default — for in-flight work: the tasks in a handler get to finish and write their result, and a
1547
+ * result batch being settled gets to finish. Whatever is still unfinished then is HANDED BACK: its
1548
+ * claims are cleared (only where still ours) so another worker picks the rows up at once, instead of
1549
+ * after the lease — and uncounted, so a deploy never pushes a long step toward the poison bound. A
1550
+ * handler still running here may finish after another worker re-ran its step; its result is
1551
+ * dropped. Does not close the shared `Database`.
1552
+ */
461
1553
  async stop() {
462
1554
  this.#pollers.stopAll();
463
1555
  this.#taskLoop = undefined;
464
- await this.#pollers.drain(5_000);
1556
+ this.#controller.stop();
1557
+ if (this.#sweepTimer)
1558
+ clearInterval(this.#sweepTimer);
1559
+ this.#sweepTimer = undefined;
1560
+ if (this.#workerHeartbeatTimer)
1561
+ clearInterval(this.#workerHeartbeatTimer);
1562
+ // A clean stop withdraws this instance's liveness keys at once, so the dashboard and
1563
+ // `listWorkerGroups` drop it now instead of after the 35s TTL. In-flight beats land first, or one
1564
+ // could re-create a key after its removal.
1565
+ if (this.#heartbeatTokens.size > 0) {
1566
+ await Promise.allSettled([...this.#livenessWrites]);
1567
+ const keys = [...this.#heartbeatTokens].map((t) => workerHeartbeatKey(this.#keyPrefix(), t, this.#instanceId));
1568
+ this.#heartbeatTokens.clear();
1569
+ await Promise.resolve()
1570
+ .then(() => this.#workerKeys().remove(keys))
1571
+ .catch((err) => this.#onError(err));
1572
+ }
1573
+ this.#workerHeartbeatTimer = undefined;
1574
+ this.#heartbeatTokens.clear();
1575
+ const deadline = Date.now() + this.#closeTimeoutMs;
1576
+ await this.#pollers.drain(this.#closeTimeoutMs);
1577
+ if (this.#runningTasks.size > 0) {
1578
+ let timer;
1579
+ await Promise.race([
1580
+ Promise.allSettled([...this.#runningTasks]),
1581
+ new Promise((resolve) => {
1582
+ timer = setTimeout(resolve, Math.max(deadline - Date.now(), 0));
1583
+ }),
1584
+ ]);
1585
+ clearTimeout(timer);
1586
+ }
1587
+ // A renewal round in flight would re-stamp what we are about to hand back.
1588
+ await this.#renewal;
1589
+ for (const [table, byToken] of [...this.#held]) {
1590
+ const tokens = [...byToken.keys()];
1591
+ if (tokens.length === 0)
1592
+ continue;
1593
+ await this.#handBack(table, tokens).catch((err) => this.#onError(err));
1594
+ }
1595
+ await this.#sweeping;
1596
+ await this.#closeInboxes();
465
1597
  }
466
1598
  /** Stop the pollers (alias of {@link stop} for the `Transport.close` contract). */
467
1599
  async close() {
468
1600
  await this.stop();
469
1601
  }
470
1602
  }
1603
+ /**
1604
+ * Run `fn` over `items` with at most `limit` in flight, in order of start. The first failure stops
1605
+ * starting new items; the ones already running settle, then it rethrows.
1606
+ */
1607
+ async function eachLimited(items, limit, fn) {
1608
+ let next = 0;
1609
+ let failure;
1610
+ const lane = async () => {
1611
+ while (!failure && next < items.length) {
1612
+ const item = items[next++];
1613
+ try {
1614
+ await fn(item);
1615
+ }
1616
+ catch (err) {
1617
+ failure ??= { err };
1618
+ }
1619
+ }
1620
+ };
1621
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, lane));
1622
+ if (failure)
1623
+ throw failure.err;
1624
+ }
471
1625
  //# sourceMappingURL=db.js.map