@adonis-agora/durable 0.49.0 → 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 +261 -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 -143
  245. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -1
  246. package/dist/src/transports/bullmq/adaptive-concurrency.js +3 -311
  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 +130 -19
  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,3 +1,4 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
1
2
  import { InMemoryAdmissionBackend } from './admission.js';
2
3
  import { backoffDelay, MAX_BACKOFF_MS } from './backoff.js';
3
4
  import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.js';
@@ -6,21 +7,28 @@ import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } fro
6
7
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
7
8
  import { parseDuration } from './duration.js';
8
9
  import { Entities } from './entities.js';
9
- import { ContinueAsNew, GatherError, NonDeterminismError, RemoteStepTimeout, WorkflowSuspended, } from './errors.js';
10
+ import { ContinueAsNew, GatherError, NonDeterminismError, RemoteStepTimeout, RemoteWorkflowTimeout, WorkflowSuspended, } from './errors.js';
10
11
  import { EventAccumulators } from './event-accumulators.js';
11
12
  import { eventMatches, eventMatchOf, eventPrefix } from './events.js';
12
13
  import { childIdsOf, FOLDED_PREFIX, fanOfWaiter, parseStepFanGroup, stepFanGroup, } from './fan-join.js';
13
14
  import { aggregateAnnouncements } from './handshake/announced.js';
15
+ import { buildWorkflowDirectory } from './handshake/directory.js';
14
16
  import { isNonEmptyRunScope, TERMINAL_RUN_STATUSES } from './interfaces.js';
17
+ import { DEFAULT_NAMESPACE } from './namespaces.js';
15
18
  import { asHeartbeat } from './observability-scope.js';
16
19
  import { assertRoutes, codeAndDetails, HANDOFF_SEQ_BASE, handoffHandlerId, handoffName, liveHandoff, onErrorRunId, retiredHandoffName, routeFor, } from './on-error.js';
17
20
  import { breakpointToken, stepId } from './protocol.js';
21
+ import { markRemoteEvent } from './remote-event.js';
18
22
  import { RemoteWorkflowExecutor } from './remote-workflow-executor.js';
23
+ import { loadFanMembers, loadRunDetail, } from './run-detail-summary.js';
24
+ import { runFacetsOf } from './run-facets.js';
19
25
  import { scanRunValueFacets } from './run-value-facets.js';
20
26
  import { listSignalWaitersOfRuns } from './run-waiting.js';
21
27
  import { scheduleWindow } from './scheduler.js';
22
28
  import { ScheduleClient } from './schedules.js';
29
+ import { SINGLETON_RESERVED_TAGS, withoutAdmission, withSingletonTracked, } from './singleton-admission.js';
23
30
  import { SingletonGate } from './singleton-gate.js';
31
+ import { validateStandard, validateStandardSync, } from './standard-schema.js';
24
32
  import { sanitizeQueueToken, tenantGroup } from './tenant-group.js';
25
33
  import { TransportPool } from './transport-pool.js';
26
34
  import { workflowAls } from './workflow-als.js';
@@ -74,6 +82,15 @@ const COMPENSATION_SEQ_BASE = -2;
74
82
  const STEP_FAN_CHUNK = 200;
75
83
  /** The default {@link WorkflowEngineDeps.timerBatchSize}. */
76
84
  const DEFAULT_TIMER_BATCH = 500;
85
+ /** The most results of one run settled by one batched write (see `completeRemoteResult`). */
86
+ const RESULT_BATCH_MAX = 256;
87
+ /**
88
+ * How soon (ms) orphan recovery re-drives a REMOTE workflow run found `running` with a call in flight:
89
+ * its turn was lost (an inline advance timed out, or the replica applying a decision died), so it is
90
+ * parked on this short wake rather than the whole `reconcileMs` window. Re-driving dispatches a fresh
91
+ * turn; the calls already in flight are not re-dispatched (their pending checkpoints dedupe them).
92
+ */
93
+ const REMOTE_TURN_REDRIVE_MS = 1_000;
77
94
  /** Fisher–Yates, in place. */
78
95
  function shuffle(items) {
79
96
  for (let i = items.length - 1; i > 0; i -= 1) {
@@ -143,11 +160,21 @@ export class WorkflowEngine {
143
160
  /** Whether the worker tick fires {@link schedules} (the `persistedSchedules` option). */
144
161
  persistedSchedulesEnabled;
145
162
  leaseMs;
163
+ /**
164
+ * The engine clock when this instance was constructed. Every lease THIS incarnation writes ends at
165
+ * or after `bootedAt + leaseMs`; a lease held under {@link instanceId} that ends before that was
166
+ * written by a previous process that ran under the same id (a pod restarted after a crash) — see
167
+ * {@link selfReclaimable}.
168
+ */
169
+ bootedAt;
146
170
  maxRecoveryAttempts;
147
171
  reconcileMs;
172
+ heartbeatPersistThrottleMs;
148
173
  timerBatchSize;
174
+ eventBufferTtlMs;
149
175
  remoteRedispatchMs;
150
176
  remoteRedispatchMax;
177
+ remoteAdvanceSilenceMs;
151
178
  webhookUrl;
152
179
  traceparent;
153
180
  context;
@@ -200,7 +227,10 @@ export class WorkflowEngine {
200
227
  accumulators;
201
228
  /** In-flight remote steps awaiting a worker result, keyed by stepId. */
202
229
  pending = new Map();
203
- /** Per-step "reset the liveness timer" callbacks, called when a heartbeat arrives. */
230
+ /** Each run's step results waiting to be settled on this instance — see {@link completeRemoteResult}. */
231
+ resultQueues = new Map();
232
+ /** Per-step "reset the liveness timer" callbacks, called with the beat's worker timestamp when a
233
+ * heartbeat arrives. */
204
234
  heartbeatResets = new Map();
205
235
  listeners = new Set();
206
236
  /** Step interceptors (onion middleware around real local-step execution), first = outermost. */
@@ -211,6 +241,8 @@ export class WorkflowEngine {
211
241
  enqueuedListeners = new Set();
212
242
  /** Notified when a run is dead-lettered (moved to `dead`) — a hook for a DLQ handler. */
213
243
  deadListeners = new Set();
244
+ /** The fleet-default dead-letter workflow's name — see {@link WorkflowEngineDeps.deadLetterWorkflow}. */
245
+ deadLetterWorkflow;
214
246
  /** Validators gating `engine.update`, keyed by `<workflow>:<updateName>`. */
215
247
  updateValidators = new Map();
216
248
  /** Runs being cancelled WITH saga compensation — see `cancel({ compensate: true })`. */
@@ -237,6 +269,29 @@ export class WorkflowEngine {
237
269
  * writes. `settleRun` checks this and degrades to an echo; `execute`'s finally clears the mark.
238
270
  */
239
271
  leaseLostRuns = new Set();
272
+ /**
273
+ * The lease handle of each execution in flight in this process, by run id. A path that gives the run
274
+ * lease away mid-execution on purpose (a remote turn suspending on its dispatched decision, see
275
+ * {@link releaseTurnLease}) flags it, so the execution's renew loop stops renewing and never reads its
276
+ * own release as a takeover: a late `false` renew would otherwise fence whoever applies the decision.
277
+ */
278
+ executionLeases = new Map();
279
+ /** Stable decision handler subscribed on every remote executor's transport (see registerRemote). */
280
+ decisionHandler = (decision) => {
281
+ // A transport that delivers the decision from INSIDE its own `dispatchWorkflowTask` (an in-process
282
+ // broker) would otherwise wait on the run gate the dispatching execution still holds. Apply it once
283
+ // that execution has returned instead; it is tracked, so drain still waits for it.
284
+ if (turnDispatchAls.getStore() === decision?.runId) {
285
+ this.trackEffect(this.completeRemoteDecision(decision));
286
+ return Promise.resolve();
287
+ }
288
+ return this.completeRemoteDecision(decision);
289
+ };
290
+ /** Earliest clock time an awaited turn's deadline is worth reading again (`runId` → clock ms), the
291
+ * read throttle for {@link rearmDecisionDeadline}. */
292
+ decisionDeadlineRearms = new Map();
293
+ /** Whether any registered remote executor carries its own turn window (`decisionTimeoutMs`). */
294
+ anyDecisionTimeout = false;
240
295
  /**
241
296
  * The in-process execution gate, by run id: the execution of the run currently in flight in THIS
242
297
  * process (`tail`), and at most one resume queued behind it (`queued`). See {@link resumeRun}.
@@ -260,6 +315,10 @@ export class WorkflowEngine {
260
315
  */
261
316
  postSettle = new Set();
262
317
  draining = false;
318
+ /** Shutdown wait already spent before/alongside {@link drain} (see {@link beginDrain}). */
319
+ drainSpentMs = 0;
320
+ /** Re-arms the deadline of every {@link drain} currently waiting, when more wait is reported. */
321
+ drainRearms = new Set();
263
322
  constructor(deps) {
264
323
  this.store = deps.store;
265
324
  this.pool = new TransportPool(deps.transports ?? (deps.transport ? [{ id: 'default', transport: deps.transport }] : []));
@@ -271,7 +330,9 @@ export class WorkflowEngine {
271
330
  // instead of waiting for their retry tick. Best-effort (the retry tick remains the guarantee).
272
331
  this.admission.onFreed?.((queue) => this.wakeQueueWaiters(queue));
273
332
  this.instanceId = deps.instanceId ?? globalThis.crypto.randomUUID();
274
- this.namespace = deps.namespace;
333
+ // `''` is the default pool (the transports already treat it as bare): normalize it here so the
334
+ // runs this engine stamps, and the pool it polls, are the default pool's.
335
+ this.namespace = deps.namespace === '' ? DEFAULT_NAMESPACE : deps.namespace;
275
336
  // The control-plane's own handshake descriptor — the side negotiated AGAINST each worker so a
276
337
  // protocol-incompatible fleet is detected before dispatch (design §7.3). Scoped to this engine's
277
338
  // namespace/partition; capabilities/protocol are config-driven (default: current single major).
@@ -285,7 +346,13 @@ export class WorkflowEngine {
285
346
  ? { namespace: this.namespace }
286
347
  : {}),
287
348
  });
288
- this.blockedPollMs = deps.blockedPollMs ?? 5000;
349
+ // A non-finite or sub-1 poll falls back to the default: NaN would park a blocked run on an
350
+ // unreachable wake, and 0 would re-drive it in a hot loop.
351
+ const blockedPoll = deps.blockedPollMs;
352
+ this.blockedPollMs =
353
+ blockedPoll !== undefined && Number.isFinite(blockedPoll) && blockedPoll >= 1
354
+ ? blockedPoll
355
+ : 5000;
289
356
  // Propagate the engine's namespace to its transport(s) so the SAME namespace that partitions the
290
357
  // store also partitions the transport's queues/keys — set once on the engine, applied everywhere.
291
358
  // The transport makes "default" a no-op (byte-identical names); an empty pool is a no-op too.
@@ -294,18 +361,34 @@ export class WorkflowEngine {
294
361
  if (this.namespace !== undefined)
295
362
  this.pool.useNamespace(this.namespace);
296
363
  this.leaseMs = deps.leaseMs ?? 30_000;
364
+ this.bootedAt = this.clock();
297
365
  this.maxRecoveryAttempts = deps.maxRecoveryAttempts;
298
- // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event).
299
- this.reconcileMs = deps.reconcileMs === undefined ? 300_000 : deps.reconcileMs || undefined;
366
+ // Default 5min; an explicit 0 disables the fallback (opt back into wake-forever-on-lost-event). A
367
+ // non-finite or negative value falls back to the default rather than silently disabling the net
368
+ // (NaN) or stamping a wake in the past that re-drives the run every tick (negative).
369
+ const reconcile = deps.reconcileMs;
370
+ this.reconcileMs =
371
+ reconcile === undefined || !Number.isFinite(reconcile) || reconcile < 0
372
+ ? 300_000
373
+ : reconcile || undefined;
374
+ // Validated like `timerBatchSize`: a NaN/negative throttle would silently disable the
375
+ // cross-instance liveness salvage (`timeoutMs + NaN` never compares true). `0` stays valid.
376
+ const throttle = deps.heartbeatPersistThrottleMs;
377
+ this.heartbeatPersistThrottleMs =
378
+ throttle !== undefined && Number.isFinite(throttle) && throttle >= 0 ? throttle : 10_000;
300
379
  // A non-finite or sub-1 value falls back to the default: a NaN limit would stall timer recovery.
301
380
  const batch = deps.timerBatchSize;
302
381
  this.timerBatchSize =
303
382
  batch !== undefined && Number.isFinite(batch) && batch >= 1
304
383
  ? Math.floor(batch)
305
384
  : DEFAULT_TIMER_BATCH;
385
+ const ttl = deps.eventBufferTtlMs;
386
+ this.eventBufferTtlMs = ttl !== undefined && Number.isFinite(ttl) && ttl > 0 ? ttl : undefined;
306
387
  // Opt-in: unset (or 0) leaves the by-design "re-suspend a lost dispatch, never re-dispatch" behavior.
307
388
  this.remoteRedispatchMs = deps.remoteRedispatchMs || undefined;
308
389
  this.remoteRedispatchMax = Math.max(1, deps.remoteRedispatchMax ?? 10);
390
+ // Opt-in: unset (or 0) keeps the unbounded wait for a remote workflow turn's decision.
391
+ this.remoteAdvanceSilenceMs = deps.remoteAdvanceSilenceMs || undefined;
309
392
  this.webhookUrl = deps.webhookUrl;
310
393
  this.traceparent = deps.traceparent;
311
394
  this.context = deps.context;
@@ -315,6 +398,8 @@ export class WorkflowEngine {
315
398
  this.compensationTimeoutMs = Math.max(1_000, deps.compensationTimeoutMs ?? 300_000);
316
399
  this.retention = deps.retention;
317
400
  this.retentionPolicies = deps.retentionPolicies ?? [];
401
+ this.deadLetterWorkflow =
402
+ deps.deadLetterWorkflow !== undefined ? workflowName(deps.deadLetterWorkflow) : undefined;
318
403
  this.persistedSchedulesEnabled = deps.persistedSchedules ?? false;
319
404
  this.schedules = new ScheduleClient(this.store, {
320
405
  start: (workflow, input, runId, opts) => this.start(workflow, input, runId, opts),
@@ -324,11 +409,22 @@ export class WorkflowEngine {
324
409
  this.stalledAfterMs = Math.max(60_000, deps.stalledAfterMs ?? 900_000);
325
410
  this.trackStepStart = deps.trackStepStart ?? true;
326
411
  // Default: execute the run on this instance, asynchronously, so `start` never blocks on the body.
327
- // A failed pickup is swallowed here (the run stays `pending` for a `runPending` poll to retry);
328
- // run failures themselves are handled inside `execute` and surfaced as the run's `failed` state.
412
+ // A failed pickup is reported, never thrown (the run stays `pending`, or leased until its lease
413
+ // lapses, for a `runPending` poll or recovery to retry); run failures themselves are handled
414
+ // inside `execute` and surfaced as the run's `failed` state. What reaches here is the store or
415
+ // the registry refusing the pickup — a lost connection, a missing table, an unregistered version.
329
416
  this.usesInProcessDispatch = deps.runDispatcher === undefined;
417
+ //
418
+ // Tracked from the moment of dispatch (see {@link trackEffect}): the pickup leases and reads the
419
+ // run before its execution enters `inflight`, so a bare `queueMicrotask` let `drain()` find both
420
+ // registries empty in that gap and return while the pickup was still about to write — onto a
421
+ // closed connection, or a dropped schema. The one-microtask defer is kept.
330
422
  this.runDispatcher = deps.runDispatcher ?? {
331
- dispatch: (runId) => queueMicrotask(() => void this.runOne(runId).catch(() => { })),
423
+ dispatch: (runId) => this.trackEffect(Promise.resolve()
424
+ .then(() => this.runOne(runId))
425
+ .catch((err) => {
426
+ console.warn(`[adonis-durable] in-process pickup of run ${runId} failed (a pending-run poll or recovery retries it): ${err instanceof Error ? err.message : String(err)}`);
427
+ })),
332
428
  };
333
429
  this.singletons = new SingletonGate({
334
430
  store: this.store,
@@ -367,10 +463,22 @@ export class WorkflowEngine {
367
463
  // in-memory waiter (the step suspended DURABLY, possibly on another instance) the beat rearms
368
464
  // the step's durable LEASE instead, so a worker still holding a long step is never presumed
369
465
  // lost and double-dispatched (see rearmStepLease).
466
+ //
467
+ // A RUN-scoped beat (no `stepId`: a worker replaying a long workflow TURN) keeps that turn alive
468
+ // instead: an inline advance awaiting on THIS instance rearms its in-memory deadline, otherwise
469
+ // the awaited turn's durable re-drive deadline moves forward (see rearmDecisionDeadline).
370
470
  async (beat) => {
471
+ if (!beat.stepId) {
472
+ const resetTurn = this.heartbeatResets.get(turnBeatKey(beat.runId));
473
+ if (resetTurn)
474
+ resetTurn(beat.at);
475
+ else
476
+ await this.rearmDecisionDeadline(beat.runId);
477
+ return;
478
+ }
371
479
  const reset = this.heartbeatResets.get(beat.stepId);
372
480
  if (reset)
373
- reset();
481
+ reset(beat.at);
374
482
  else
375
483
  await this.rearmStepLease(beat.runId, beat.seq);
376
484
  this.persistHeartbeat(beat);
@@ -388,7 +496,7 @@ export class WorkflowEngine {
388
496
  return;
389
497
  if (msg.kind === 'event') {
390
498
  // `at` may be a string after JSON transit (Redis) — normalize back to a Date.
391
- this.deliver({ ...msg.event, at: new Date(msg.event.at) });
499
+ this.deliver(markRemoteEvent({ ...msg.event, at: new Date(msg.event.at) }));
392
500
  }
393
501
  else if (msg.kind === 'cancel') {
394
502
  this.notifyCancelled(msg.runId);
@@ -412,7 +520,11 @@ export class WorkflowEngine {
412
520
  }
413
521
  });
414
522
  this.accumulators = new EventAccumulators(this);
415
- this.entities = new Entities(this);
523
+ this.entities = new Entities(this, {
524
+ bufferSignal: (token, payload) => this.store.bufferSignal(token, payload),
525
+ wakeWaiter: (token, payload) => this.wakeSignalWaiter(token, payload),
526
+ ensureStarted: (workflow, input, runId) => this.start(workflow, input, runId),
527
+ });
416
528
  }
417
529
  /**
418
530
  * Register a **durable entity** (a virtual object): a keyed actor whose `handlers` run **serialized
@@ -537,6 +649,42 @@ export class WorkflowEngine {
537
649
  return this.entities.signal(name, key, op, arg);
538
650
  }
539
651
  /** Read an entity's current durable state (published after each op), or undefined if it has none yet. */
652
+ /**
653
+ * The entity lost-wake reconcile: wake (start, if need be) every entity that has ops sitting in its
654
+ * buffer, up to `limit` of them. A workflow-side send stores the op and THEN wakes the entity, so a
655
+ * process that dies in between leaves the op buffered while the entity sleeps; this pass is what
656
+ * applies it. Idempotent — a wake carries a no-op nudge, and the entity reads its buffer in arrival
657
+ * order, consuming each op once (an op with a stable id is skipped if already applied). The worker
658
+ * tick runs it, throttled to one pass a minute. Returns how many entities were woken. A no-op on a
659
+ * store without `listBufferedSignalTokens`.
660
+ */
661
+ async reconcileEntityWakes(limit = 100) {
662
+ if (this.draining || !this.store.listBufferedSignalTokens)
663
+ return 0;
664
+ const tokens = await this.store.listBufferedSignalTokens('entity:', limit);
665
+ let woken = 0;
666
+ for (const token of tokens) {
667
+ if (this.draining)
668
+ break;
669
+ try {
670
+ if (await this.entities.kickRunId(token))
671
+ woken += 1;
672
+ }
673
+ catch (err) {
674
+ console.warn(`[adonis-durable] entity reconcile of ${token} skipped: ${err instanceof Error ? err.message : String(err)}`);
675
+ }
676
+ }
677
+ return woken;
678
+ }
679
+ /** Throttle stamp for the tick's {@link reconcileEntityWakes} pass. */
680
+ #lastEntityReconcileAt = Number.NEGATIVE_INFINITY;
681
+ /** {@link reconcileEntityWakes}, at most once a minute — the worker tick's entry point. */
682
+ async sweepEntityWakes(now = this.clock()) {
683
+ if (now - this.#lastEntityReconcileAt < 60_000)
684
+ return 0;
685
+ this.#lastEntityReconcileAt = now;
686
+ return this.reconcileEntityWakes();
687
+ }
540
688
  getEntityState(name, key) {
541
689
  return this.entities.getState(name, key);
542
690
  }
@@ -571,12 +719,15 @@ export class WorkflowEngine {
571
719
  version,
572
720
  fn,
573
721
  onError: this.checkedRoutes(name, opts?.onError),
722
+ deadLetterWorkflow: opts?.deadLetterWorkflow !== undefined ? workflowName(opts.deadLetterWorkflow) : undefined,
574
723
  tags: opts?.tags,
575
724
  origin: opts?.origin,
576
725
  singleton: opts?.singleton,
577
726
  concurrency: opts?.concurrency,
578
727
  executionTimeoutMs: opts?.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
579
728
  validateInput: opts?.validateInput,
729
+ inputSchema: opts?.inputSchema,
730
+ searchAttributesSchema: opts?.searchAttributesSchema,
580
731
  onEvent: opts?.onEvent,
581
732
  eventBatch: opts?.eventBatch,
582
733
  requires: opts?.requires,
@@ -602,6 +753,7 @@ export class WorkflowEngine {
602
753
  name,
603
754
  version,
604
755
  onError: this.checkedRoutes(name, opts.onError),
756
+ deadLetterWorkflow: opts.deadLetterWorkflow !== undefined ? workflowName(opts.deadLetterWorkflow) : undefined,
605
757
  // A remote workflow has no in-process body; execute() branches on `remote` before this is read.
606
758
  fn: () => {
607
759
  throw new Error(`workflow ${name} is remote — it has no in-process body`);
@@ -611,34 +763,71 @@ export class WorkflowEngine {
611
763
  concurrency: opts.concurrency,
612
764
  executionTimeoutMs: opts.executionTimeout != null ? parseDuration(opts.executionTimeout) : undefined,
613
765
  validateInput: opts.validateInput,
766
+ inputSchema: opts.inputSchema,
614
767
  remote: { group: opts.group, executor: opts.executor },
615
768
  requires: opts.requires,
616
769
  };
770
+ // A dispatched turn's decision is applied by whichever replica consumes it, so every engine that
771
+ // can dispatch a turn also consumes decisions. The handler is one stable function: a transport that
772
+ // keeps a handler set registers it once however many remote workflows share the transport.
773
+ opts.executor.subscribeDecisions?.(this.decisionHandler);
774
+ if (opts.executor.decisionTimeoutMs)
775
+ this.anyDecisionTimeout = true;
617
776
  this.workflows.set(versionKey(name, version), registered);
618
777
  const current = this.latest.get(name);
619
778
  if (!current || isNewerVersion(version, current.version))
620
779
  this.latest.set(name, registered);
621
780
  }
622
781
  /**
623
- * Convention-dispatch resolution: when `name` isn't locally registered but a LIVE worker group of
624
- * the same name exists, auto-register a remote workflow routed to that group (bare group, no
625
- * partition) so its runs advance over the broker like any `registerRemote`'d one. Always on — the
626
- * queue name IS the routing, exactly as the aviary engine does — so a Python/NestJS/thin-worker
627
- * workflow is reached by NAME with no `registerRemote` boilerplate. Returns whether `name` is
628
- * registered afterwards. A no-op (returns `false`) when no transport is wired (nothing to route
629
- * over) or no matching worker group is live (then `start`/`resume` throws `is not registered`).
782
+ * Register a workflow whose body runs in another SDK, routed to worker `group` — {@link
783
+ * registerRemote} with the executor built for you over this engine's primary transport. `version`
784
+ * defaults to `'1'`; register `name` at a version explicitly to pin starts to it
785
+ * ({@link StartOptions.version}).
630
786
  */
631
- async ensureConventionWorkflow(name) {
787
+ remote(name, opts) {
788
+ if (!this.primaryTransport) {
789
+ throw new Error(`@agora/durable: engine.remote('${name}') needs a transport that carries workflow tasks.`);
790
+ }
791
+ const { version, ...rest } = opts;
792
+ this.registerRemote(name, version ?? '1', {
793
+ ...rest,
794
+ executor: new RemoteWorkflowExecutor(this.primaryTransport, opts.group),
795
+ });
796
+ }
797
+ /**
798
+ * Convention-dispatch resolution: when `name` isn't locally registered but a LIVE worker group for
799
+ * it exists, auto-register a remote workflow routed to that group so its runs advance over the
800
+ * broker like any `registerRemote`'d one. Always on — the queue name IS the routing, exactly as the
801
+ * aviary engine does — so a Python/NestJS/thin-worker workflow is reached by NAME with no
802
+ * `registerRemote` boilerplate. Returns whether `name` is registered (and routable for `namespace`)
803
+ * afterwards. A no-op (returns `false`) when no transport is wired (nothing to route over) or no
804
+ * matching worker group is live (then `start`/`resume` throws `is not registered`).
805
+ *
806
+ * TENANT-AWARE, per run: the group is the one the RUN's namespace routes to,
807
+ * `<name>@<namespace>` (bare for an absent/`default` namespace) — the token a tenant worker
808
+ * (`partition: <tenant>`) serves, and the same rule steps follow ({@link stepGroup}). So an operator
809
+ * starting a tenant's run reaches the tenant's live `<name>@<tenant>` group, not the bare one, and a
810
+ * convention workflow is re-checked for the namespace of every start.
811
+ */
812
+ async ensureConventionWorkflow(name, namespace) {
632
813
  if (!this.primaryTransport)
633
814
  return false;
634
- if (this.latest.get(name))
815
+ const current = this.latest.get(name);
816
+ if (current && !current.convention)
635
817
  return true;
636
- const groups = await this.pool.listWorkerGroups();
637
- if (!groups.includes(name))
818
+ const group = conventionGroup(name, namespace);
819
+ // The transport whose keyspace reports the live group: in a mixed pool each transport scans a
820
+ // different keyspace, and dispatching on the primary while the worker beats on another one parks
821
+ // the turn on a queue nobody consumes.
822
+ const transport = await this.pool.transportWithLiveGroup(group);
823
+ if (!transport)
638
824
  return false;
825
+ if (current)
826
+ return true;
639
827
  this.registerRemote(name, '1', {
640
828
  group: name,
641
- executor: new RemoteWorkflowExecutor(this.primaryTransport, name),
829
+ // Per run: each run dispatches to the group of its own namespace.
830
+ executor: new RemoteWorkflowExecutor(transport, (run) => conventionGroup(run.workflow, run.namespace)),
642
831
  });
643
832
  const registered = this.workflows.get(versionKey(name, '1'));
644
833
  if (registered)
@@ -658,7 +847,7 @@ export class WorkflowEngine {
658
847
  heartbeatPersists = new Map();
659
848
  /**
660
849
  * Persist a worker heartbeat on its step's checkpoint (see {@link StepCheckpoint.lastHeartbeatAt}),
661
- * best-effort and THROTTLED per step (≥10s between writes) — beats may arrive every few seconds
850
+ * best-effort and THROTTLED per step (≥ `heartbeatPersistThrottleMs`, default 10s, between writes) — beats may arrive every few seconds
662
851
  * from a hot batch loop, and liveness observability must not turn into a per-item UPDATE storm.
663
852
  * One exemption: a beat CARRYING progress punches through when the last persisted one had none —
664
853
  * the automatic pickup beat lands first (no payload) and must not shadow the handler's first real
@@ -667,16 +856,19 @@ export class WorkflowEngine {
667
856
  * wholesale past 1024 entries — a rare extra write, never a leak.
668
857
  */
669
858
  persistHeartbeat(beat) {
670
- if (!this.store.recordStepHeartbeat)
859
+ const id = beat.stepId;
860
+ if (!this.store.recordStepHeartbeat || !id)
671
861
  return;
672
862
  const now = this.clock();
673
- const last = this.heartbeatPersists.get(beat.stepId);
863
+ const last = this.heartbeatPersists.get(id);
674
864
  const upgradesToProgress = beat.progress !== undefined && last !== undefined && !last.hadProgress;
675
- if (last !== undefined && now - last.at < 10_000 && !upgradesToProgress)
865
+ if (last !== undefined &&
866
+ now - last.at < this.heartbeatPersistThrottleMs &&
867
+ !upgradesToProgress)
676
868
  return;
677
869
  if (this.heartbeatPersists.size > 1024)
678
870
  this.heartbeatPersists.clear();
679
- this.heartbeatPersists.set(beat.stepId, { at: now, hadProgress: beat.progress !== undefined });
871
+ this.heartbeatPersists.set(id, { at: now, hadProgress: beat.progress !== undefined });
680
872
  void this.store
681
873
  .recordStepHeartbeat(beat.runId, beat.seq, new Date(beat.at ?? now), beat.progress)
682
874
  .catch(() => undefined);
@@ -691,6 +883,28 @@ export class WorkflowEngine {
691
883
  startConsumers() {
692
884
  this.pool.startConsumers();
693
885
  }
886
+ /**
887
+ * Serve a step body by name on this engine's primary transport — the same
888
+ * `transport.handle(name, fn)` the provider calls for every `stepsPath` handler. It makes the
889
+ * engine a {@link import('./step-discovery.js').StepServer}, so app code can serve steps from an
890
+ * extra directory without reaching for the (provider-private) transport:
891
+ *
892
+ * ```ts
893
+ * // start/durable.ts (a preload)
894
+ * import engine from '@adonis-agora/durable/services/main'
895
+ * import { registerStepsFromDir } from '@adonis-agora/durable'
896
+ * await registerStepsFromDir(engine, app.makePath('app/engine/steps'))
897
+ * ```
898
+ *
899
+ * Throws when no transport is wired or the wired one can't serve handlers (no `handle`).
900
+ */
901
+ handle(name, fn) {
902
+ const server = this.primaryTransport;
903
+ if (!server || typeof server.handle !== 'function') {
904
+ throw new Error(`@agora/durable: cannot serve step "${name}": the engine has no transport that serves step handlers (no handle()).`);
905
+ }
906
+ server.handle(name, fn);
907
+ }
694
908
  /** Subscribe to lifecycle events. Returns an unsubscribe function. */
695
909
  subscribe(listener) {
696
910
  this.listeners.add(listener);
@@ -756,6 +970,35 @@ export class WorkflowEngine {
756
970
  /* a dead-letter handler must not break recovery */
757
971
  }
758
972
  }
973
+ this.routeDeadLetter(run);
974
+ }
975
+ /**
976
+ * Hand a `dead` run to its dead-letter workflow, when one is configured: the workflow's own
977
+ * `deadLetterWorkflow`, else the engine's default. Started as `dlq:<runId>` (idempotent: a run
978
+ * dead-lettered twice is handled once; the dashboard links the pair by that id) in the dead run's
979
+ * namespace, with a {@link DeadLetter} input. Tracked, so a drain waits for the start; a failure
980
+ * (e.g. the handler is not registered here) is warned, never thrown into recovery. A handler run
981
+ * that itself goes dead is not routed again — no `dlq:dlq:…` chain.
982
+ */
983
+ routeDeadLetter(run) {
984
+ if (run.id.startsWith('dlq:'))
985
+ return;
986
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
987
+ this.latest.get(run.workflow);
988
+ const target = registered?.deadLetterWorkflow ?? this.deadLetterWorkflow;
989
+ if (!target)
990
+ return;
991
+ const input = {
992
+ deadRunId: run.id,
993
+ workflow: run.workflow,
994
+ input: run.input,
995
+ ...(run.error ? { error: run.error } : {}),
996
+ };
997
+ this.trackEffect(this.start(target, input, `dlq:${run.id}`, {
998
+ ...(run.namespace !== undefined ? { namespace: run.namespace } : {}),
999
+ }).catch((err) => {
1000
+ console.warn(`[adonis-durable] could not start dead-letter workflow ${target} for run ${run.id}: ${err instanceof Error ? err.message : String(err)}`);
1001
+ }));
759
1002
  }
760
1003
  /** Emit a locally-produced lifecycle event: deliver to subscribers AND broadcast it on the
761
1004
  * control plane so other instances (e.g. a dashboard pod) can live-tail this run. */
@@ -797,16 +1040,31 @@ export class WorkflowEngine {
797
1040
  */
798
1041
  async startCore(workflow, input, runId, opts, dispatch) {
799
1042
  const name = workflowName(workflow);
800
- let registered = this.latest.get(name);
1043
+ // A pinned start (`opts.version`) resolves name@version and stops there: no newest, no
1044
+ // convention fallback, since neither can prove that version exists.
1045
+ const pinned = opts?.version;
1046
+ let registered = pinned === undefined ? this.latest.get(name) : this.workflows.get(versionKey(name, pinned));
801
1047
  // Convention dispatch: an unregistered workflow whose name matches a LIVE worker group is routed
802
- // to it as a remote workflow (bare group). No registration needed — the queue name is the routing;
803
- // when no matching group is live this is a no-op and the "not registered" throw below fires.
804
- if (!registered && (await this.ensureConventionWorkflow(name))) {
805
- registered = this.latest.get(name);
806
- }
807
- if (!registered)
808
- throw new Error(`workflow ${name} is not registered`);
809
- // Validate the input up front — a bad payload is rejected before any run is created.
1048
+ // to it as a remote workflow — the group of the namespace this run will carry (see
1049
+ // ensureConventionWorkflow). No registration needed — the queue name is the routing; when no
1050
+ // matching group is live this is a no-op and the "not registered" throw below fires. Re-checked
1051
+ // per start for a convention registration, since the tenant can differ from one start to the next.
1052
+ // A pinned start never falls back to convention: it cannot prove the pinned version exists.
1053
+ if (pinned === undefined && (!registered || registered.convention)) {
1054
+ registered = (await this.ensureConventionWorkflow(name, opts?.namespace ?? this.namespace))
1055
+ ? this.latest.get(name)
1056
+ : undefined;
1057
+ }
1058
+ if (!registered) {
1059
+ throw new Error(pinned === undefined
1060
+ ? `workflow ${name} is not registered`
1061
+ : `workflow ${name}@${pinned} is not registered`);
1062
+ }
1063
+ // Validate the input up front — a bad payload is rejected before any run is created. The schema
1064
+ // runs first and its OUTPUT (parsed/coerced) is what the run stores; `validateInput` then sees it.
1065
+ if (registered.inputSchema) {
1066
+ input = await validateStandard(registered.inputSchema, input, `workflow "${name}" input`);
1067
+ }
810
1068
  await registered.validateInput?.(input);
811
1069
  // Idempotent by run id: a redelivered trigger (at-least-once queues) or a scheduler re-tick for
812
1070
  // the same id is a no-op, returning the existing run's state instead of starting a duplicate.
@@ -834,7 +1092,9 @@ export class WorkflowEngine {
834
1092
  ...(registered.singleton ? [this.singletons.tag(registered.singleton, input)] : []),
835
1093
  ...(quota ? [concurrencyTag(quota.key)] : []),
836
1094
  ];
837
- const tags = mergeTags(registered.tags, minted.length ? [...(opts?.tags ?? []), ...minted] : opts?.tags);
1095
+ // The admission marker is owned by the store's atomic claim: a fresh run can neither supply it nor
1096
+ // inherit it (`retryWithInput` copies the original run's tags).
1097
+ const tags = mergeTags(registered.tags, minted.length ? [...(opts?.tags ?? []), ...minted] : opts?.tags)?.filter((t) => !SINGLETON_RESERVED_TAGS.includes(t));
838
1098
  // Singleton back-pressure: reject a start that would grow the same-key backlog past
839
1099
  // `limit + maxQueueDepth` (no-op when no maxQueueDepth is configured).
840
1100
  if (registered.singleton) {
@@ -978,6 +1238,39 @@ export class WorkflowEngine {
978
1238
  listCheckpoints(runId) {
979
1239
  return this.store.listCheckpoints(runId);
980
1240
  }
1241
+ /**
1242
+ * How the runs matching `query` split across `(status, origin)`, in one aggregate when the store
1243
+ * has {@link StateStore.runFacets} (else counted over a listing). Part of the engine's read API.
1244
+ */
1245
+ runFacets(query) {
1246
+ return runFacetsOf(this.store, query);
1247
+ }
1248
+ /**
1249
+ * A run's detail (see `loadRunDetail`), built from the store's checkpoint aggregates when it has
1250
+ * `checkpointStats` + `queryCheckpoints` — a summarized detail then reads a few rows per wide fan
1251
+ * instead of the whole history, and carries an `etag` that `options.ifNoneMatch` can match.
1252
+ */
1253
+ getRunDetail(runId, options) {
1254
+ return loadRunDetail(this.detailSource(), runId, options);
1255
+ }
1256
+ /** One page of a summarized fan's members (see `loadFanMembers`), versioned like {@link getRunDetail}. */
1257
+ getFanMembers(runId, group, query) {
1258
+ return loadFanMembers(this.detailSource(), runId, group, query);
1259
+ }
1260
+ detailSource() {
1261
+ const store = this.store;
1262
+ return {
1263
+ getRun: (id) => this.getRun(id),
1264
+ listCheckpoints: (id) => store.listCheckpoints(id),
1265
+ getRunChildren: (id) => this.getRunChildren(id),
1266
+ ...(store.checkpointStats && store.queryCheckpoints
1267
+ ? {
1268
+ checkpointStats: store.checkpointStats.bind(store),
1269
+ queryCheckpoints: store.queryCheckpoints.bind(store),
1270
+ }
1271
+ : {}),
1272
+ };
1273
+ }
981
1274
  /**
982
1275
  * Hard-delete a run and its whole child subtree from the store, returning the number of runs
983
1276
  * removed (0 if `runId` is unknown). Children are collected BEFORE this run's checkpoints are
@@ -1087,11 +1380,16 @@ export class WorkflowEngine {
1087
1380
  * parents.
1088
1381
  */
1089
1382
  async sweepRetention(now = this.clock()) {
1090
- if ((!this.retention && this.retentionPolicies.length === 0) || this.draining)
1383
+ if ((!this.retention &&
1384
+ this.retentionPolicies.length === 0 &&
1385
+ this.eventBufferTtlMs === undefined) ||
1386
+ this.draining) {
1091
1387
  return 0;
1388
+ }
1092
1389
  if (now - this.#lastRetentionSweepAt < 60_000)
1093
1390
  return 0;
1094
1391
  this.#lastRetentionSweepAt = now;
1392
+ await this.pruneExpiredBufferedEvents(now);
1095
1393
  let evicted = 0;
1096
1394
  const passes = [];
1097
1395
  for (const status of TERMINAL_RUN_STATUSES) {
@@ -1102,7 +1400,9 @@ export class WorkflowEngine {
1102
1400
  }
1103
1401
  for (const policy of this.retentionPolicies) {
1104
1402
  const statuses = policy.statuses.filter((s) => TERMINAL_RUN_STATUSES.includes(s));
1105
- if (statuses.length === 0 || !(policy.maxAgeMs > 0))
1403
+ const byAge = policy.maxAgeMs !== undefined && policy.maxAgeMs > 0;
1404
+ const byCount = policy.maxCount !== undefined && policy.maxCount >= 0;
1405
+ if (statuses.length === 0 || (!byAge && !byCount))
1106
1406
  continue;
1107
1407
  // A namespaced worker only ever prunes its own partition: a scope naming another one is not its
1108
1408
  // to sweep (the operator, `namespace: undefined`, sweeps every partition).
@@ -1111,15 +1411,32 @@ export class WorkflowEngine {
1111
1411
  policy.scope.namespace !== this.namespace) {
1112
1412
  continue;
1113
1413
  }
1114
- passes.push({
1414
+ const scoped = {
1115
1415
  ...policy.scope,
1116
1416
  namespace: policy.scope.namespace ?? this.namespace,
1117
1417
  statuses,
1118
- updatedBefore: now - policy.maxAgeMs,
1119
- });
1418
+ };
1419
+ if (byAge)
1420
+ passes.push({ ...scoped, updatedBefore: now - policy.maxAgeMs });
1421
+ if (byCount) {
1422
+ // Keep the `maxCount` newest (listRuns is newest-first by createdAt): the last one kept marks
1423
+ // the cutoff, and every run created strictly before it goes. A run sharing that start
1424
+ // millisecond is kept too, so a tie never deletes one of the newest `maxCount`.
1425
+ const maxCount = policy.maxCount;
1426
+ if (maxCount === 0) {
1427
+ passes.push(scoped);
1428
+ }
1429
+ else {
1430
+ const [lastKept] = await this.store.listRuns({ ...scoped, page: maxCount, size: 1 });
1431
+ if (lastKept) {
1432
+ passes.push({ ...scoped, createdBefore: lastKept.createdAt.getTime() - 1 });
1433
+ }
1434
+ }
1435
+ }
1120
1436
  }
1121
1437
  for (const pass of passes) {
1122
1438
  const candidates = await this.store.listRuns({ ...pass, size: 100 });
1439
+ const archived = [];
1123
1440
  for (const run of candidates) {
1124
1441
  try {
1125
1442
  if (this.evictListeners.size > 0) {
@@ -1127,16 +1444,81 @@ export class WorkflowEngine {
1127
1444
  for (const listener of this.evictListeners)
1128
1445
  await listener(run, checkpoints);
1129
1446
  }
1130
- evicted += await this.deleteRun(run.id);
1447
+ archived.push(run.id);
1131
1448
  }
1132
1449
  catch (err) {
1133
- // An archival hook (or the delete itself) failed — keep the run; the next sweep retries.
1450
+ // An archival hook failed — keep the run; the next sweep retries.
1134
1451
  console.warn(`[adonis-durable] retention eviction of run ${run.id} skipped: ${err instanceof Error ? err.message : String(err)}`);
1135
1452
  }
1136
1453
  }
1454
+ try {
1455
+ // One batched delete per pass (the store's `deleteRuns`), subtrees included.
1456
+ evicted += await this.deleteRunTrees(archived);
1457
+ }
1458
+ catch (err) {
1459
+ // The delete failed — keep the runs; the next sweep retries.
1460
+ console.warn(`[adonis-durable] retention eviction of ${archived.length} run(s) skipped: ${err instanceof Error ? err.message : String(err)}`);
1461
+ }
1137
1462
  }
1138
1463
  return evicted;
1139
1464
  }
1465
+ /**
1466
+ * The {@link eventBufferTtlMs} prune: delete buffered events older than the TTL, a bounded batch
1467
+ * at a time, through the store's `pruneBufferedEvents`. Runs in {@link sweepRetention}'s pass.
1468
+ * Best-effort — a failure is warned and retried on the next pass.
1469
+ */
1470
+ async pruneExpiredBufferedEvents(now) {
1471
+ const ttl = this.eventBufferTtlMs;
1472
+ if (ttl === undefined || !this.store.pruneBufferedEvents)
1473
+ return;
1474
+ try {
1475
+ for (let pass = 0; pass < 10; pass += 1) {
1476
+ const deleted = await this.store.pruneBufferedEvents(now - ttl, 500);
1477
+ if (deleted < 500)
1478
+ break;
1479
+ }
1480
+ }
1481
+ catch (err) {
1482
+ console.warn(`[adonis-durable] buffered-event prune skipped: ${err instanceof Error ? err.message : String(err)}`);
1483
+ }
1484
+ }
1485
+ /**
1486
+ * Delete the runs `rootIds` and their child subtrees: the ids are collected children-first (the
1487
+ * edge is read from the parent's checkpoints, so before anything is deleted), deleted in one
1488
+ * {@link deleteRunIds} batch, then each one's token-keyed buffered signals are swept like
1489
+ * {@link deleteRun} does. Returns how many runs were deleted.
1490
+ */
1491
+ async deleteRunTrees(rootIds) {
1492
+ const ids = [];
1493
+ const seen = new Set();
1494
+ const visit = async (id) => {
1495
+ if (seen.has(id))
1496
+ return;
1497
+ seen.add(id);
1498
+ if (!(await this.store.getRun(id)))
1499
+ return;
1500
+ for (const child of await this.getRunChildren(id))
1501
+ await visit(child);
1502
+ ids.push(id);
1503
+ };
1504
+ for (const id of rootIds)
1505
+ await visit(id);
1506
+ await this.deleteRunIds(ids);
1507
+ for (const id of ids)
1508
+ await this.sweepRunBufferedSignals(id);
1509
+ return ids.length;
1510
+ }
1511
+ /** Bulk-delete through the store's `deleteRuns`, or one `deleteRun` per id when it has none. */
1512
+ async deleteRunIds(ids) {
1513
+ if (ids.length === 0)
1514
+ return;
1515
+ if (this.store.deleteRuns) {
1516
+ await this.store.deleteRuns(ids);
1517
+ return;
1518
+ }
1519
+ for (const id of ids)
1520
+ await this.store.deleteRun(id);
1521
+ }
1140
1522
  async deleteRun(runId) {
1141
1523
  const run = await this.store.getRun(runId);
1142
1524
  if (!run)
@@ -1217,6 +1599,7 @@ export class WorkflowEngine {
1217
1599
  };
1218
1600
  for (const run of batch)
1219
1601
  await visit(run.id);
1602
+ const doomed = [];
1220
1603
  for (const id of ids) {
1221
1604
  const status = known.has(id) ? known.get(id) : (await this.store.getRun(id))?.status;
1222
1605
  if (status === undefined)
@@ -1226,10 +1609,13 @@ export class WorkflowEngine {
1226
1609
  continue;
1227
1610
  await this.cancel(id).catch(() => undefined);
1228
1611
  }
1229
- await this.store.deleteRun(id);
1230
- await this.sweepRunBufferedSignals(id);
1231
- deleted += 1;
1612
+ doomed.push(id);
1232
1613
  }
1614
+ // One batched delete per page (the store's `deleteRuns`), children before their parent.
1615
+ await this.deleteRunIds(doomed);
1616
+ for (const id of doomed)
1617
+ await this.sweepRunBufferedSignals(id);
1618
+ deleted += doomed.length;
1233
1619
  if (batch.length < size)
1234
1620
  break;
1235
1621
  }
@@ -1352,7 +1738,7 @@ export class WorkflowEngine {
1352
1738
  let registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
1353
1739
  // Convention dispatch: a run recovered on an instance that never registered this workflow (e.g. a
1354
1740
  // crash-recovery pickup) is re-routed to a live worker group of the same name, if one exists.
1355
- if (!registered && (await this.ensureConventionWorkflow(run.workflow))) {
1741
+ if (!registered && (await this.ensureConventionWorkflow(run.workflow, run.namespace))) {
1356
1742
  registered =
1357
1743
  this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
1358
1744
  this.latest.get(run.workflow);
@@ -1405,10 +1791,21 @@ export class WorkflowEngine {
1405
1791
  }
1406
1792
  return (await this.store.listCheckpoints(runId)).filter((c) => c.seq >= from && c.seq <= to && c.status === 'pending').length;
1407
1793
  }
1408
- /** Track an in-flight execution so {@link drain} can wait for it. */
1794
+ /**
1795
+ * Track an in-flight execution so {@link drain} can wait for it.
1796
+ *
1797
+ * The bookkeeping chain must not re-raise: `p.finally(...)` returns a NEW promise that rejects
1798
+ * whenever `p` does, and nothing awaits it. So a turn whose store write failed (a dropped
1799
+ * connection, a missing table) became a process-level unhandled rejection even though the caller
1800
+ * awaiting `p` handled the very same error — fatal under Node's default `--unhandled-rejections`.
1801
+ * The rejection is reported once, to the caller that owns `p`.
1802
+ */
1409
1803
  track(runId, p) {
1410
1804
  this.inflight.set(p, runId);
1411
- void p.finally(() => this.inflight.delete(p));
1805
+ const untrack = () => {
1806
+ this.inflight.delete(p);
1807
+ };
1808
+ p.then(untrack, untrack);
1412
1809
  return p;
1413
1810
  }
1414
1811
  /**
@@ -1428,9 +1825,20 @@ export class WorkflowEngine {
1428
1825
  * (and their per-run lock loops) start nothing once this is called, and {@link runOne} refuses
1429
1826
  * broker-delivered runs. Runs already started finish. {@link drain} calls it first; the worker loop
1430
1827
  * calls it the moment a stop is requested, so a tick still in flight stops leasing a backlog.
1828
+ *
1829
+ * `spentMs` is wait time ALREADY spent on this shutdown (the worker loop waiting for its in-flight
1830
+ * tick). It is deducted from the budget of a later {@link drain}, so that wait plus the drain stay
1831
+ * within one `drainTimeoutMs`; a drain already waiting is shortened. Only time actually spent
1832
+ * waiting is charged, and reports add up. Non-finite or negative values are ignored, so one bad
1833
+ * report can't turn every later drain into a 0 ms wait.
1431
1834
  */
1432
- beginDrain() {
1835
+ beginDrain(spentMs = 0) {
1433
1836
  this.draining = true;
1837
+ if (Number.isFinite(spentMs) && spentMs > 0) {
1838
+ this.drainSpentMs += spentMs;
1839
+ for (const rearm of this.drainRearms)
1840
+ rearm();
1841
+ }
1434
1842
  }
1435
1843
  /** True once {@link beginDrain} (or {@link drain}) has been called. */
1436
1844
  isDraining() {
@@ -1454,15 +1862,40 @@ export class WorkflowEngine {
1454
1862
  * process is going away, so a held lease would only delay the next pod's `recoverIncomplete` until
1455
1863
  * expiry; after release the next pod replays deterministically (a pending step re-dispatches the same
1456
1864
  * `stepId`, idempotent by the durable contract; a completed step short-circuits).
1865
+ *
1866
+ * `timeoutMs` is the TOTAL shutdown budget: wait already reported through {@link beginDrain}
1867
+ * (`spentMs`) is deducted, so a worker that first waited for its in-flight tick drains only for
1868
+ * what is left. With nothing reported, the full `timeoutMs` applies.
1457
1869
  */
1458
1870
  async drain(timeoutMs = 10_000) {
1459
1871
  this.beginDrain();
1460
1872
  if (this.inflight.size === 0 && this.postSettle.size === 0)
1461
1873
  return;
1874
+ // The deadline is `start + timeoutMs - spent`, re-read whenever `beginDrain(spentMs)` reports more
1875
+ // wait — so a shutdown that already waited for an in-flight tick only drains for the remainder.
1876
+ const start = Date.now();
1877
+ let timeout;
1878
+ let rearm = () => { };
1462
1879
  const timer = new Promise((resolve) => {
1463
- const t = setTimeout(() => resolve('timeout'), timeoutMs);
1464
- t.unref?.();
1880
+ rearm = () => {
1881
+ clearTimeout(timeout);
1882
+ const remaining = Math.max(0, start + timeoutMs - this.drainSpentMs - Date.now());
1883
+ timeout = setTimeout(() => resolve('timeout'), remaining);
1884
+ timeout.unref?.();
1885
+ };
1886
+ rearm();
1465
1887
  });
1888
+ this.drainRearms.add(rearm);
1889
+ try {
1890
+ await this.drainUntil(timer);
1891
+ }
1892
+ finally {
1893
+ this.drainRearms.delete(rearm);
1894
+ clearTimeout(timeout);
1895
+ }
1896
+ }
1897
+ /** The {@link drain} wait loop, bounded by `timer`. */
1898
+ async drainUntil(timer) {
1466
1899
  // Loop, don't snapshot-once: settling an in-flight run fires post-settle effects (parent notify,
1467
1900
  // singleton wake) that can resume — and thus re-inflight — another run, which settles and fires
1468
1901
  // more effects. Wait until BOTH sets reach steady-state empty, or the shared timer wins. The
@@ -1576,18 +2009,38 @@ export class WorkflowEngine {
1576
2009
  // that's ~zero rows per tick, instead of fetching every running run and issuing one doomed
1577
2010
  // lock-probe UPDATE per row per worker per second. Falls back to the old full-list + in-process
1578
2011
  // filter for a custom store without the optimized method.
2012
+ //
2013
+ // Self-reclaim: for one lease window after boot, also ask for the runs still leased to THIS
2014
+ // instance id. Under a stable id (a pod name), a SIGKILLed/OOM-killed predecessor's leases read
2015
+ // as "held by me" and would otherwise sit out their full `leaseMs`. After that window no such
2016
+ // lease can still be live, so the extra predicate is dropped and the query is the orphan-only one.
1579
2017
  const incomplete = await asHeartbeat(async () => {
1580
2018
  const nowMs = this.clock();
2019
+ const owner = nowMs < this.bootedAt + this.leaseMs ? this.instanceId : undefined;
1581
2020
  if (this.store.listOrphanedRuns) {
1582
- return this.store.listOrphanedRuns(nowMs, 100, this.namespace);
2021
+ return owner === undefined
2022
+ ? this.store.listOrphanedRuns(nowMs, 100, this.namespace)
2023
+ : this.store.listOrphanedRuns(nowMs, 100, this.namespace, owner);
1583
2024
  }
1584
- return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined || r.lockedUntil <= nowMs);
2025
+ return (await this.store.listIncompleteRuns(this.namespace)).filter((r) => r.lockedUntil === undefined ||
2026
+ r.lockedUntil <= nowMs ||
2027
+ (owner !== undefined && r.lockedBy === owner));
1585
2028
  });
1586
2029
  for (const listed of incomplete) {
1587
2030
  if (this.draining)
1588
2031
  break; // stop requested mid-sweep
1589
- // A live worker renews its lease, so an acquirable lease means the run is genuinely orphaned
1590
- // (its worker crashed). Skip the ones still owned.
2032
+ // A run still leased to this instance id: reclaimable only when a PREVIOUS process under the
2033
+ // same id left that lease behind — never one this process holds (see selfReclaimable). Give the
2034
+ // stale lease up so the normal orphan path below can take it; anything else is skipped.
2035
+ if (listed.lockedBy === this.instanceId &&
2036
+ listed.lockedUntil !== undefined &&
2037
+ listed.lockedUntil > nowMs &&
2038
+ !(await this.releaseStaleSelfLease(listed))) {
2039
+ continue;
2040
+ }
2041
+ // An IN-PROCESS turn renews its lease, so an acquirable lease means no turn is executing this
2042
+ // run right now. Skip the ones still owned. That is NOT the same as "its worker crashed" — see
2043
+ // the dispatched-step guard below for the case it doesn't cover.
1591
2044
  const acquired = await this.store.tryLockRun(listed.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1592
2045
  if (!acquired)
1593
2046
  continue;
@@ -1603,6 +2056,38 @@ export class WorkflowEngine {
1603
2056
  await this.store.releaseRunLock(listed.id, this.instanceId);
1604
2057
  continue;
1605
2058
  }
2059
+ // NOT AN ORPHAN: a run with an in-flight (`pending`) remote step is not executed in process at
2060
+ // all — its work sits on the transport, run by a worker that holds NO run lease, so the free
2061
+ // lease proves nothing about it. By contract such a run is durably SUSPENDED (see
2062
+ // `StepCheckpoint.status`); reading `running` means a turn ended without restoring that. Counting
2063
+ // it as a crash would burn the poison-pill budget on a healthy run and, past
2064
+ // `maxRecoveryAttempts`, dead-letter it while its worker is still executing the step. Re-assert
2065
+ // the contract instead: park it `suspended` on the step's deadline or the reconcile timer. The
2066
+ // worker's result resumes it; `resumeDueTimers` is the safety net. A LOST dispatch stays the job
2067
+ // of `remoteRedispatchMs` / `redispatchPending`.
2068
+ const dispatched = await this.inFlightRemoteStep(run.id);
2069
+ if (dispatched) {
2070
+ // A REMOTE workflow reads `running` only while a turn is being computed or applied, so a free
2071
+ // lease here means that turn was lost — the call in flight is not what the run waits on next.
2072
+ // Park it on a near wake so the timer pass re-drives the turn promptly (never later than the
2073
+ // call's own deadline) instead of after the whole reconcile window.
2074
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
2075
+ this.latest.get(run.workflow);
2076
+ const stepWake = this.reconcileWakeAt(dispatched.wakeAt);
2077
+ const wakeAt = registered?.remote
2078
+ ? Math.min(stepWake ?? Number.POSITIVE_INFINITY, this.clock() + REMOTE_TURN_REDRIVE_MS)
2079
+ : stepWake;
2080
+ // Conditional on still-`running`: a lease-free `cancel()` may have settled it meanwhile.
2081
+ const parked = await this.store.updateRunIf(run.id, ['running'], {
2082
+ status: 'suspended',
2083
+ wakeAt,
2084
+ updatedAt: new Date(),
2085
+ });
2086
+ await this.store.releaseRunLock(run.id, this.instanceId);
2087
+ if (parked)
2088
+ results.push({ runId: run.id, status: 'suspended' });
2089
+ continue;
2090
+ }
1606
2091
  // Count the attempt / dead-letter a poison pill past maxRecoveryAttempts.
1607
2092
  const settled = await this.countRecovery(run);
1608
2093
  // `null`: the run left `running` between the re-read above and the dead-letter write (a
@@ -1634,6 +2119,55 @@ export class WorkflowEngine {
1634
2119
  }
1635
2120
  return results;
1636
2121
  }
2122
+ /**
2123
+ * Whether a lease held under this engine's own {@link instanceId} was left behind by a previous
2124
+ * process that ran under the same id, rather than held by this one. Both must hold:
2125
+ * - nothing in this process executes the run: no execution in its run gate and no execution lease
2126
+ * tracked for it;
2127
+ * - the lease was written before this engine booted: every lease this process writes (acquire or
2128
+ * renew) ends at `clock() + leaseMs >= bootedAt + leaseMs`, so an earlier end proves an earlier
2129
+ * writer. This also covers the brief windows where a path of this process holds the lease
2130
+ * without having entered the run gate yet (a sweep between `tryLockRun` and the resume).
2131
+ *
2132
+ * Sound only when no two LIVE processes share an instance id — the documented requirement.
2133
+ */
2134
+ selfReclaimable(run, gated = this.runGates.has(run.id)) {
2135
+ if (run.lockedBy !== this.instanceId || run.lockedUntil === undefined)
2136
+ return false;
2137
+ if (gated || this.executionLeases.has(run.id))
2138
+ return false;
2139
+ return run.lockedUntil - this.leaseMs < this.bootedAt;
2140
+ }
2141
+ /**
2142
+ * Release a lease this instance's previous incarnation left on `listed` (see {@link selfReclaimable}),
2143
+ * so the recovery sweep can take the run like any orphan. The release runs inside the run's
2144
+ * in-process gate, so a resume landing meanwhile here queues behind it instead of executing on the
2145
+ * lease being given up; the run is re-read there and only released if it is still a stale `running`
2146
+ * self-lease. Returns whether the lease was released.
2147
+ */
2148
+ async releaseStaleSelfLease(listed) {
2149
+ if (!this.selfReclaimable(listed))
2150
+ return false;
2151
+ let released = false;
2152
+ await this.serializeOnRun(listed.id, async () => {
2153
+ const run = await this.store.getRun(listed.id);
2154
+ // The gate is ours now (serializeOnRun registered it before any other resume could queue).
2155
+ if (run?.status !== 'running' || !this.selfReclaimable(run, false))
2156
+ return;
2157
+ await this.store.releaseRunLock(run.id, this.instanceId);
2158
+ released = true;
2159
+ });
2160
+ return released;
2161
+ }
2162
+ /**
2163
+ * The remote step this run dispatched and still awaits a result for, if any — the durable marker
2164
+ * that tells {@link recoverIncomplete} "this run's work is on the transport, not in a crashed
2165
+ * process". A `pending` remote checkpoint is written before the dispatch and settled by the result.
2166
+ */
2167
+ async inFlightRemoteStep(runId) {
2168
+ const checkpoints = await this.store.listCheckpoints(runId);
2169
+ return checkpoints.find((cp) => cp.kind === 'remote' && cp.status === 'pending');
2170
+ }
1637
2171
  /**
1638
2172
  * Per-recovery bookkeeping (called once the lease is held): count the attempt, or — past
1639
2173
  * `maxRecoveryAttempts` — move a poison pill to the `dead` dead-letter state. Returns a terminal
@@ -1666,6 +2200,9 @@ export class WorkflowEngine {
1666
2200
  error,
1667
2201
  });
1668
2202
  this.notifyDead({ ...run, status: 'dead', error, recoveryAttempts: attempts });
2203
+ // `dead` is terminal like `failed`: a parent awaiting this run as a child (ctx.child, a ctx.all /
2204
+ // allSettled fan) must learn it now, as a failed child, not on its next reconcile wake.
2205
+ this.notifyParent(run.id, failedCompletion(error));
1669
2206
  return { runId: run.id, status: 'dead', error };
1670
2207
  }
1671
2208
  // Count BEFORE resuming, so a crash mid-resume still advances the counter.
@@ -1743,7 +2280,9 @@ export class WorkflowEngine {
1743
2280
  // live `running` placeholder with its `child:<id>` waiter put back — a separately retried
1744
2281
  // child's completion (already buffered, or still to come) then resumes this run.
1745
2282
  // Retry child and parent in either order; signal buffering makes it converge.
1746
- if (run.status === 'failed' || run.status === 'dead') {
2283
+ // - the run-level `recoveryAttempts` budget goes back to zero (see the final `updateRun`).
2284
+ const resurrecting = run.status === 'failed' || run.status === 'dead';
2285
+ if (resurrecting) {
1747
2286
  const isFailureCompletion = (v) => typeof v === 'object' && v !== null && v.ok === false;
1748
2287
  for (const cp of await this.store.listCheckpoints(runId)) {
1749
2288
  // A retry runs the body again, so a failure it handed to an onError handler is retired: kept in
@@ -1827,9 +2366,27 @@ export class WorkflowEngine {
1827
2366
  await this.store.releaseRunLock(runId);
1828
2367
  // Explicit `error: undefined` CLEARS the stale failure (the store spreads the patch over the
1829
2368
  // run) — otherwise the dashboard keeps showing the old error while the run is re-executing.
2369
+ //
2370
+ // RESURRECTING a `failed`/`dead` run also resets `recoveryAttempts`, or the retry is a lie: a run
2371
+ // dead-lettered AT `maxRecoveryAttempts` comes back `pending`, and the next `recoverIncomplete`
2372
+ // pass that finds it orphaned computes `cap + 1` and kills it again with the same
2373
+ // `max_recovery_attempts` error. The budget bounds ONE crash-loop episode; resurrecting a run that
2374
+ // came to rest starts a new one. `0`, not `undefined`, so every store writes a real value.
2375
+ // Gated on `resurrecting`: requeueing a still-live run is not a resurrection, and zeroing there
2376
+ // would let a retry loop keep a crash-looping run alive forever.
2377
+ //
2378
+ // It also drops a stale singleton admission marker (a terminal settle clears it; this covers a
2379
+ // row written before that), so the resurrected run competes for a slot like any waiter.
2380
+ //
2381
+ // And it clears a remote workflow turn's decision marker (`awaitingDecisionTaskId`): the requeued
2382
+ // run dispatches a FRESH turn, so a late decision of the old one must find no marker to match.
2383
+ const staleAdmission = resurrecting ? withoutAdmission(run.tags) : undefined;
1830
2384
  await this.store.updateRun(runId, {
1831
2385
  status: 'pending',
1832
2386
  error: undefined,
2387
+ awaitingDecisionTaskId: undefined,
2388
+ ...(resurrecting ? { recoveryAttempts: 0 } : {}),
2389
+ ...(staleAdmission ? { tags: staleAdmission } : {}),
1833
2390
  updatedAt: new Date(),
1834
2391
  });
1835
2392
  await this.runDispatcher.dispatch(runId);
@@ -1839,8 +2396,9 @@ export class WorkflowEngine {
1839
2396
  * Explicitly re-dispatch every remote step of `runId` stuck `pending` — the OPERATOR escape hatch for
1840
2397
  * a run whose dispatched step job was LOST (worker crashed with no result, or a transport that dropped
1841
2398
  * the job). No automatic recovery re-drives these: a reconcile re-drive re-suspends a still-pending
1842
- * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` only reclaims LEASED runs,
1843
- * and `requeue` just replays back to the pending guard. This re-enqueues the same `stepId` so the
2399
+ * step by design (unless `remoteRedispatchMs` is set), `recoverIncomplete` parks a run with an
2400
+ * in-flight `pending` remote step back to `suspended` instead of re-driving it, and `requeue` just
2401
+ * replays back to the pending guard. This re-enqueues the same `stepId` so the
1844
2402
  * (idempotent) step re-runs and its result resumes the run. Safe to call on a healthy run — it re-runs
1845
2403
  * only in-flight `pending` remote steps, which are idempotent by the durable contract. Returns the
1846
2404
  * run's current status (with the count re-dispatched), or null if the run is unknown.
@@ -1916,8 +2474,13 @@ export class WorkflowEngine {
1916
2474
  */
1917
2475
  waitForRun(runId, opts) {
1918
2476
  const isTerminal = (s) => s === 'completed' || s === 'failed' || s === 'cancelled' || s === 'dead';
1919
- const isSettled = (s) => s !== 'pending' && s !== 'running';
1920
- const shouldResolve = opts?.terminal ? isTerminal : isSettled;
2477
+ // A run `suspended` while it awaits a dispatched remote workflow turn's decision
2478
+ // (`awaitingDecisionTaskId` set) is mid-turn, not settled: that suspend is the engine's re-drive
2479
+ // marker, not a sleep/signal the body asked for. Keep waiting until the decision lands.
2480
+ const isSettled = (run) => run.status !== 'pending' &&
2481
+ run.status !== 'running' &&
2482
+ !(run.status === 'suspended' && run.awaitingDecisionTaskId !== undefined);
2483
+ const shouldResolve = (run) => opts?.terminal ? isTerminal(run.status) : isSettled(run);
1921
2484
  const toResult = (run) => ({
1922
2485
  runId,
1923
2486
  status: run.status,
@@ -1951,7 +2514,7 @@ export class WorkflowEngine {
1951
2514
  void this.store
1952
2515
  .getRun(runId)
1953
2516
  .then((run) => {
1954
- if (run && shouldResolve(run.status))
2517
+ if (run && shouldResolve(run))
1955
2518
  finish(run);
1956
2519
  })
1957
2520
  .catch(() => undefined);
@@ -1970,6 +2533,12 @@ export class WorkflowEngine {
1970
2533
  /**
1971
2534
  * Resume each run only if this instance can acquire its recovery lease — so when several
1972
2535
  * replicas recover or poll at once, each run is picked up by exactly one of them.
2536
+ *
2537
+ * A run this instance cannot resume is SKIPPED (and logged), never allowed to abort the batch: a
2538
+ * leased batch is a mixed bag during a rolling deploy (a pod without a run's workflow throws for
2539
+ * it by design) and a store hiccup can fail one row, but the rest of the batch is runs it can drive
2540
+ * — and every caller is a recovery/poll sweep. The skipped run stays locked only until its lease
2541
+ * expires, then another instance (or a later tick) picks it up.
1973
2542
  */
1974
2543
  async resumeLeased(runs, nowMs = this.clock(), onLocked) {
1975
2544
  if (this.draining)
@@ -1992,9 +2561,26 @@ export class WorkflowEngine {
1992
2561
  const acquired = await this.store.tryLockRun(run.id, this.instanceId, nowMs + this.leaseMs, nowMs);
1993
2562
  if (!acquired)
1994
2563
  continue;
1995
- // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
1996
- const settled = onLocked ? await onLocked(run) : undefined;
1997
- results.push(settled ?? (await this.resumeImplicit(run.id)));
2564
+ if (this.draining) {
2565
+ // Drain began while the lock was being taken. Starting the run now would hand `drain()` work
2566
+ // it may already have stopped waiting for, and an exit would strand the lease until expiry.
2567
+ // Give the lease back; the run stays for the next instance.
2568
+ await this.store.releaseRunLock(run.id, this.instanceId).catch(() => undefined);
2569
+ return;
2570
+ }
2571
+ try {
2572
+ // A per-run hook (recovery counting / dead-lettering) may settle the run terminally instead.
2573
+ const settled = onLocked ? await onLocked(run) : undefined;
2574
+ results.push(settled ?? (await this.resumeImplicit(run.id)));
2575
+ }
2576
+ catch (error) {
2577
+ // A foreign-namespace run is simply not ours (resume released its lease) — skip quietly.
2578
+ if (error instanceof NamespaceMismatch)
2579
+ continue;
2580
+ // Loud: a skip is not nothing — the run is still there and not progressing, and the reason
2581
+ // (a version skew, a store outage) is what tells an operator which.
2582
+ console.error(`[adonis-durable] could not resume run ${run.id} (${run.workflow}@${run.workflowVersion}); leaving it for another instance or a later tick:`, error);
2583
+ }
1998
2584
  }
1999
2585
  };
2000
2586
  await Promise.all(Array.from({ length: Math.min(CONCURRENCY, runs.length) }, () => worker()));
@@ -2190,6 +2776,16 @@ export class WorkflowEngine {
2190
2776
  }
2191
2777
  return this.deliverSignal(lateWaiter, reclaimed.payload);
2192
2778
  }
2779
+ /**
2780
+ * Wake the run parked on `token` with `payload`, if there is one; otherwise do nothing — unlike
2781
+ * {@link signal}, it never buffers. For a wake-up whose real payload is ALREADY buffered on the
2782
+ * token (an entity op, see `Entities.kick`): a run that isn't parked reads that buffer at its next
2783
+ * wait, so there is nothing to keep.
2784
+ */
2785
+ async wakeSignalWaiter(token, payload) {
2786
+ const waiter = await this.store.takeSignalWaiter(token);
2787
+ return waiter ? this.deliverSignal(waiter, payload) : null;
2788
+ }
2193
2789
  /**
2194
2790
  * Ensure a run exists for `runId`, then deliver a signal to it — atomically race-free thanks to
2195
2791
  * signal buffering: if the run is new (or busy / not yet waiting), the signal is buffered and
@@ -2337,7 +2933,13 @@ export class WorkflowEngine {
2337
2933
  return { runId, status: run.status };
2338
2934
  }
2339
2935
  const error = { message: 'cancelled' };
2340
- await this.store.updateRun(runId, { status: 'cancelled', error, updatedAt: new Date() });
2936
+ // Clears a remote turn's decision marker too: a cancelled run awaits no decision.
2937
+ await this.store.updateRun(runId, {
2938
+ status: 'cancelled',
2939
+ error,
2940
+ awaitingDecisionTaskId: undefined,
2941
+ updatedAt: new Date(),
2942
+ });
2341
2943
  this.emit({
2342
2944
  type: 'run.failed',
2343
2945
  runId,
@@ -2444,6 +3046,38 @@ export class WorkflowEngine {
2444
3046
  countRuns(query) {
2445
3047
  return countRuns(this.store, query);
2446
3048
  }
3049
+ /**
3050
+ * Every workflow the live fleet can execute in this engine's partition: what workers DECLARE in
3051
+ * their descriptors, plus the live routing tokens no descriptor explains (a worker too old to
3052
+ * describe itself). Each entry says which kind of evidence it rests on. See
3053
+ * {@link workflowDirectory} for why the list is the length it is.
3054
+ */
3055
+ async announcedWorkflows() {
3056
+ return (await this.workflowDirectory()).workflows;
3057
+ }
3058
+ /**
3059
+ * {@link announcedWorkflows}, plus why it is empty when it is: no transport here can read the fleet
3060
+ * (`supported: false`), nothing is live, or workers are live only on OTHER partitions
3061
+ * (`otherPartitions`), which otherwise reads exactly like an empty fleet. Two live reads, neither
3062
+ * retained: the descriptor advertisement and the heartbeat keyspace. Only the BullMQ transport reads
3063
+ * them today.
3064
+ */
3065
+ async workflowDirectory() {
3066
+ const supported = this.pool.introspectsFleet;
3067
+ const [descriptors, heartbeats] = supported
3068
+ ? await Promise.all([
3069
+ this.pool.readAllWorkerDescriptors(),
3070
+ this.pool.readAllWorkerHeartbeats(),
3071
+ ])
3072
+ : [[], []];
3073
+ return buildWorkflowDirectory({
3074
+ descriptors,
3075
+ heartbeats,
3076
+ namespace: this.namespace,
3077
+ supported,
3078
+ sanitize: sanitizeQueueToken,
3079
+ });
3080
+ }
2447
3081
  /** The worker groups this engine dispatches to: every registered remote workflow's group, plus any
2448
3082
  * `extra` the caller declares. Local-step groups (a group consumed by in-process `@DurableStep`
2449
3083
  * workers, e.g. `pipeline`) aren't derivable from registrations — pass them via `extra` so a group
@@ -2460,7 +3094,7 @@ export class WorkflowEngine {
2460
3094
  * (so a registered group with backlog and ZERO workers still reports — the alert case) UNION the
2461
3095
  * groups discovered from live heartbeats (so a local-step group like `pipeline`, not derivable
2462
3096
  * from registrations, shows once its workers beat). Empty when no transport can introspect health
2463
- * (only the BullMQ transport implements `groupHealth`). */
3097
+ * (only the BullMQ, queue and db transports implement `groupHealth`). */
2464
3098
  async workerHealth(extra = []) {
2465
3099
  const groups = new Set([...this.knownGroups(extra), ...(await this.pool.listWorkerGroups())]);
2466
3100
  // One round per group, in parallel — the dashboard polls this, and each group's health is an
@@ -2627,7 +3261,13 @@ export class WorkflowEngine {
2627
3261
  return { runId: run.id, status: run.status };
2628
3262
  }
2629
3263
  }
3264
+ const lease = { handedOff: false };
3265
+ this.executionLeases.set(run.id, lease);
2630
3266
  const renew = setInterval(() => {
3267
+ // A lease this execution gave away on purpose (see releaseTurnLease) is not renewed, and its
3268
+ // `false` renew is not a takeover.
3269
+ if (lease.handedOff)
3270
+ return;
2631
3271
  // A renew that returns FALSE means the lease was taken over (this turn stalled past expiry
2632
3272
  // and another instance reclaimed the run) — this executor is now a zombie. Fence it: mark
2633
3273
  // the run so its eventual settle degrades to a no-op echo instead of clobbering the new
@@ -2636,7 +3276,7 @@ export class WorkflowEngine {
2636
3276
  void this.store
2637
3277
  .renewRunLock(run.id, this.instanceId, this.clock() + this.leaseMs)
2638
3278
  .then((renewed) => {
2639
- if (!renewed)
3279
+ if (!renewed && !lease.handedOff)
2640
3280
  this.leaseLostRuns.add(run.id);
2641
3281
  })
2642
3282
  .catch(() => undefined);
@@ -2664,6 +3304,8 @@ export class WorkflowEngine {
2664
3304
  finally {
2665
3305
  clearInterval(renew);
2666
3306
  this.leaseLostRuns.delete(run.id);
3307
+ if (this.executionLeases.get(run.id) === lease)
3308
+ this.executionLeases.delete(run.id);
2667
3309
  }
2668
3310
  }
2669
3311
  /**
@@ -2749,7 +3391,7 @@ export class WorkflowEngine {
2749
3391
  async routeFailure(run, error) {
2750
3392
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion));
2751
3393
  const routes = registered?.convention
2752
- ? await this.announcedRoutes(run, registered.remote?.group ?? run.workflow, error)
3394
+ ? await this.announcedRoutes(run, conventionGroup(run.workflow, run.namespace), error)
2753
3395
  : registered?.onError;
2754
3396
  const route = routeFor(routes, error);
2755
3397
  if (!route)
@@ -2958,11 +3600,72 @@ export class WorkflowEngine {
2958
3600
  return wakeAt;
2959
3601
  return this.reconcileMs != null ? this.clock() + this.reconcileMs : undefined;
2960
3602
  }
3603
+ /**
3604
+ * The result of a turn the singleton gate denied. The store already parked the run `suspended` on
3605
+ * its retry timer INSIDE the admission claim (or left it alone, when a concurrent cancel had
3606
+ * settled it), so report what is persisted rather than writing over it.
3607
+ */
3608
+ /**
3609
+ * A run's first execution: `pending` -> `running`. A singleton run not yet admitted is stamped
3610
+ * {@link SINGLETON_TRACKED_TAG} in the same write, BEFORE it competes for a slot, so a run this
3611
+ * replica is about to admit or deny is never mistaken for a holder admitted by a replica older than
3612
+ * 0.51 (see `legacySingletonHolders`).
3613
+ */
3614
+ async markStarted(run, singleton) {
3615
+ const tags = singleton ? withSingletonTracked(run.tags) : run.tags;
3616
+ const retag = tags !== run.tags;
3617
+ await this.store.updateRun(run.id, {
3618
+ status: 'running',
3619
+ ...(retag ? { tags } : {}),
3620
+ updatedAt: new Date(),
3621
+ });
3622
+ run.status = 'running';
3623
+ if (retag)
3624
+ run.tags = tags;
3625
+ }
3626
+ async singletonWaitResult(run) {
3627
+ const current = await this.store.getRun(run.id);
3628
+ await this.store.releaseRunLock(run.id, this.instanceId);
3629
+ if (!current)
3630
+ throw new Error(`run ${run.id} was deleted during singleton admission`);
3631
+ if (current.status === 'suspended') {
3632
+ this.emit({
3633
+ type: 'run.suspended',
3634
+ runId: current.id,
3635
+ workflow: current.workflow,
3636
+ namespace: current.namespace,
3637
+ });
3638
+ }
3639
+ return {
3640
+ runId: current.id,
3641
+ status: current.status,
3642
+ output: current.output,
3643
+ error: current.error,
3644
+ };
3645
+ }
2961
3646
  async runRemoteExecution(run, registered) {
2962
3647
  const remote = registered.remote;
3648
+ // A compensating cancel (`cancel(id, { compensate: true })`) resumes the run to unwind it. A remote
3649
+ // workflow registers no engine-side saga compensations, so the unwind is just the `cancelled`
3650
+ // settle — and it must happen even while a dispatched turn is still out (the hold below would
3651
+ // otherwise swallow the cancel's resume, and the in-flight turn's decision would carry the run on
3652
+ // as if it was never cancelled). The late decision then finds the run cancelled and is dropped.
3653
+ if (await this.takeCancelIntent(run.id))
3654
+ return this.compensateAndCancel(run, []);
3655
+ // A dispatched turn is still out and within its liveness window: whatever woke the run (a step
3656
+ // result, a signal, a wait recheck) must not dispatch a second turn over it — that would supersede
3657
+ // the first and drop its decision, re-running whatever local steps that turn ran. Everything such a
3658
+ // wake wrote is durable already; the in-flight turn's decision is applied as usual and its suspend
3659
+ // rechecks what it waits on (the `reconcileMs` wake is the fallback). Only a LAPSED window (the
3660
+ // timer poller, `wakeAt` due) re-drives the turn below.
3661
+ if (run.status === 'suspended' &&
3662
+ run.awaitingDecisionTaskId !== undefined &&
3663
+ (run.wakeAt == null || run.wakeAt > this.clock())) {
3664
+ await this.releaseTurnLease(run.id);
3665
+ return { runId: run.id, status: 'suspended' };
3666
+ }
2963
3667
  if (run.status === 'pending') {
2964
- await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
2965
- run.status = 'running';
3668
+ await this.markStarted(run, registered?.singleton !== undefined);
2966
3669
  this.emit({
2967
3670
  type: 'run.started',
2968
3671
  runId: run.id,
@@ -2971,21 +3674,12 @@ export class WorkflowEngine {
2971
3674
  });
2972
3675
  }
2973
3676
  if (registered.singleton && !(await this.singletons.admit(run, registered.singleton))) {
2974
- const wakeAt = this.singletons.retryWakeAt();
2975
- await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
2976
- this.emit({
2977
- type: 'run.suspended',
2978
- runId: run.id,
2979
- workflow: run.workflow,
2980
- namespace: run.namespace,
2981
- });
2982
- await this.store.releaseRunLock(run.id);
2983
- return { runId: run.id, status: 'suspended' };
3677
+ return this.singletonWaitResult(run);
2984
3678
  }
2985
3679
  // Capability/protocol guard (design §7.5): a remote workflow's turn dispatches a WorkflowTask to
2986
3680
  // its group — if no live worker on that group can run it, park `blocked` before advancing rather
2987
3681
  // than handing the task to the executor to enqueue into a void. Re-checked each turn (recovery).
2988
- const routePlan = await this.planRoute(remote.group, registered.requires);
3682
+ const routePlan = await this.planRoute(registered.convention ? conventionGroup(run.workflow, run.namespace) : remote.group, registered.requires);
2989
3683
  if (routePlan?.status === 'blocked')
2990
3684
  return this.parkBlocked(run, routePlan);
2991
3685
  const checkpoints = await this.store.listCheckpoints(run.id);
@@ -2995,11 +3689,39 @@ export class WorkflowEngine {
2995
3689
  return this.settleRun(run, await this.followHandoff(run, handoff));
2996
3690
  }
2997
3691
  const history = await this.remoteHistory(run.id, checkpoints);
3692
+ // Broker-backed executors: dispatch the turn and suspend on it. The decision is applied by
3693
+ // whichever replica consumes it (completeRemoteDecision), never awaited here.
3694
+ if (remote.executor.dispatch)
3695
+ return this.dispatchRemoteTurn(run, registered, history);
3696
+ // INLINE advance (an executor that produces the decision in-process): await it under the lease.
3697
+ const advance = remote.executor.advance;
3698
+ if (!advance) {
3699
+ throw new Error(`workflow ${run.workflow}@${run.workflowVersion}: its executor implements neither advance nor dispatch`);
3700
+ }
2998
3701
  let decision;
2999
3702
  try {
3000
- decision = await remote.executor.advance(run, history);
3703
+ const silenceMs = this.remoteAdvanceSilenceMs;
3704
+ const pending = advance.call(remote.executor, run, history);
3705
+ decision =
3706
+ silenceMs != null
3707
+ ? await this.awaitTurnWithSilence(run.id, pending, silenceMs)
3708
+ : await pending;
3001
3709
  }
3002
3710
  catch (err) {
3711
+ // A timed-out advance is not a failure: the decision may have been lost while the work completed.
3712
+ // Failing the run would notify its parent of a failure that never happened. Leave it `running`
3713
+ // with its lease released, so crash recovery re-drives it (replaying completed steps; bounded by
3714
+ // `maxRecoveryAttempts`).
3715
+ if (err instanceof RemoteWorkflowTimeout) {
3716
+ await this.store
3717
+ .updateRunIf(run.id, ['running', 'suspended'], {
3718
+ status: 'running',
3719
+ updatedAt: new Date(),
3720
+ })
3721
+ .catch(() => false);
3722
+ await this.releaseTurnLease(run.id);
3723
+ return { runId: run.id, status: 'running' };
3724
+ }
3003
3725
  const error = {
3004
3726
  message: err instanceof Error ? err.message : String(err),
3005
3727
  stack: err instanceof Error ? err.stack : undefined,
@@ -3014,6 +3736,16 @@ export class WorkflowEngine {
3014
3736
  });
3015
3737
  return { runId: run.id, status: 'failed', error };
3016
3738
  }
3739
+ return this.applyDecision(run, decision);
3740
+ }
3741
+ /**
3742
+ * Apply a remote workflow turn's {@link WorkflowDecision}: persist the local steps it ran, then settle
3743
+ * (completed/failed/cancelled) or dispatch its blocking ops and suspend (continue). Shared by the
3744
+ * INLINE advance path ({@link runRemoteExecution}, under the execution's lease) and the dispatched
3745
+ * path ({@link completeRemoteDecision}, which holds the lease itself and has already checked that
3746
+ * this is the decision of the turn the run awaits).
3747
+ */
3748
+ async applyDecision(run, decision) {
3017
3749
  if (decision.status === 'completed') {
3018
3750
  // Persist the local steps THIS turn ran before marking the run done. A workflow that runs
3019
3751
  // straight to completion in a single turn (every step inline, never suspending — e.g. a Python
@@ -3058,6 +3790,327 @@ export class WorkflowEngine {
3058
3790
  }
3059
3791
  return suspended;
3060
3792
  }
3793
+ /**
3794
+ * The dispatched-turn path of {@link runRemoteExecution}: SUSPEND, then ENQUEUE. The engine mints a
3795
+ * globally unique turn `taskId`, records it on the run as the awaited marker, and releases the lease,
3796
+ * all BEFORE the turn is enqueued, so a worker's decision, however fast and on whichever replica it
3797
+ * lands, always finds both its marker and a free lease (see {@link completeRemoteDecision}). The
3798
+ * deadline is the turn's liveness window ({@link decisionWindowMs}); when it lapses with no decision
3799
+ * and no run-scoped heartbeat, the timer poller re-drives the run, which dispatches a fresh turn.
3800
+ */
3801
+ async dispatchRemoteTurn(run, registered, history) {
3802
+ const executor = registered.remote.executor;
3803
+ const dispatch = executor.dispatch;
3804
+ // Zombie fence, as settleRun's: a lost lease means another instance owns the run now.
3805
+ if (this.leaseLostRuns.has(run.id))
3806
+ return this.echoCurrentStatus(run.id);
3807
+ // Random, not a per-process counter: two replicas re-driving the same run must never mint the same
3808
+ // id (the `db` transport dedupes a turn by it, and the marker gates the decision on it).
3809
+ const taskId = `${run.id}:wf:${globalThis.crypto.randomUUID()}`;
3810
+ const windowMs = this.decisionWindowMs(registered);
3811
+ const marked = await this.store.updateRunIf(run.id, SETTLE_ELIGIBLE_STATUSES, {
3812
+ status: 'suspended',
3813
+ awaitingDecisionTaskId: taskId,
3814
+ wakeAt: windowMs != null ? this.clock() + Math.min(windowMs, MAX_BACKOFF_MS) : undefined,
3815
+ updatedAt: new Date(),
3816
+ });
3817
+ if (!marked) {
3818
+ // Cancelled / completed / dead while this turn was being prepared: nothing to dispatch.
3819
+ await this.releaseTurnLease(run.id);
3820
+ return this.echoCurrentStatus(run.id);
3821
+ }
3822
+ this.emit({
3823
+ type: 'run.suspended',
3824
+ runId: run.id,
3825
+ workflow: run.workflow,
3826
+ namespace: run.namespace,
3827
+ });
3828
+ await this.releaseTurnLease(run.id);
3829
+ try {
3830
+ await turnDispatchAls.run(run.id, () => dispatch.call(executor, run, history, taskId));
3831
+ }
3832
+ catch (err) {
3833
+ return this.failUndispatchedTurn(run, taskId, err);
3834
+ }
3835
+ return { runId: run.id, status: 'suspended' };
3836
+ }
3837
+ /**
3838
+ * The turn could not be enqueued (the transport cannot carry workflow turns, or the broker refused the
3839
+ * write): fail the run, as an advance that throws does — but only while it still awaits THIS turn, so
3840
+ * a cancel or a re-drive that landed meanwhile wins.
3841
+ */
3842
+ async failUndispatchedTurn(run, taskId, err) {
3843
+ const error = {
3844
+ message: err instanceof Error ? err.message : String(err),
3845
+ stack: err instanceof Error ? err.stack : undefined,
3846
+ };
3847
+ const current = await this.store.getRun(run.id);
3848
+ if (current?.awaitingDecisionTaskId !== taskId)
3849
+ return this.echoCurrentStatus(run.id);
3850
+ const failed = await this.store.updateRunIf(run.id, ['suspended'], {
3851
+ status: 'failed',
3852
+ error,
3853
+ awaitingDecisionTaskId: undefined,
3854
+ wakeAt: undefined,
3855
+ updatedAt: new Date(),
3856
+ });
3857
+ if (!failed)
3858
+ return this.echoCurrentStatus(run.id);
3859
+ this.emit({
3860
+ type: 'run.failed',
3861
+ runId: run.id,
3862
+ workflow: run.workflow,
3863
+ namespace: run.namespace,
3864
+ error,
3865
+ });
3866
+ return { runId: run.id, status: 'failed', error };
3867
+ }
3868
+ /**
3869
+ * Give the run lease away mid-execution on purpose: stop the execution's renew loop from renewing it
3870
+ * (or reading the release as a takeover), then release it. Owner-scoped, so a lease another instance
3871
+ * already holds is left alone.
3872
+ */
3873
+ async releaseTurnLease(runId) {
3874
+ const lease = this.executionLeases.get(runId);
3875
+ if (lease)
3876
+ lease.handedOff = true;
3877
+ await this.store.releaseRunLock(runId, this.instanceId);
3878
+ }
3879
+ /**
3880
+ * The liveness window of a dispatched turn: the engine's `remoteAdvanceSilenceMs` and the executor's
3881
+ * own `decisionTimeoutMs` (a `RemoteWorkflowExecutor`'s `timeoutMs`), the shorter of the two when both
3882
+ * are set. `undefined` = no deadline: the run waits for its decision.
3883
+ */
3884
+ decisionWindowMs(registered) {
3885
+ const own = registered?.remote?.executor.decisionTimeoutMs || undefined;
3886
+ const silence = this.remoteAdvanceSilenceMs;
3887
+ if (own != null && silence != null)
3888
+ return Math.min(own, silence);
3889
+ return own ?? silence;
3890
+ }
3891
+ /**
3892
+ * Await an INLINE advance, rejecting with {@link RemoteWorkflowTimeout} once `silenceMs` passes with
3893
+ * neither a decision nor a run-scoped heartbeat for `runId`. Each beat rearms the window. The beat
3894
+ * hook is registered before this returns, so a beat can't slip in unseen.
3895
+ */
3896
+ awaitTurnWithSilence(runId, advance, silenceMs) {
3897
+ const key = turnBeatKey(runId);
3898
+ const windowMs = Math.min(silenceMs, MAX_BACKOFF_MS);
3899
+ return new Promise((resolve, reject) => {
3900
+ let timer;
3901
+ const cleanup = () => {
3902
+ clearTimeout(timer);
3903
+ if (this.heartbeatResets.get(key) === reset)
3904
+ this.heartbeatResets.delete(key);
3905
+ };
3906
+ const arm = () => {
3907
+ timer = setTimeout(() => {
3908
+ cleanup();
3909
+ reject(new RemoteWorkflowTimeout(runId, silenceMs));
3910
+ }, windowMs);
3911
+ timer.unref?.();
3912
+ };
3913
+ const reset = () => {
3914
+ clearTimeout(timer);
3915
+ arm();
3916
+ };
3917
+ this.heartbeatResets.set(key, reset);
3918
+ arm();
3919
+ advance.then((decision) => {
3920
+ cleanup();
3921
+ resolve(decision);
3922
+ }, (err) => {
3923
+ cleanup();
3924
+ reject(err);
3925
+ });
3926
+ });
3927
+ }
3928
+ /** Whether `run` is awaiting exactly `decision`'s turn (and so may still apply it). */
3929
+ awaitsDecision(run, decision) {
3930
+ return (run !== null &&
3931
+ run.status === 'suspended' &&
3932
+ run.awaitingDecisionTaskId !== undefined &&
3933
+ run.awaitingDecisionTaskId === decision.taskId);
3934
+ }
3935
+ /**
3936
+ * Apply a remote workflow-turn {@link WorkflowDecision} durably, on WHICHEVER replica consumes it: the
3937
+ * dispatching replica may be gone, or a point-to-point decisions queue handed the decision to a
3938
+ * sibling. The durable twin of {@link completeRemoteResult} for a workflow TURN: look the run up by
3939
+ * `decision.runId`, check that it still awaits this `taskId` (a stale, duplicate, foreign or
3940
+ * superseded decision is dropped, so a decision applies at most once), lease it, and apply it.
3941
+ * Serialized with this replica's own executions of the run through the run gate.
3942
+ */
3943
+ async completeRemoteDecision(decision) {
3944
+ if (!decision?.runId || !decision.taskId)
3945
+ return;
3946
+ const run = await this.store.getRun(decision.runId);
3947
+ if (!this.awaitsDecision(run, decision))
3948
+ return;
3949
+ // A scoped engine leaves another pool's runs to that pool (an operator applies everyone's).
3950
+ if (this.namespace !== undefined &&
3951
+ run.namespace !== undefined &&
3952
+ run.namespace !== this.namespace) {
3953
+ return;
3954
+ }
3955
+ await this.serializeOnRun(decision.runId, () => this.applyRemoteDecision(decision));
3956
+ }
3957
+ /**
3958
+ * Run `fn` in the run's in-process execution gate (see {@link resumeRun}): after the execution in
3959
+ * flight here, before any resume that lands later. A resume already queued (not started) runs first.
3960
+ */
3961
+ serializeOnRun(runId, fn) {
3962
+ let gate = this.runGates.get(runId);
3963
+ const prior = gate ? gate.tail.catch(() => undefined) : Promise.resolve();
3964
+ const done = prior.then(fn);
3965
+ const tail = done.then(() => ({ runId, status: 'suspended' }), () => ({ runId, status: 'suspended' }));
3966
+ if (!gate) {
3967
+ gate = { tail };
3968
+ this.runGates.set(runId, gate);
3969
+ }
3970
+ else {
3971
+ gate.tail = tail;
3972
+ }
3973
+ this.closeGateAfter(runId, gate, tail);
3974
+ this.trackEffect(done);
3975
+ return done;
3976
+ }
3977
+ /** {@link completeRemoteDecision} inside the run gate: lease, re-check, claim the turn, apply. */
3978
+ async applyRemoteDecision(decision) {
3979
+ const runId = decision.runId;
3980
+ if (!(await this.leaseForDecision(decision)))
3981
+ return;
3982
+ const renew = setInterval(() => {
3983
+ void this.store
3984
+ .renewRunLock(runId, this.instanceId, this.clock() + this.leaseMs)
3985
+ .then((renewed) => {
3986
+ if (!renewed)
3987
+ this.leaseLostRuns.add(runId);
3988
+ })
3989
+ .catch(() => undefined);
3990
+ }, Math.max(50, Math.floor(this.leaseMs / 2)));
3991
+ renew.unref?.();
3992
+ try {
3993
+ const fresh = await this.store.getRun(runId);
3994
+ if (!this.awaitsDecision(fresh, decision))
3995
+ return;
3996
+ // Claim the turn: clear the marker and its deadline, and flip to `running` while the decision is
3997
+ // applied. Under the lease the marker cannot move (only a leased dispatch or re-drive rewrites
3998
+ // it); the status predicate loses to a lease-free cancel/requeue. If this instance dies
3999
+ // mid-apply, the run is a plain `running` orphan that crash recovery re-drives.
4000
+ const claimed = await this.store.updateRunIf(runId, ['suspended'], {
4001
+ status: 'running',
4002
+ awaitingDecisionTaskId: undefined,
4003
+ wakeAt: undefined,
4004
+ updatedAt: new Date(),
4005
+ });
4006
+ if (!claimed)
4007
+ return;
4008
+ this.decisionDeadlineRearms.delete(runId);
4009
+ const run = {
4010
+ ...fresh,
4011
+ status: 'running',
4012
+ awaitingDecisionTaskId: undefined,
4013
+ wakeAt: undefined,
4014
+ };
4015
+ const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
4016
+ this.latest.get(run.workflow);
4017
+ const result = await this.applyDecision(run, decision);
4018
+ // Notify-on-release, as execute does for a turn it settles.
4019
+ if (registered?.singleton &&
4020
+ (result.status === 'completed' ||
4021
+ result.status === 'failed' ||
4022
+ result.status === 'cancelled' ||
4023
+ result.status === 'dead')) {
4024
+ this.trackEffect(this.singletons.wakeNext(run));
4025
+ }
4026
+ }
4027
+ finally {
4028
+ clearInterval(renew);
4029
+ this.leaseLostRuns.delete(runId);
4030
+ await this.store.releaseRunLock(runId, this.instanceId).catch(() => undefined);
4031
+ }
4032
+ }
4033
+ /**
4034
+ * Acquire the run lease to apply `decision`. The dispatcher released it before enqueueing, so this
4035
+ * normally succeeds at once; another replica may still hold it briefly (its wake found the turn in
4036
+ * flight and is releasing), so retry until the lease frees, the run stops awaiting this turn, or one
4037
+ * lease window passes (a crashed holder's lease expires within it). A lease THIS instance holds is
4038
+ * reused: we are inside the run gate, so no execution of it is running here.
4039
+ */
4040
+ async leaseForDecision(decision) {
4041
+ const runId = decision.runId;
4042
+ // Real time, not `this.clock`: a test clock may stand still.
4043
+ const giveUpAt = Date.now() + this.leaseMs + 1_000;
4044
+ let delayMs = 10;
4045
+ for (;;) {
4046
+ const run = await this.store.getRun(runId);
4047
+ if (!this.awaitsDecision(run, decision))
4048
+ return false;
4049
+ const now = this.clock();
4050
+ if (run.lockedBy === this.instanceId && (run.lockedUntil ?? 0) > now)
4051
+ return true;
4052
+ if (await this.store.tryLockRun(runId, this.instanceId, now + this.leaseMs, now))
4053
+ return true;
4054
+ if (Date.now() >= giveUpAt) {
4055
+ console.warn(`[adonis-durable] could not lease run ${runId} to apply the decision of turn ${decision.taskId}; the turn's liveness window re-drives it`);
4056
+ return false;
4057
+ }
4058
+ await new Promise((r) => setTimeout(r, delayMs));
4059
+ delayMs = Math.min(delayMs * 2, 250);
4060
+ }
4061
+ }
4062
+ /**
4063
+ * A run-scoped heartbeat for a run awaiting a dispatched turn's decision: push its re-drive deadline
4064
+ * (`wakeAt`) forward so a worker still replaying the turn is not re-driven. The durable counterpart
4065
+ * of an inline advance's in-memory rearm, and the run-scoped sibling of {@link rearmStepLease}.
4066
+ *
4067
+ * Throttled the same way: a renewal buys at most one window, so it is only worth a write once the
4068
+ * window is half spent. A beat that finds more than half of it left writes nothing and records the
4069
+ * half-spent mark, so later beats skip even the read until then. That bounds the cost at one read and
4070
+ * one write per half window per awaited turn, whatever the beat cadence. The mark is in memory only
4071
+ * (cold on another replica, which re-derives the same answer from the run's `wakeAt`). Best-effort:
4072
+ * it runs inside the transport's serial beat handler, so a store error is swallowed, and the mark only
4073
+ * moves after a write commits, so the next beat retries.
4074
+ */
4075
+ async rearmDecisionDeadline(runId) {
4076
+ // No window anywhere: nothing ever re-drives a turn, so there is no deadline to defend.
4077
+ if (!runId || (this.remoteAdvanceSilenceMs == null && !this.anyDecisionTimeout))
4078
+ return;
4079
+ const now = this.clock();
4080
+ const lookAgainAt = this.decisionDeadlineRearms.get(runId);
4081
+ if (lookAgainAt !== undefined && now < lookAgainAt)
4082
+ return;
4083
+ try {
4084
+ const run = await this.store.getRun(runId);
4085
+ if (run?.status !== 'suspended' || run.awaitingDecisionTaskId === undefined) {
4086
+ this.decisionDeadlineRearms.delete(runId);
4087
+ return;
4088
+ }
4089
+ const windowMs = this.decisionWindowMs(this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
4090
+ this.latest.get(run.workflow));
4091
+ if (windowMs == null)
4092
+ return;
4093
+ const halfWindow = Math.ceil(windowMs / 2);
4094
+ if (run.wakeAt != null && run.wakeAt > now + halfWindow) {
4095
+ this.decisionDeadlineRearms.set(runId, run.wakeAt - halfWindow);
4096
+ return;
4097
+ }
4098
+ const renewed = now + Math.min(windowMs, MAX_BACKOFF_MS);
4099
+ // Only while the same turn is still awaited: a decision applied (or a re-drive) meanwhile wins.
4100
+ const applied = await this.store.updateRunIf(runId, ['suspended'], {
4101
+ wakeAt: renewed,
4102
+ updatedAt: new Date(),
4103
+ });
4104
+ if (!applied)
4105
+ return;
4106
+ if (this.decisionDeadlineRearms.size > 1024)
4107
+ this.decisionDeadlineRearms.clear();
4108
+ this.decisionDeadlineRearms.set(runId, renewed - halfWindow);
4109
+ }
4110
+ catch {
4111
+ // Best-effort liveness: a store blip costs one skipped renewal, never the beat loop.
4112
+ }
4113
+ }
3061
4114
  /**
3062
4115
  * A polyglot workflow's re-emitted `call` whose checkpoint already exists: decide whether the step is
3063
4116
  * still out for delivery, or whether its dispatch was LOST and has to be re-driven. Returns the
@@ -3531,14 +4584,70 @@ export class WorkflowEngine {
3531
4584
  }
3532
4585
  return wakeAt;
3533
4586
  }
4587
+ /**
4588
+ * Whether a compensating cancel is pending for this run, consuming the in-memory flag. The intent
4589
+ * lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same boot), and the
4590
+ * DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or a cross-pod handoff
4591
+ * mid-cancellation can't silently forget the cancel. The marker is peeked (take + re-buffer), not
4592
+ * consumed: it must survive a crash mid-unwind; {@link compensateAndCancel} drains it once
4593
+ * `cancelled` is durably recorded.
4594
+ */
4595
+ async takeCancelIntent(runId) {
4596
+ if (this.cancelRequested.delete(runId))
4597
+ return true;
4598
+ const marker = await this.store.takeBufferedSignal(`cancel:${runId}`);
4599
+ if (!marker)
4600
+ return false;
4601
+ await this.store.bufferSignal(`cancel:${runId}`, marker.payload).catch(() => undefined);
4602
+ return true;
4603
+ }
4604
+ /**
4605
+ * Undo the registered saga compensations in reverse and settle the run `cancelled`. Shared by EVERY
4606
+ * exit of a turn with a compensating cancel pending — the body returned, suspended, hit
4607
+ * continue-as-new or threw — so the cancel is never lost because the body didn't stop at a
4608
+ * suspension point.
4609
+ */
4610
+ async compensateAndCancel(run, compensations) {
4611
+ this.cancelRequested.delete(run.id);
4612
+ // Zombie fence (as in settleRun): another instance owns the run now; the durable marker is still
4613
+ // there for it, so this stale turn must neither undo nor write.
4614
+ if (this.leaseLostRuns.has(run.id))
4615
+ return this.echoCurrentStatus(run.id);
4616
+ for (let i = compensations.length - 1; i >= 0; i -= 1) {
4617
+ const comp = compensations[i];
4618
+ if (comp)
4619
+ await this.runCompensation(run, comp, i);
4620
+ }
4621
+ const error = { message: 'cancelled' };
4622
+ await this.store.updateRun(run.id, {
4623
+ status: 'cancelled',
4624
+ error,
4625
+ awaitingDecisionTaskId: undefined,
4626
+ updatedAt: new Date(),
4627
+ });
4628
+ // The cancellation is durably recorded — consume the marker(s) so a later retry of this run id
4629
+ // doesn't get spuriously re-cancelled.
4630
+ for (;;) {
4631
+ const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
4632
+ if (!m)
4633
+ break;
4634
+ }
4635
+ this.emit({
4636
+ type: 'run.failed',
4637
+ runId: run.id,
4638
+ workflow: run.workflow,
4639
+ namespace: run.namespace,
4640
+ error,
4641
+ });
4642
+ return { runId: run.id, status: 'cancelled', error };
4643
+ }
3534
4644
  /** The run body, lease held + renewed by {@link execute}. */
3535
4645
  async runExecution(run, fn, registered) {
3536
4646
  // First execution of an enqueued run: mark it running and announce the start, BEFORE the singleton
3537
- // gate — `singletons.admit` only counts `running`/`suspended` runs, so a still-`pending` run could
3538
- // never be admitted. A resumed run is already past `pending`, so this fires exactly once.
4647
+ // gate, so an admitted run's body never starts while it still reads `pending`. A resumed run is
4648
+ // already past `pending`, so this fires exactly once.
3539
4649
  if (run.status === 'pending') {
3540
- await this.store.updateRun(run.id, { status: 'running', updatedAt: new Date() });
3541
- run.status = 'running';
4650
+ await this.markStarted(run, registered?.singleton !== undefined);
3542
4651
  this.emit({
3543
4652
  type: 'run.started',
3544
4653
  runId: run.id,
@@ -3549,16 +4658,7 @@ export class WorkflowEngine {
3549
4658
  // Singleton admission gate: if this run shares its key with `limit` older in-flight runs, wait
3550
4659
  // (suspend on a short timer) until a slot frees instead of running now. Re-checked on each resume.
3551
4660
  if (registered?.singleton && !(await this.singletons.admit(run, registered.singleton))) {
3552
- const wakeAt = this.singletons.retryWakeAt();
3553
- await this.store.updateRun(run.id, { status: 'suspended', wakeAt, updatedAt: new Date() });
3554
- this.emit({
3555
- type: 'run.suspended',
3556
- runId: run.id,
3557
- workflow: run.workflow,
3558
- namespace: run.namespace,
3559
- });
3560
- await this.store.releaseRunLock(run.id, this.instanceId);
3561
- return { runId: run.id, status: 'suspended' };
4661
+ return this.singletonWaitResult(run);
3562
4662
  }
3563
4663
  // Saga compensations registered by completed steps; run in reverse if the run later fails.
3564
4664
  const compensations = [];
@@ -3590,6 +4690,11 @@ export class WorkflowEngine {
3590
4690
  // (`BaseWorkflow.start`/`dispatch`) reachable from `fn` route through this run's `ctx.child`/
3591
4691
  // `ctx.startChild`. Re-set on every replay turn (each is its own async scope) — correct.
3592
4692
  const output = await workflowAls.run(ctx, () => fn(ctx, run.input));
4693
+ // A compensating cancel requested while this turn ran (or one whose resume replays straight to
4694
+ // `return`, with no suspension point left) still wins: undo + settle cancelled, never completed.
4695
+ if (await this.takeCancelIntent(run.id)) {
4696
+ return await this.compensateAndCancel(run, compensations);
4697
+ }
3593
4698
  return await this.settleRun(run, { kind: 'completed', output });
3594
4699
  }
3595
4700
  catch (err) {
@@ -3598,6 +4703,10 @@ export class WorkflowEngine {
3598
4703
  // complete this one. Zombie fence first — a fenced turn must not fork a continuation either.
3599
4704
  if (this.leaseLostRuns.has(run.id))
3600
4705
  return await this.echoCurrentStatus(run.id);
4706
+ // A cancel in flight wins over continue-as-new: undo + cancel instead of forking the next run.
4707
+ if (await this.takeCancelIntent(run.id)) {
4708
+ return await this.compensateAndCancel(run, compensations);
4709
+ }
3601
4710
  const nextId = nextContinuationId(run.id);
3602
4711
  // Persist the continuation BEFORE the parent's terminal write — the durable ordering that
3603
4712
  // makes the chain crash-safe. The old order (complete parent, then create the continuation
@@ -3639,45 +4748,8 @@ export class WorkflowEngine {
3639
4748
  if (err instanceof WorkflowSuspended) {
3640
4749
  // A compensating cancel resumed this run to reach here: the replay re-registered the saga,
3641
4750
  // so undo the completed steps in reverse and mark it cancelled instead of re-suspending.
3642
- // The intent lives in TWO places: the in-memory `cancelRequested` fast path (same pod, same
3643
- // boot), and the DURABLE buffered `cancel:<id>` marker `cancel()` persisted — so a crash or
3644
- // a cross-pod handoff mid-cancellation can't silently forget the cancel and settle the run
3645
- // back to `suspended` as if nothing happened. Peek the marker (take + re-buffer) rather than
3646
- // consume it: it must survive a crash mid-unwind; it's drained below once `cancelled` is
3647
- // durably recorded.
3648
- let cancelling = this.cancelRequested.delete(run.id);
3649
- if (!cancelling) {
3650
- const marker = await this.store.takeBufferedSignal(`cancel:${run.id}`);
3651
- if (marker) {
3652
- cancelling = true;
3653
- await this.store
3654
- .bufferSignal(`cancel:${run.id}`, marker.payload)
3655
- .catch(() => undefined);
3656
- }
3657
- }
3658
- if (cancelling) {
3659
- for (let i = compensations.length - 1; i >= 0; i -= 1) {
3660
- const comp = compensations[i];
3661
- if (comp)
3662
- await this.runCompensation(run, comp, i);
3663
- }
3664
- const error = { message: 'cancelled' };
3665
- await this.store.updateRun(run.id, { status: 'cancelled', error, updatedAt: new Date() });
3666
- // The cancellation is durably recorded — consume the marker(s) so a later retry of this
3667
- // run id doesn't get spuriously re-cancelled.
3668
- for (;;) {
3669
- const m = await this.store.takeBufferedSignal(`cancel:${run.id}`).catch(() => null);
3670
- if (!m)
3671
- break;
3672
- }
3673
- this.emit({
3674
- type: 'run.failed',
3675
- runId: run.id,
3676
- workflow: run.workflow,
3677
- namespace: run.namespace,
3678
- error,
3679
- });
3680
- return { runId: run.id, status: 'cancelled', error };
4751
+ if (await this.takeCancelIntent(run.id)) {
4752
+ return await this.compensateAndCancel(run, compensations);
3681
4753
  }
3682
4754
  const settled = await this.settleRun(run, { kind: 'suspended', wakeAt: err.wakeAt });
3683
4755
  // Close the settle-vs-delivery race: a signal/result that landed WHILE this turn was still
@@ -3695,6 +4767,11 @@ export class WorkflowEngine {
3695
4767
  suspendedOnFan = err.fan;
3696
4768
  return settled;
3697
4769
  }
4770
+ // A cancel in flight that surfaced as a thrown error settles `cancelled`, not `failed`: the saga
4771
+ // undo runs either way; this keeps the terminal status faithful to the cancel request.
4772
+ if (await this.takeCancelIntent(run.id)) {
4773
+ return await this.compensateAndCancel(run, compensations);
4774
+ }
3698
4775
  const error = {
3699
4776
  message: err instanceof Error ? err.message : String(err),
3700
4777
  stack: err instanceof Error ? err.stack : undefined,
@@ -3840,7 +4917,9 @@ export class WorkflowEngine {
3840
4917
  if (this.pool.size === 0)
3841
4918
  throw new Error('dispatched compensation requires a Transport');
3842
4919
  const token = stepGroup(def, run.namespace);
3843
- const validInput = def.input ? def.input.parse(args) : args;
4920
+ const validInput = def.input
4921
+ ? await validateStandard(def.input, args, `step "${def.name}" input`)
4922
+ : args;
3844
4923
  const id = `${run.id}:compensate:${def.name}:${attempt}`;
3845
4924
  const name = `compensate:${def.name}`;
3846
4925
  const existing = await this.store.getCheckpoint(run.id, seq);
@@ -3883,7 +4962,7 @@ export class WorkflowEngine {
3883
4962
  });
3884
4963
  }
3885
4964
  const awaited = def.timeoutMs
3886
- ? this.awaitWithHeartbeat(id, resultPromise, def.timeoutMs)
4965
+ ? this.awaitWithHeartbeat(run.id, seq, id, resultPromise, def.timeoutMs)
3887
4966
  : resultPromise;
3888
4967
  await this.awaitCompensationOutcome(run.id, seq, name, id, token, attempt, awaited,
3889
4968
  // With a heartbeat window the def's own liveness handling governs; otherwise bound the wait.
@@ -3972,6 +5051,7 @@ export class WorkflowEngine {
3972
5051
  store: this.store,
3973
5052
  replay,
3974
5053
  clock: this.clock,
5054
+ eventBufferTtlMs: this.eventBufferTtlMs,
3975
5055
  webhookUrl: this.webhookUrl,
3976
5056
  startStep: (s) => this.startStep(s),
3977
5057
  completeStep: (s) => this.completeStep(s),
@@ -3998,8 +5078,19 @@ export class WorkflowEngine {
3998
5078
  // Shallow-merge into the run's searchAttributes (the ctx primitive makes this exactly-once).
3999
5079
  upsertSearchAttributes: async (runId, attrs) => {
4000
5080
  const run = await this.store.getRun(runId);
5081
+ const merged = { ...(run?.searchAttributes ?? {}), ...attrs };
5082
+ // A declared `searchAttributesSchema` validates the MERGED bag (the schema describes the whole
5083
+ // run's shape, optional keys included) — before the write, so the ctx primitive records no
5084
+ // checkpoint and a rejected write throws in the body (non-retryable SchemaValidationError).
5085
+ const schema = run
5086
+ ? this.workflows.get(versionKey(run.workflow, run.workflowVersion))
5087
+ ?.searchAttributesSchema
5088
+ : undefined;
5089
+ if (schema) {
5090
+ validateStandardSync(schema, merged, `workflow "${run?.workflow}": ctx.upsertSearchAttributes`);
5091
+ }
4001
5092
  await this.store.updateRun(runId, {
4002
- searchAttributes: { ...(run?.searchAttributes ?? {}), ...attrs },
5093
+ searchAttributes: merged,
4003
5094
  updatedAt: new Date(),
4004
5095
  });
4005
5096
  },
@@ -4011,6 +5102,16 @@ export class WorkflowEngine {
4011
5102
  signalEntity: (name, key, op, arg, reply) => {
4012
5103
  this.trackEffect(Promise.resolve().then(() => this.entities.dispatch(name, key, op, arg, reply)));
4013
5104
  },
5105
+ // The durable half of a workflow-side entity send: awaited inside the turn (a store write
5106
+ // only, never an inline entity turn), so the ctx can checkpoint after it. See Entities.enqueue.
5107
+ enqueueEntityOp: (name, key, op, arg, id, reply) => this.entities.enqueue(name, key, op, arg, id, reply),
5108
+ // Start/wake the entity for what enqueueEntityOp buffered — deferred and tracked like
5109
+ // signalEntity above, so it never re-enters a still-executing turn and drain() waits for it.
5110
+ kickEntity: (name, key) => {
5111
+ this.trackEffect(Promise.resolve().then(() => this.entities.kick(name, key).catch((err) => {
5112
+ console.warn(`[durable] waking entity "${name}" key "${key}" failed (its op stays buffered for the next wake):`, err);
5113
+ })));
5114
+ },
4014
5115
  interceptStep: (invocation, body) => this.interceptStep(invocation, body),
4015
5116
  };
4016
5117
  }
@@ -4316,7 +5417,9 @@ export class WorkflowEngine {
4316
5417
  }
4317
5418
  // Optional runtime schema: validate only when the def carries one (a bare `@Step()` / string-name
4318
5419
  // call carries none — the engine passes the input through untouched then).
4319
- const validInput = step.input ? step.input.parse(input) : input;
5420
+ const validInput = step.input
5421
+ ? await validateStandard(step.input, input, `step "${step.name}" input`)
5422
+ : input;
4320
5423
  // Routing token: BY NAME (sanitized for brokers), optionally partition-suffixed. Computed once and
4321
5424
  // used at BOTH the checkpoint's workerGroup and the dispatched task's routing `group`, so the same
4322
5425
  // token the worker subscribes to per handler name serves this step.
@@ -4464,8 +5567,10 @@ export class WorkflowEngine {
4464
5567
  async sendStepFan(runId, positions, step, inputs, batch, group, opts, runNamespace) {
4465
5568
  const token = stepGroup(step, runNamespace);
4466
5569
  await this.ensureRoutable(token, step.requires);
4467
- const input = (i) => step.input ? step.input.parse(inputs[i]) : inputs[i];
4468
- const validInputs = new Map(batch.map((i) => [i, input(i)]));
5570
+ const input = (i) => step.input ? validateStandard(step.input, inputs[i], `step "${step.name}" input`) : inputs[i];
5571
+ const validInputs = new Map();
5572
+ for (const i of batch)
5573
+ validInputs.set(i, await input(i));
4469
5574
  const enqueuedAt = new Date();
4470
5575
  for (let at = 0; at < batch.length; at += STEP_FAN_CHUNK) {
4471
5576
  await Promise.all(batch.slice(at, at + STEP_FAN_CHUNK).map((i) => {
@@ -4552,17 +5657,212 @@ export class WorkflowEngine {
4552
5657
  await this.store.saveCheckpoint(checkpoint);
4553
5658
  return true;
4554
5659
  }
4555
- async completeRemoteResult(result) {
5660
+ /**
5661
+ * Settle a durable step result and (barrier permitting) resume its run in the background.
5662
+ *
5663
+ * One run's results are settled in batches: the first starts a batch, and the ones that arrive
5664
+ * while it is being written (with `resultConcurrency > 1`) form the next, up to
5665
+ * {@link RESULT_BATCH_MAX}. A batch of several claims its checkpoints with one
5666
+ * {@link StateStore.saveCheckpointsIfPending} call, asks each step fan's barrier once, and resumes
5667
+ * the run at most once; a batch of one takes the single-result path unchanged. A result resolves
5668
+ * only once its own write committed, so the transport acknowledges nothing that is not durable.
5669
+ */
5670
+ completeRemoteResult(result) {
5671
+ return new Promise((resolve, reject) => {
5672
+ const entry = { result, resolve, reject };
5673
+ const queued = this.resultQueues.get(result.runId);
5674
+ if (queued) {
5675
+ queued.push(entry);
5676
+ return;
5677
+ }
5678
+ const queue = [entry];
5679
+ this.resultQueues.set(result.runId, queue);
5680
+ void this.drainResultQueue(result.runId, queue);
5681
+ });
5682
+ }
5683
+ /** Settle `queue` (one run's queued results) batch by batch until it is empty, then retire it. */
5684
+ async drainResultQueue(runId, queue) {
5685
+ // Results delivered in the same tick as the first join its batch.
5686
+ await Promise.resolve();
5687
+ while (queue.length > 0) {
5688
+ const batch = queue.splice(0, RESULT_BATCH_MAX);
5689
+ let outcomes;
5690
+ try {
5691
+ outcomes = await this.completeRemoteResults(runId, batch.map((q) => q.result));
5692
+ }
5693
+ catch (error) {
5694
+ outcomes = batch.map(() => ({ error }));
5695
+ }
5696
+ batch.forEach((q, i) => {
5697
+ const outcome = outcomes[i] ?? true;
5698
+ if (outcome === true)
5699
+ q.resolve();
5700
+ else
5701
+ q.reject(outcome.error);
5702
+ });
5703
+ }
5704
+ // Same tick as the empty check: a result arriving later starts a new queue.
5705
+ this.resultQueues.delete(runId);
5706
+ }
5707
+ /** The single-result path: settle `result`, then resume its run unless its fan is still running. */
5708
+ async completeOneRemoteResult(result) {
4556
5709
  const cp = await this.settleRemoteCheckpoint(result);
4557
5710
  if (cp === undefined)
4558
5711
  return;
4559
5712
  if (await this.stepFanStillRunning(cp, result))
4560
5713
  return;
4561
- const runId = result.runId;
5714
+ this.resumeInBackground(result.runId);
5715
+ }
5716
+ resumeInBackground(runId) {
4562
5717
  this.trackEffect(this.resumeImplicit(runId).catch((err) => {
4563
5718
  console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
4564
5719
  }));
4565
5720
  }
5721
+ /**
5722
+ * Settle a batch of one run's step results; per result, `true` (settled or dropped) or the error
5723
+ * that kept it from settling, which the transport then redelivers. Same outcome per result as
5724
+ * {@link completeOneRemoteResult} on each in turn, with one resume for the batch: that resume
5725
+ * replays every write of the batch, so a second would only replay it again.
5726
+ */
5727
+ async completeRemoteResults(runId, results) {
5728
+ const outcomes = results.map(() => true);
5729
+ const settleOneByOne = async (indexes) => {
5730
+ for (const i of indexes) {
5731
+ try {
5732
+ await this.completeOneRemoteResult(results[i]);
5733
+ }
5734
+ catch (error) {
5735
+ outcomes[i] = { error };
5736
+ }
5737
+ }
5738
+ return outcomes;
5739
+ };
5740
+ if (results.length === 1)
5741
+ return settleOneByOne([0]);
5742
+ // A second copy of a result in the same batch is a duplicate delivery: the first copy settles
5743
+ // the step (or re-drives the resume), so the copy is dropped like a late one.
5744
+ const seen = new Set();
5745
+ const live = results.flatMap((r, i) => {
5746
+ if (seen.has(r.seq))
5747
+ return [];
5748
+ seen.add(r.seq);
5749
+ return [i];
5750
+ });
5751
+ const run = await this.store.getRun(runId);
5752
+ // A finished or unknown run takes the single-result path, which salvages a late success onto a
5753
+ // failed run and frees the slots of a cancelled one. Rare, so it is not batched.
5754
+ if (run === null ||
5755
+ run.status === 'completed' ||
5756
+ run.status === 'failed' ||
5757
+ run.status === 'cancelled' ||
5758
+ run.status === 'dead') {
5759
+ return settleOneByOne(live);
5760
+ }
5761
+ const reads = await Promise.allSettled(live.map((i) => this.store.getCheckpoint(runId, results[i].seq)));
5762
+ // Checkpoints whose result should resume the run (barrier permitting): the ones this batch
5763
+ // settled, and redeliveries of ones settled before — whose resume may never have happened.
5764
+ const resumable = [];
5765
+ const pending = [];
5766
+ reads.forEach((read, k) => {
5767
+ const index = live[k];
5768
+ const result = results[index];
5769
+ if (read.status === 'rejected')
5770
+ outcomes[index] = { error: read.reason };
5771
+ else if (!read.value)
5772
+ return;
5773
+ else if (read.value.status === 'pending')
5774
+ pending.push({ index, cp: read.value });
5775
+ else
5776
+ resumable.push({ cp: read.value, result });
5777
+ });
5778
+ const finishedAt = new Date();
5779
+ const writes = pending.map(({ index, cp }) => {
5780
+ const result = results[index];
5781
+ return {
5782
+ ...cp,
5783
+ status: result.status,
5784
+ output: result.status === 'completed' ? result.output : cp.output,
5785
+ error: result.error,
5786
+ events: result.events ?? cp.events,
5787
+ startedAt: result.startedAt ? new Date(result.startedAt) : cp.startedAt,
5788
+ finishedAt,
5789
+ };
5790
+ });
5791
+ // Claimed, not blindly written: a duplicate delivery may have settled a step first.
5792
+ const claims = await this.savePendingCheckpoints(writes);
5793
+ for (const [k, claim] of claims.entries()) {
5794
+ const { index, cp } = pending[k];
5795
+ const result = results[index];
5796
+ if (claim === false) {
5797
+ resumable.push({ cp, result });
5798
+ continue;
5799
+ }
5800
+ if (claim !== true) {
5801
+ outcomes[index] = claim;
5802
+ continue;
5803
+ }
5804
+ try {
5805
+ await this.releaseQueueSlot(cp);
5806
+ }
5807
+ catch (error) {
5808
+ // Written but not acknowledged: the redelivery finds the step settled and re-drives.
5809
+ outcomes[index] = { error };
5810
+ }
5811
+ const startedAt = result.startedAt ? new Date(result.startedAt) : cp.startedAt;
5812
+ this.emit({
5813
+ type: result.status === 'completed' ? 'step.completed' : 'step.failed',
5814
+ runId,
5815
+ seq: result.seq,
5816
+ name: cp.name,
5817
+ kind: cp.kind,
5818
+ output: result.output,
5819
+ error: result.error,
5820
+ queueMs: startedAt.getTime() - cp.enqueuedAt.getTime(),
5821
+ durationMs: finishedAt.getTime() - startedAt.getTime(),
5822
+ });
5823
+ resumable.push({ cp, result });
5824
+ }
5825
+ // Asked after every write of the batch committed, so the batch holding a fan's last step sees
5826
+ // its barrier open; once per fan. A barrier that cannot be read counts as open: a spare resume
5827
+ // replays and re-suspends, a lost one strands the run.
5828
+ const barriers = new Map();
5829
+ for (const { cp, result } of resumable) {
5830
+ const fan = parseStepFanGroup(cp.parallelGroup);
5831
+ if (!fan || result.status === 'failed') {
5832
+ this.resumeInBackground(runId);
5833
+ return outcomes;
5834
+ }
5835
+ let closed = barriers.get(fan.firstSeq);
5836
+ if (!closed) {
5837
+ closed = this.countPendingSteps(runId, fan.firstSeq, fan.firstSeq + fan.size - 1).then((n) => n > 0, () => false);
5838
+ barriers.set(fan.firstSeq, closed);
5839
+ }
5840
+ if (!(await closed)) {
5841
+ this.resumeInBackground(runId);
5842
+ return outcomes;
5843
+ }
5844
+ }
5845
+ return outcomes;
5846
+ }
5847
+ /**
5848
+ * {@link savePendingCheckpoint} for checkpoints at distinct seqs: one
5849
+ * {@link StateStore.saveCheckpointsIfPending} call when the store has it, else one write each.
5850
+ * Never rejects as a whole: a failed write is reported as that entry's error, and the batch call
5851
+ * rejecting means it wrote nothing, so the entries are then written one by one.
5852
+ */
5853
+ async savePendingCheckpoints(checkpoints) {
5854
+ if (checkpoints.length > 1 && this.store.saveCheckpointsIfPending) {
5855
+ try {
5856
+ const wrote = await this.store.saveCheckpointsIfPending(checkpoints);
5857
+ return checkpoints.map((_, i) => wrote[i] === true);
5858
+ }
5859
+ catch {
5860
+ // Fall through: one bad row must not fail the whole batch.
5861
+ }
5862
+ }
5863
+ const settled = await Promise.allSettled(checkpoints.map((cp) => this.savePendingCheckpoint(cp)));
5864
+ return settled.map((r) => (r.status === 'fulfilled' ? r.value : { error: r.reason }));
5865
+ }
4566
5866
  /**
4567
5867
  * The join barrier of a step fan: a result for one of its steps resumes the parent only once no
4568
5868
  * step of the fan is still `pending` (a settled wave), or on a failure (failFast surfaces it, and a
@@ -4698,7 +5998,9 @@ export class WorkflowEngine {
4698
5998
  async callRemoteInMemory(runId, seq, step, input, transport, runNamespace, queue, admission) {
4699
5999
  if (this.pool.size === 0)
4700
6000
  throw new Error('remote steps require a Transport');
4701
- const validInput = step.input ? step.input.parse(input) : input;
6001
+ const validInput = step.input
6002
+ ? await validateStandard(step.input, input, `step "${step.name}" input`)
6003
+ : input;
4702
6004
  const token = stepGroup(step, runNamespace);
4703
6005
  // Capability/protocol guard (design §7.5) — same as the durable path, before the `step.started`
4704
6006
  // emit + dispatch, so a step nobody can run parks `blocked` instead of awaiting a phantom worker.
@@ -4783,7 +6085,7 @@ export class WorkflowEngine {
4783
6085
  }, transport);
4784
6086
  try {
4785
6087
  const resolution = step.timeoutMs
4786
- ? await this.awaitWithHeartbeat(id, resultPromise, step.timeoutMs, step.pickupTimeoutMs)
6088
+ ? await this.awaitWithHeartbeat(runId, seq, id, resultPromise, step.timeoutMs, step.pickupTimeoutMs)
4787
6089
  : await resultPromise;
4788
6090
  return await this.settleRemoteAttempt(runId, seq, step, validInput, resolution, attempt, token, enqueuedAt);
4789
6091
  }
@@ -4815,7 +6117,9 @@ export class WorkflowEngine {
4815
6117
  }
4816
6118
  /** Shared finalization for a remote resolution, however it arrived (live await or salvaged). */
4817
6119
  async settleRemoteAttempt(runId, seq, step, input, resolution, attempt, token, enqueuedAt) {
4818
- const output = (step.output ? step.output.parse(resolution.output) : resolution.output);
6120
+ const output = (step.output
6121
+ ? await validateStandard(step.output, resolution.output, `step "${step.name}" output`)
6122
+ : resolution.output);
4819
6123
  // The worker reports when it actually picked the task up; fall back to dispatch time if a
4820
6124
  // transport doesn't carry it (queue-wait then reads as zero rather than going negative).
4821
6125
  const startedAt = resolution.startedAt ? new Date(resolution.startedAt) : enqueuedAt;
@@ -4839,9 +6143,11 @@ export class WorkflowEngine {
4839
6143
  * arrives within `timeoutMs`. Each heartbeat (delivered via `transport.onHeartbeat`) rearms the
4840
6144
  * window, so a worker that keeps beating stays alive past `timeoutMs`.
4841
6145
  */
4842
- awaitWithHeartbeat(id, resultPromise, timeoutMs, pickupTimeoutMs) {
6146
+ awaitWithHeartbeat(runId, seq, id, resultPromise, timeoutMs, pickupTimeoutMs) {
4843
6147
  return new Promise((resolve, reject) => {
4844
6148
  let timer;
6149
+ let settled = false;
6150
+ const dispatchedAt = this.clock();
4845
6151
  // Two windows, one timer: until the FIRST beat arrives the job may be sitting in the broker
4846
6152
  // queue behind long work — a stretch that produces no heartbeats by definition, so it gets
4847
6153
  // `pickupTimeoutMs` (default: `timeoutMs`, the historical behavior). The worker's automatic
@@ -4853,20 +6159,63 @@ export class WorkflowEngine {
4853
6159
  // would turn a generous liveness window into an instant RemoteStepTimeout.
4854
6160
  const window = () => Math.min(seenBeat ? timeoutMs : (pickupTimeoutMs ?? timeoutMs), MAX_BACKOFF_MS);
4855
6161
  const cleanup = () => {
6162
+ settled = true;
4856
6163
  clearTimeout(timer);
4857
6164
  this.heartbeatResets.delete(id);
4858
6165
  };
4859
- const arm = () => {
4860
- const ms = window();
4861
- timer = setTimeout(() => {
4862
- cleanup();
4863
- this.pending.delete(id);
4864
- reject(new RemoteStepTimeout(id, ms));
4865
- }, ms);
6166
+ const arm = (ms = window()) => {
6167
+ timer = setTimeout(() => void expire(ms), ms);
4866
6168
  timer.unref?.();
4867
6169
  };
4868
- this.heartbeatResets.set(id, () => {
6170
+ // Worker timestamps of the beats THIS instance heard, to tell its own persisted beats from ones
6171
+ // persisted by another instance. Bounded: a step beating for days must not grow it forever.
6172
+ const localBeats = new Set();
6173
+ // Set once a persisted beat of this attempt turns out to come from another instance.
6174
+ let beatsLandElsewhere = false;
6175
+ // The window ran out with no beat seen HERE. With several engine instances consuming the same
6176
+ // heartbeat queue (the `queue` transport: point-to-point), the beats may have landed on another
6177
+ // instance — which persists them on the checkpoint (`persistHeartbeat`) but cannot reach this
6178
+ // timer. Before declaring the worker dead, read the persisted beat: a recent one (from this
6179
+ // attempt) means the step is alive, so re-arm instead of failing over a healthy step and
6180
+ // re-dispatching it while it still runs.
6181
+ //
6182
+ // "Recent" has to account for the persist throttle: the other instance writes at most one beat
6183
+ // per `heartbeatPersistThrottleMs`, so the newest beat it heard can be up to that much newer
6184
+ // than the one on the checkpoint. Once a persisted beat is known to come from elsewhere (its
6185
+ // timestamp is not one this instance heard), that much extra silence is allowed. A beat this
6186
+ // instance persisted itself gets no allowance — it knows its own beats exactly — so a lone
6187
+ // instance detects a dead worker as fast as before.
6188
+ const expire = async (ms) => {
6189
+ if (settled)
6190
+ return;
6191
+ const at = await this.store
6192
+ .getCheckpoint(runId, seq)
6193
+ .then((cp) => cp?.lastHeartbeatAt?.getTime())
6194
+ .catch(() => undefined);
6195
+ if (settled)
6196
+ return;
6197
+ const now = this.clock();
6198
+ if (at !== undefined && at >= dispatchedAt - HEARTBEAT_CLOCK_SKEW_MS) {
6199
+ if (!localBeats.has(at))
6200
+ beatsLandElsewhere = true;
6201
+ const allowance = timeoutMs + (beatsLandElsewhere ? this.heartbeatPersistThrottleMs : 0);
6202
+ if (now - at < allowance) {
6203
+ seenBeat = true;
6204
+ arm(Math.min(Math.max(allowance - (now - at), 1), MAX_BACKOFF_MS));
6205
+ return;
6206
+ }
6207
+ }
6208
+ cleanup();
6209
+ this.pending.delete(id);
6210
+ reject(new RemoteStepTimeout(id, ms));
6211
+ };
6212
+ this.heartbeatResets.set(id, (beatAt) => {
4869
6213
  seenBeat = true;
6214
+ if (beatAt !== undefined) {
6215
+ if (localBeats.size >= 256)
6216
+ localBeats.clear();
6217
+ localBeats.add(beatAt);
6218
+ }
4870
6219
  clearTimeout(timer);
4871
6220
  arm();
4872
6221
  });
@@ -4881,6 +6230,21 @@ export class WorkflowEngine {
4881
6230
  });
4882
6231
  }
4883
6232
  }
6233
+ /** The worker group a convention-routed workflow's run dispatches to: its sanitized name, suffixed by
6234
+ * the run's namespace (bare for absent/`''`/`default`) — the token a tenant worker serves. */
6235
+ function conventionGroup(name, namespace) {
6236
+ return tenantGroup(sanitizeQueueToken(name), namespace);
6237
+ }
6238
+ /** Key of an inline remote turn's liveness reset in `heartbeatResets` (step keys are `runId:seq`). */
6239
+ function turnBeatKey(runId) {
6240
+ return `turn:${runId}`;
6241
+ }
6242
+ /** The run whose remote turn is being enqueued in the current async context — lets the decision
6243
+ * handler spot a transport delivering the decision from inside its own `dispatchWorkflowTask`. */
6244
+ const turnDispatchAls = new AsyncLocalStorage();
6245
+ /** Tolerance (ms) between the worker's clock, which stamps a persisted heartbeat, and this engine's,
6246
+ * when deciding whether a beat belongs to the current attempt (see `awaitWithHeartbeat`). */
6247
+ const HEARTBEAT_CLOCK_SKEW_MS = 1_000;
4884
6248
  /** Raised inside the workflow when a remote worker reports a step failure. */
4885
6249
  export class RemoteStepError extends Error {
4886
6250
  stepError;