@adonis-agora/durable 0.49.1 → 0.50.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/CHANGELOG.md +251 -0
  2. package/README.md +53 -22
  3. package/dist/assets/spa/assets/index-DupjrK7N.js +16 -0
  4. package/dist/assets/spa/assets/index-DupjrK7N.js.map +1 -0
  5. package/dist/assets/spa/index.html +1 -1
  6. package/dist/commands/durable_worker.d.ts.map +1 -1
  7. package/dist/commands/durable_worker.js +9 -8
  8. package/dist/commands/durable_worker.js.map +1 -1
  9. package/dist/configure.d.ts.map +1 -1
  10. package/dist/configure.js +1 -0
  11. package/dist/configure.js.map +1 -1
  12. package/dist/providers/dashboard_provider.d.ts +2 -0
  13. package/dist/providers/dashboard_provider.d.ts.map +1 -1
  14. package/dist/providers/dashboard_provider.js +50 -6
  15. package/dist/providers/dashboard_provider.js.map +1 -1
  16. package/dist/providers/durable_provider.d.ts.map +1 -1
  17. package/dist/providers/durable_provider.js +143 -34
  18. package/dist/providers/durable_provider.js.map +1 -1
  19. package/dist/src/admission-redis/redis-admission-backend.d.ts.map +1 -1
  20. package/dist/src/admission-redis/redis-admission-backend.js +3 -1
  21. package/dist/src/admission-redis/redis-admission-backend.js.map +1 -1
  22. package/dist/src/ambient-step.d.ts +24 -0
  23. package/dist/src/ambient-step.d.ts.map +1 -0
  24. package/dist/src/ambient-step.js +42 -0
  25. package/dist/src/ambient-step.js.map +1 -0
  26. package/dist/src/base-workflow.d.ts +10 -1
  27. package/dist/src/base-workflow.d.ts.map +1 -1
  28. package/dist/src/base-workflow.js.map +1 -1
  29. package/dist/src/codec-state-store.d.ts +28 -2
  30. package/dist/src/codec-state-store.d.ts.map +1 -1
  31. package/dist/src/codec-state-store.js +61 -3
  32. package/dist/src/codec-state-store.js.map +1 -1
  33. package/dist/src/commands/worker.d.ts +4 -1
  34. package/dist/src/commands/worker.d.ts.map +1 -1
  35. package/dist/src/commands/worker.js +27 -3
  36. package/dist/src/commands/worker.js.map +1 -1
  37. package/dist/src/config_types.d.ts +6 -0
  38. package/dist/src/config_types.d.ts.map +1 -1
  39. package/dist/src/control-plane-redis/redis-control-plane.d.ts.map +1 -1
  40. package/dist/src/control-plane-redis/redis-control-plane.js +22 -6
  41. package/dist/src/control-plane-redis/redis-control-plane.js.map +1 -1
  42. package/dist/src/control-planes/factory.d.ts +6 -0
  43. package/dist/src/control-planes/factory.d.ts.map +1 -1
  44. package/dist/src/control-planes/factory.js +1 -0
  45. package/dist/src/control-planes/factory.js.map +1 -1
  46. package/dist/src/dashboard/gateway-adapter.d.ts +15 -5
  47. package/dist/src/dashboard/gateway-adapter.d.ts.map +1 -1
  48. package/dist/src/dashboard/gateway-adapter.js +71 -7
  49. package/dist/src/dashboard/gateway-adapter.js.map +1 -1
  50. package/dist/src/dashboard/handlers.d.ts +53 -1
  51. package/dist/src/dashboard/handlers.d.ts.map +1 -1
  52. package/dist/src/dashboard/handlers.js +200 -20
  53. package/dist/src/dashboard/handlers.js.map +1 -1
  54. package/dist/src/dashboard/index.d.ts +2 -2
  55. package/dist/src/dashboard/index.d.ts.map +1 -1
  56. package/dist/src/dashboard/index.js +2 -2
  57. package/dist/src/dashboard/index.js.map +1 -1
  58. package/dist/src/dashboard/openapi.d.ts.map +1 -1
  59. package/dist/src/dashboard/openapi.js +71 -1
  60. package/dist/src/dashboard/openapi.js.map +1 -1
  61. package/dist/src/define_config.d.ts +122 -8
  62. package/dist/src/define_config.d.ts.map +1 -1
  63. package/dist/src/define_config.js.map +1 -1
  64. package/dist/src/diagnostics-bridge.d.ts +16 -3
  65. package/dist/src/diagnostics-bridge.d.ts.map +1 -1
  66. package/dist/src/diagnostics-bridge.js +9 -3
  67. package/dist/src/diagnostics-bridge.js.map +1 -1
  68. package/dist/src/discovery-roots.d.ts +92 -0
  69. package/dist/src/discovery-roots.d.ts.map +1 -0
  70. package/dist/src/discovery-roots.js +150 -0
  71. package/dist/src/discovery-roots.js.map +1 -0
  72. package/dist/src/engine.d.ts +418 -20
  73. package/dist/src/engine.d.ts.map +1 -1
  74. package/dist/src/engine.js +1523 -159
  75. package/dist/src/engine.js.map +1 -1
  76. package/dist/src/entities.d.ts +36 -1
  77. package/dist/src/entities.d.ts.map +1 -1
  78. package/dist/src/entities.js +65 -4
  79. package/dist/src/entities.js.map +1 -1
  80. package/dist/src/errors.d.ts +36 -0
  81. package/dist/src/errors.d.ts.map +1 -1
  82. package/dist/src/errors.js +44 -0
  83. package/dist/src/errors.js.map +1 -1
  84. package/dist/src/event-trigger-bridge.d.ts +8 -0
  85. package/dist/src/event-trigger-bridge.d.ts.map +1 -1
  86. package/dist/src/event-trigger-bridge.js +24 -3
  87. package/dist/src/event-trigger-bridge.js.map +1 -1
  88. package/dist/src/handshake/announced.d.ts +27 -0
  89. package/dist/src/handshake/announced.d.ts.map +1 -1
  90. package/dist/src/handshake/announced.js +62 -0
  91. package/dist/src/handshake/announced.js.map +1 -1
  92. package/dist/src/handshake/directory.d.ts +92 -0
  93. package/dist/src/handshake/directory.d.ts.map +1 -0
  94. package/dist/src/handshake/directory.js +139 -0
  95. package/dist/src/handshake/directory.js.map +1 -0
  96. package/dist/src/hooks/barrel-sources.d.ts +28 -0
  97. package/dist/src/hooks/barrel-sources.d.ts.map +1 -0
  98. package/dist/src/hooks/barrel-sources.js +92 -0
  99. package/dist/src/hooks/barrel-sources.js.map +1 -0
  100. package/dist/src/hooks/steps.d.ts +12 -3
  101. package/dist/src/hooks/steps.d.ts.map +1 -1
  102. package/dist/src/hooks/steps.js +7 -11
  103. package/dist/src/hooks/steps.js.map +1 -1
  104. package/dist/src/hooks/workflows.d.ts +12 -3
  105. package/dist/src/hooks/workflows.d.ts.map +1 -1
  106. package/dist/src/hooks/workflows.js +7 -13
  107. package/dist/src/hooks/workflows.js.map +1 -1
  108. package/dist/src/index.d.ts +12 -3
  109. package/dist/src/index.d.ts.map +1 -1
  110. package/dist/src/index.js +12 -3
  111. package/dist/src/index.js.map +1 -1
  112. package/dist/src/interfaces.d.ts +309 -21
  113. package/dist/src/interfaces.d.ts.map +1 -1
  114. package/dist/src/interfaces.js +10 -0
  115. package/dist/src/interfaces.js.map +1 -1
  116. package/dist/src/metrics.d.ts.map +1 -1
  117. package/dist/src/metrics.js +5 -0
  118. package/dist/src/metrics.js.map +1 -1
  119. package/dist/src/namespaces.d.ts +17 -0
  120. package/dist/src/namespaces.d.ts.map +1 -0
  121. package/dist/src/namespaces.js +24 -0
  122. package/dist/src/namespaces.js.map +1 -0
  123. package/dist/src/otel/durable-metrics.d.ts.map +1 -1
  124. package/dist/src/otel/durable-metrics.js +5 -0
  125. package/dist/src/otel/durable-metrics.js.map +1 -1
  126. package/dist/src/otel/durable-otel.d.ts.map +1 -1
  127. package/dist/src/otel/durable-otel.js +5 -0
  128. package/dist/src/otel/durable-otel.js.map +1 -1
  129. package/dist/src/pollers.d.ts +10 -1
  130. package/dist/src/pollers.d.ts.map +1 -1
  131. package/dist/src/pollers.js +15 -5
  132. package/dist/src/pollers.js.map +1 -1
  133. package/dist/src/protocol.d.ts +12 -0
  134. package/dist/src/protocol.d.ts.map +1 -1
  135. package/dist/src/protocol.js +27 -1
  136. package/dist/src/protocol.js.map +1 -1
  137. package/dist/src/remote-event.d.ts +6 -0
  138. package/dist/src/remote-event.d.ts.map +1 -0
  139. package/dist/src/remote-event.js +19 -0
  140. package/dist/src/remote-event.js.map +1 -0
  141. package/dist/src/remote-workflow-executor.d.ts +34 -12
  142. package/dist/src/remote-workflow-executor.d.ts.map +1 -1
  143. package/dist/src/remote-workflow-executor.js +42 -40
  144. package/dist/src/remote-workflow-executor.js.map +1 -1
  145. package/dist/src/run-detail-summary.d.ts +90 -3
  146. package/dist/src/run-detail-summary.d.ts.map +1 -1
  147. package/dist/src/run-detail-summary.js +360 -2
  148. package/dist/src/run-detail-summary.js.map +1 -1
  149. package/dist/src/run-facets.d.ts +48 -0
  150. package/dist/src/run-facets.d.ts.map +1 -0
  151. package/dist/src/run-facets.js +100 -0
  152. package/dist/src/run-facets.js.map +1 -0
  153. package/dist/src/run-gateway/interface.d.ts +27 -1
  154. package/dist/src/run-gateway/interface.d.ts.map +1 -1
  155. package/dist/src/run-gateway/proxy-run-gateway.d.ts +32 -4
  156. package/dist/src/run-gateway/proxy-run-gateway.d.ts.map +1 -1
  157. package/dist/src/run-gateway/proxy-run-gateway.js +101 -5
  158. package/dist/src/run-gateway/proxy-run-gateway.js.map +1 -1
  159. package/dist/src/run-gateway/run-request-responder.d.ts +18 -5
  160. package/dist/src/run-gateway/run-request-responder.d.ts.map +1 -1
  161. package/dist/src/run-gateway/run-request-responder.js +155 -32
  162. package/dist/src/run-gateway/run-request-responder.js.map +1 -1
  163. package/dist/src/run-gateway/store-run-gateway.d.ts +23 -1
  164. package/dist/src/run-gateway/store-run-gateway.d.ts.map +1 -1
  165. package/dist/src/run-gateway/store-run-gateway.js +38 -0
  166. package/dist/src/run-gateway/store-run-gateway.js.map +1 -1
  167. package/dist/src/search-attributes-schema.d.ts +44 -0
  168. package/dist/src/search-attributes-schema.d.ts.map +1 -0
  169. package/dist/src/search-attributes-schema.js +51 -0
  170. package/dist/src/search-attributes-schema.js.map +1 -0
  171. package/dist/src/singleton-admission.d.ts +75 -0
  172. package/dist/src/singleton-admission.d.ts.map +1 -0
  173. package/dist/src/singleton-admission.js +156 -0
  174. package/dist/src/singleton-admission.js.map +1 -0
  175. package/dist/src/singleton-gate.d.ts +23 -8
  176. package/dist/src/singleton-gate.d.ts.map +1 -1
  177. package/dist/src/singleton-gate.js +59 -12
  178. package/dist/src/singleton-gate.js.map +1 -1
  179. package/dist/src/standard-schema.d.ts +83 -0
  180. package/dist/src/standard-schema.d.ts.map +1 -0
  181. package/dist/src/standard-schema.js +31 -0
  182. package/dist/src/standard-schema.js.map +1 -0
  183. package/dist/src/step-discovery.d.ts +21 -11
  184. package/dist/src/step-discovery.d.ts.map +1 -1
  185. package/dist/src/step-discovery.js +86 -45
  186. package/dist/src/step-discovery.js.map +1 -1
  187. package/dist/src/step-ref.d.ts +38 -12
  188. package/dist/src/step-ref.d.ts.map +1 -1
  189. package/dist/src/step-ref.js +5 -13
  190. package/dist/src/step-ref.js.map +1 -1
  191. package/dist/src/stores/factory.d.ts +6 -0
  192. package/dist/src/stores/factory.d.ts.map +1 -1
  193. package/dist/src/stores/factory.js +4 -1
  194. package/dist/src/stores/factory.js.map +1 -1
  195. package/dist/src/stores/lucid-helpers.d.ts +2 -0
  196. package/dist/src/stores/lucid-helpers.d.ts.map +1 -1
  197. package/dist/src/stores/lucid-helpers.js +7 -0
  198. package/dist/src/stores/lucid-helpers.js.map +1 -1
  199. package/dist/src/stores/lucid-mappers.d.ts +25 -0
  200. package/dist/src/stores/lucid-mappers.d.ts.map +1 -1
  201. package/dist/src/stores/lucid-mappers.js +81 -18
  202. package/dist/src/stores/lucid-mappers.js.map +1 -1
  203. package/dist/src/stores/lucid-schema.d.ts +59 -1
  204. package/dist/src/stores/lucid-schema.d.ts.map +1 -1
  205. package/dist/src/stores/lucid-schema.js +271 -25
  206. package/dist/src/stores/lucid-schema.js.map +1 -1
  207. package/dist/src/stores/lucid.d.ts +61 -2
  208. package/dist/src/stores/lucid.d.ts.map +1 -1
  209. package/dist/src/stores/lucid.js +346 -41
  210. package/dist/src/stores/lucid.js.map +1 -1
  211. package/dist/src/telescope/dashboard.d.ts.map +1 -1
  212. package/dist/src/telescope/dashboard.js +30 -1
  213. package/dist/src/telescope/dashboard.js.map +1 -1
  214. package/dist/src/telescope/data-providers.d.ts +19 -1
  215. package/dist/src/telescope/data-providers.d.ts.map +1 -1
  216. package/dist/src/telescope/data-providers.js +119 -3
  217. package/dist/src/telescope/data-providers.js.map +1 -1
  218. package/dist/src/telescope/extension.d.ts.map +1 -1
  219. package/dist/src/telescope/extension.js +2 -1
  220. package/dist/src/telescope/extension.js.map +1 -1
  221. package/dist/src/telescope/index.d.ts +1 -1
  222. package/dist/src/telescope/index.d.ts.map +1 -1
  223. package/dist/src/telescope/index.js +1 -1
  224. package/dist/src/telescope/index.js.map +1 -1
  225. package/dist/src/testing/in-memory-state-store.d.ts +44 -15
  226. package/dist/src/testing/in-memory-state-store.d.ts.map +1 -1
  227. package/dist/src/testing/in-memory-state-store.js +204 -41
  228. package/dist/src/testing/in-memory-state-store.js.map +1 -1
  229. package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts +16 -0
  230. package/dist/src/testing-kit/checkpoint-aggregation-contract.d.ts.map +1 -0
  231. package/dist/src/testing-kit/checkpoint-aggregation-contract.js +228 -0
  232. package/dist/src/testing-kit/checkpoint-aggregation-contract.js.map +1 -0
  233. package/dist/src/testing-kit/state-store-conformance.d.ts.map +1 -1
  234. package/dist/src/testing-kit/state-store-conformance.js +355 -3
  235. package/dist/src/testing-kit/state-store-conformance.js.map +1 -1
  236. package/dist/src/transport-pool.d.ts +15 -1
  237. package/dist/src/transport-pool.d.ts.map +1 -1
  238. package/dist/src/transport-pool.js +50 -4
  239. package/dist/src/transport-pool.js.map +1 -1
  240. package/dist/src/transports/adaptive-concurrency.d.ts +168 -0
  241. package/dist/src/transports/adaptive-concurrency.d.ts.map +1 -0
  242. package/dist/src/transports/adaptive-concurrency.js +353 -0
  243. package/dist/src/transports/adaptive-concurrency.js.map +1 -0
  244. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts +1 -165
  245. package/dist/src/transports/bullmq/adaptive-concurrency.d.ts.map +1 -1
  246. package/dist/src/transports/bullmq/adaptive-concurrency.js +3 -351
  247. package/dist/src/transports/bullmq/adaptive-concurrency.js.map +1 -1
  248. package/dist/src/transports/bullmq/bullmq-transport.d.ts +49 -1
  249. package/dist/src/transports/bullmq/bullmq-transport.d.ts.map +1 -1
  250. package/dist/src/transports/bullmq/bullmq-transport.js +330 -38
  251. package/dist/src/transports/bullmq/bullmq-transport.js.map +1 -1
  252. package/dist/src/transports/bullmq/deps.d.ts +18 -2
  253. package/dist/src/transports/bullmq/deps.d.ts.map +1 -1
  254. package/dist/src/transports/bullmq/deps.js +44 -2
  255. package/dist/src/transports/bullmq/deps.js.map +1 -1
  256. package/dist/src/transports/db-helpers.d.ts +39 -0
  257. package/dist/src/transports/db-helpers.d.ts.map +1 -1
  258. package/dist/src/transports/db-helpers.js +73 -0
  259. package/dist/src/transports/db-helpers.js.map +1 -1
  260. package/dist/src/transports/db-remote.d.ts +53 -0
  261. package/dist/src/transports/db-remote.d.ts.map +1 -0
  262. package/dist/src/transports/db-remote.js +147 -0
  263. package/dist/src/transports/db-remote.js.map +1 -0
  264. package/dist/src/transports/db-schema.d.ts +44 -1
  265. package/dist/src/transports/db-schema.d.ts.map +1 -1
  266. package/dist/src/transports/db-schema.js +227 -88
  267. package/dist/src/transports/db-schema.js.map +1 -1
  268. package/dist/src/transports/db.d.ts +189 -6
  269. package/dist/src/transports/db.d.ts.map +1 -1
  270. package/dist/src/transports/db.js +1291 -137
  271. package/dist/src/transports/db.js.map +1 -1
  272. package/dist/src/transports/event-emitter.d.ts +8 -0
  273. package/dist/src/transports/event-emitter.d.ts.map +1 -1
  274. package/dist/src/transports/event-emitter.js +36 -3
  275. package/dist/src/transports/event-emitter.js.map +1 -1
  276. package/dist/src/transports/factory.d.ts +110 -5
  277. package/dist/src/transports/factory.d.ts.map +1 -1
  278. package/dist/src/transports/factory.js +56 -9
  279. package/dist/src/transports/factory.js.map +1 -1
  280. package/dist/src/transports/queue-mock-adapter.d.ts +7 -2
  281. package/dist/src/transports/queue-mock-adapter.d.ts.map +1 -1
  282. package/dist/src/transports/queue-mock-adapter.js +18 -2
  283. package/dist/src/transports/queue-mock-adapter.js.map +1 -1
  284. package/dist/src/transports/queue-remote.d.ts +146 -0
  285. package/dist/src/transports/queue-remote.d.ts.map +1 -0
  286. package/dist/src/transports/queue-remote.js +398 -0
  287. package/dist/src/transports/queue-remote.js.map +1 -0
  288. package/dist/src/transports/queue.d.ts +159 -6
  289. package/dist/src/transports/queue.d.ts.map +1 -1
  290. package/dist/src/transports/queue.js +747 -37
  291. package/dist/src/transports/queue.js.map +1 -1
  292. package/dist/src/transports/task-failure.d.ts +31 -0
  293. package/dist/src/transports/task-failure.d.ts.map +1 -0
  294. package/dist/src/transports/task-failure.js +50 -0
  295. package/dist/src/transports/task-failure.js.map +1 -0
  296. package/dist/src/workflow-als.d.ts +1 -1
  297. package/dist/src/workflow-als.d.ts.map +1 -1
  298. package/dist/src/workflow-ctx.d.ts +10 -0
  299. package/dist/src/workflow-ctx.d.ts.map +1 -1
  300. package/dist/src/workflow-ctx.js +97 -15
  301. package/dist/src/workflow-ctx.js.map +1 -1
  302. package/dist/src/workflow-discovery.d.ts +32 -41
  303. package/dist/src/workflow-discovery.d.ts.map +1 -1
  304. package/dist/src/workflow-discovery.js +65 -88
  305. package/dist/src/workflow-discovery.js.map +1 -1
  306. package/dist/src/workflow-ref.d.ts +24 -0
  307. package/dist/src/workflow-ref.d.ts.map +1 -1
  308. package/dist/src/workflow-ref.js.map +1 -1
  309. package/dist/stubs/config/durable.stub +6 -0
  310. package/dist/stubs/config/durable_dashboard.stub +7 -3
  311. package/dist/stubs/database/migrations/create_durable_tables.stub +2 -1
  312. package/dist/stubs/database/migrations/create_durable_transport_messages.stub +43 -0
  313. package/dist/stubs/database/migrations/create_durable_transport_tables.stub +12 -9
  314. package/dist/stubs/database/migrations/create_durable_transport_worker_keys.stub +1 -1
  315. package/dist/stubs/make/workflow/main.stub +1 -1
  316. package/package.json +6 -4
  317. package/skills/durable-cluster/SKILL.md +80 -11
  318. package/skills/durable-determinism/SKILL.md +11 -5
  319. package/skills/durable-reliability/SKILL.md +77 -37
  320. package/skills/durable-setup/SKILL.md +51 -22
  321. package/skills/durable-transports-stores/SKILL.md +128 -18
  322. package/skills/durable-workflows/SKILL.md +71 -18
  323. package/dist/assets/spa/assets/index-DdB9zxuX.js +0 -16
  324. package/dist/assets/spa/assets/index-DdB9zxuX.js.map +0 -1
@@ -1,7 +1,13 @@
1
1
  import { randomUUID } from 'node:crypto';
2
- import { Pollers } from '../pollers.js';
3
- import { runStepHandler } from '../protocol.js';
2
+ import { atLeastOne, Pollers } from '../pollers.js';
3
+ import { runStepHandler, withTurnHeartbeat } from '../protocol.js';
4
4
  import { sanitizeQueueToken, tenantGroup } from '../tenant-group.js';
5
+ import { isWorkflowTask } from '../workflow-turn.js';
6
+ import { AdaptiveController } from './adaptive-concurrency.js';
7
+ import { workerHeartbeatKey } from './bullmq/naming.js';
8
+ import { heartbeatKeyValue, parseHeartbeatValue, WORKER_HEARTBEAT_INTERVAL_MS, WORKER_HEARTBEAT_TTL_SECONDS, } from './bullmq/serialization.js';
9
+ import { DEFAULT_INBOX_SWEEP_INTERVAL_MS, DEFAULT_INBOX_TTL_MS, DEFAULT_TENANT_SUBSCRIBERS_CACHE_MS, InboxRegistry, inboxRegistrationsQueue, runRepliesQueue, tenantEventsQueue, } from './queue-remote.js';
10
+ import { failedTaskIdentity, fanOutDecision, infrastructureFailure } from './task-failure.js';
5
11
  import { cachedLiveKeys, descriptorsOn, liveGroupsOf, NO_WORKER_KEYS, workerKeyWriter, } from './worker-keys.js';
6
12
  /**
7
13
  * The wire payloads carried as a queue job's `payload`. Everything that crosses the queue is plain
@@ -25,16 +31,31 @@ export function fromJson(value) {
25
31
  }
26
32
  /** How often the engine-side / worker-side poll loops ask the adapter for the next job. */
27
33
  const DEFAULT_POLL_INTERVAL_MS = 200;
34
+ /** Idle backoff for the task, heartbeat and control loops: after consecutive empty polls the sleep
35
+ * doubles up to this many poll intervals (800ms at the default interval), and the first job found
36
+ * resets it. Each handler name has its own task loop, so an idle worker serving many step names
37
+ * would otherwise hit the backend `names × 5` times a second. The results loop is exempt — see
38
+ * {@link QueueTransportOptions.idleBackoffMax}. */
39
+ const DEFAULT_IDLE_BACKOFF_MAX = 4;
28
40
  /** How often (ms) the stalled-job reclaim sweep runs — a coarse, low-frequency check, NOT per poll
29
41
  * tick. Mirrors `DbTransport`'s crash-recovery cadence (its stale-lease default is also 30s). */
30
42
  const DEFAULT_STALLED_CHECK_INTERVAL_MS = 30_000;
31
- /** How old (ms) a claim must be before the sweep presumes its worker dead and re-delivers the job.
32
- * Deliberately generous: re-delivery double-runs a step whose worker is merely slow, so this must
33
- * comfortably exceed the longest legitimate step execution (see the reclaim doc block for why this
34
- * is safe under the durable idempotency contract). The claim's `acquiredAt` is never renewed while
35
- * the worker processes — long-running steps hold one claim for their whole duration — so this
36
- * default clears real steps in the tens of minutes; a step routinely longer should raise it. */
43
+ /** How old (ms) a claim must be before the sweep presumes its worker dead and re-delivers the job, on
44
+ * an adapter that CANNOT renew claims (one without `renewJobs` — every `@adonisjs/queue` driver has it,
45
+ * so in practice a custom adapter). There the claim's `acquiredAt` is never
46
+ * refreshed while the worker processes — a long step holds one claim for its whole duration — so the
47
+ * threshold must comfortably exceed the longest legitimate step: re-delivery double-runs a step whose
48
+ * worker is merely slow (safe under the durable idempotency contract, see the reclaim doc block, but
49
+ * not free). A step routinely longer than this should raise it. */
37
50
  const DEFAULT_STALLED_THRESHOLD_MS = 1_800_000;
51
+ /** The threshold on an adapter that renews claims (`adapter.renewJobs`, on both `@adonisjs/queue` 0.6
52
+ * and 0.7). Every
53
+ * in-flight claim is renewed every half threshold (see {@link QueueTransport.#renewOpenClaims}), so a
54
+ * claim's age measures how long its worker has been SILENT, not how long the step has run: a dead
55
+ * worker's job is re-delivered within about this long, and a healthy long step never is. Larger than
56
+ * `@boringnode/queue`'s own 30s worker default so a step that blocks the event loop for a while (a
57
+ * CPU-heavy body starves the renewal timer) is not mistaken for a dead worker. */
58
+ const DEFAULT_RENEWING_STALLED_THRESHOLD_MS = 120_000;
38
59
  /** How many times a single job may be reclaimed before the adapter fails it permanently instead of
39
60
  * re-delivering — bounds a poison job that stalls every worker it touches. */
40
61
  const DEFAULT_MAX_STALLED_COUNT = 3;
@@ -87,6 +108,12 @@ export function toBrokerPriority(priority) {
87
108
  * is single-consumer (point-to-point), so it is correct for a single engine instance but does NOT
88
109
  * fan out to every pod the way a real pub/sub does.
89
110
  *
111
+ * It also carries remote workflow turns (`dispatchWorkflowTask` / `handleWorkflow` / `onDecision`,
112
+ * streamed step events) and the store-less tenant-pod protocol (start-run, run-request, run replies,
113
+ * tenant events), so a `tenant` pod can reach a `control-plane` over `@adonisjs/queue` alone. Run
114
+ * replies and tenant events, which BullMQ broadcasts on Redis PUB/SUB, ride per-instance inbox queues
115
+ * here — see `queue-remote.ts`.
116
+ *
90
117
  * Run one instance engine-side (`onResult` + `dispatch`) and one per worker process (`handle()`).
91
118
  * The wire payloads are the documented `RemoteTask`/`StepResult` JSON, so non-Node workers on the
92
119
  * same queues interoperate.
@@ -104,6 +131,26 @@ export class QueueTransport {
104
131
  #namespace;
105
132
  #explicitNamespace;
106
133
  #pollIntervalMs;
134
+ #closeTimeoutMs;
135
+ /** Fixed or adaptive task concurrency, plus the in-flight/latency/RSS/CPU telemetry the liveness
136
+ * heartbeat carries. One controller for every task loop of this instance, as on BullMQ. */
137
+ #controller;
138
+ /** Task loops parked on "every slot busy", woken when an adaptive move raises the limit. */
139
+ #limitWaiters = new Set();
140
+ /** The task queues this instance consumes — the reclaim sweep bridges a poison job only on these. */
141
+ #taskQueues = new Set();
142
+ /** Routing tokens this instance beats a liveness key for (one per handled step name). */
143
+ #heartbeatTokens = new Set();
144
+ #workerHeartbeatIntervalMs;
145
+ #workerHeartbeatTimer;
146
+ /** Liveness-key writes still in flight — `close()` lets them land before withdrawing the keys. */
147
+ #livenessWrites = new Set();
148
+ /** Routing tokens whose liveness-key write is still in flight (see `#startWorkerHeartbeat`). */
149
+ #beatsInFlight = new Set();
150
+ #resultConcurrency;
151
+ #idleBackoffMax;
152
+ /** Every job handler still running (any queue) — `close()` waits for these, bounded. */
153
+ #runningJobs = new Set();
107
154
  #stalledThresholdMs;
108
155
  #maxStalledCount;
109
156
  #instanceId;
@@ -124,13 +171,54 @@ export class QueueTransport {
124
171
  * (`stalledCheckIntervalMs <= 0`) or the adapter can't `recoverStalledJobs`. Separate from
125
172
  * {@link #pollers} because the sweep cadence is unrelated to the fast job-poll cadence. */
126
173
  #reclaimPollers;
174
+ /** Renews the claims in {@link #openJobs} every half threshold. Absent when the adapter can't
175
+ * `renewJobs`. */
176
+ #renewTimer;
177
+ /** The renewal round in flight, so a slow adapter never stacks rounds and close() can await it. */
178
+ #renewal;
127
179
  /** When set (see {@link deferConsumers}), {@link QueueTransport.#startLoop} parks its loop here
128
180
  * instead of polling — {@link startConsumers} flushes the parked starts. Dispatching (`pushOn`)
129
181
  * is never deferred; only consumption is. */
130
182
  #consumersDeferred = false;
131
183
  #deferredStarts = [];
132
184
  #workerKeys;
185
+ /** Whether a real key store was configured — without one there is no liveness to report. */
186
+ #hasWorkerKeys;
133
187
  #liveWorkerKeys;
188
+ /** Workflow turn consumers by workflow name — a `workflow`-shaped task job routes here. */
189
+ #workflowTurns = new Map();
190
+ /** Every `onDecision` handler (an engine subscribes one; it applies a decision by run id). */
191
+ #decisionHandlers = new Set();
192
+ #decisionsLoop = false;
193
+ #stepEventsLoop = false;
194
+ #startRunLoop = false;
195
+ #runRequestLoop = false;
196
+ #runRepliesLoop = false;
197
+ #runReplyHandler;
198
+ #tenantEventsLoop = false;
199
+ #registrationsLoop = false;
200
+ #tenantRole = false;
201
+ /** Control-plane side: correlation id (requestId, or a start-run's runId) → the requester's instance
202
+ * id, held while the request's handler runs so {@link publishRunReply} knows whose inbox to target. */
203
+ #replyRoutes = new Map();
204
+ #tenantEventHandlers = new Map();
205
+ /** Serializes {@link publishTenantEvent} so events reach every inbox in the order they were published. */
206
+ #tenantPublishChain = Promise.resolve();
207
+ #inboxes;
208
+ /**
209
+ * Per-group worker health, for `engine.workerHealth()` and the dashboard/Telescope worker panels.
210
+ * Present only when a {@link QueueTransportOptions.workerKeys} store is configured (the
211
+ * `transports.queue()` default) — without one no worker can advertise liveness, and reporting
212
+ * "backlog, zero workers" for every group would be a false alarm.
213
+ *
214
+ * - `depth` is the task queue's PENDING count (`adapter.sizeOf`), the jobs waiting for a worker.
215
+ * Unlike BullMQ's waiting+active+delayed+prioritized it leaves out jobs in flight and delayed
216
+ * retries: the `@adonisjs/queue` adapter contract has no count for those. Priorities are included
217
+ * (they are pending jobs). An adapter without `sizeOf` reports `0`.
218
+ * - `liveWorkers` are the unexpired `${P}-worker-heartbeat:<group>:<instance>` keys, each with the
219
+ * worker's {@link WorkerStatus} when it carries one (this transport's own beats do).
220
+ */
221
+ groupHealth;
134
222
  constructor(options) {
135
223
  // Typed through CompatAdapter so the same code drives the 0.6 (job-id) and 0.7 (lease) contracts.
136
224
  this.#adapter = options.adapter();
@@ -139,7 +227,25 @@ export class QueueTransport {
139
227
  this.#namespace = options.namespace;
140
228
  this.#explicitNamespace = options.namespace !== undefined;
141
229
  this.#pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
142
- this.#stalledThresholdMs = options.stalledThresholdMs ?? DEFAULT_STALLED_THRESHOLD_MS;
230
+ this.#closeTimeoutMs =
231
+ options.closeTimeoutMs !== undefined &&
232
+ Number.isFinite(options.closeTimeoutMs) &&
233
+ options.closeTimeoutMs >= 0
234
+ ? options.closeTimeoutMs
235
+ : 5_000;
236
+ this.#controller = new AdaptiveController({
237
+ ...(options.concurrency !== undefined ? { concurrency: options.concurrency } : {}),
238
+ apply: () => {
239
+ for (const wake of this.#limitWaiters)
240
+ wake();
241
+ },
242
+ });
243
+ this.#resultConcurrency = atLeastOne(options.resultConcurrency, 1);
244
+ this.#idleBackoffMax = atLeastOne(options.idleBackoffMax, DEFAULT_IDLE_BACKOFF_MAX);
245
+ const canRenew = typeof this.#adapter.renewJobs === 'function';
246
+ this.#stalledThresholdMs =
247
+ options.stalledThresholdMs ??
248
+ (canRenew ? DEFAULT_RENEWING_STALLED_THRESHOLD_MS : DEFAULT_STALLED_THRESHOLD_MS);
143
249
  this.#maxStalledCount = options.maxStalledCount ?? DEFAULT_MAX_STALLED_COUNT;
144
250
  this.#instanceId = options.instanceId ?? randomUUID();
145
251
  this.#onError = options.onError ?? DEFAULT_LOG;
@@ -147,13 +253,44 @@ export class QueueTransport {
147
253
  this.#adapter.setWorkerId(this.#instanceId);
148
254
  this.#pollers = new Pollers(this.#pollIntervalMs, this.#onError);
149
255
  this.#reclaimPollers = this.#startReclaimSweep(options.stalledCheckIntervalMs ?? DEFAULT_STALLED_CHECK_INTERVAL_MS);
256
+ if (canRenew)
257
+ this.#startClaimRenewal();
258
+ this.#hasWorkerKeys = options.workerKeys !== undefined;
259
+ if (this.#hasWorkerKeys)
260
+ this.groupHealth = (group) => this.#groupHealth(group);
261
+ this.#workerHeartbeatIntervalMs = atLeastOne(options.workerHeartbeatIntervalMs, WORKER_HEARTBEAT_INTERVAL_MS);
150
262
  this.#workerKeys = options.workerKeys ?? NO_WORKER_KEYS;
151
263
  this.#liveWorkerKeys = cachedLiveKeys(this.#workerKeys, options.workerKeysCacheMs ?? 5_000, Date.now);
264
+ this.#inboxes = new InboxRegistry({
265
+ store: this.#workerKeys,
266
+ prefix: () => this.#effectivePrefix(),
267
+ instanceId: this.#instanceId,
268
+ ttlMs: atLeastOne(options.inboxTtlMs, DEFAULT_INBOX_TTL_MS),
269
+ subscribersCacheMs: options.tenantSubscribersCacheMs ?? DEFAULT_TENANT_SUBSCRIBERS_CACHE_MS,
270
+ sweepIntervalMs: options.inboxSweepIntervalMs ?? DEFAULT_INBOX_SWEEP_INTERVAL_MS,
271
+ announce: (registration) => this.#adapter
272
+ .pushOn(inboxRegistrationsQueue(this.#effectivePrefix()), this.#job('inboxRegistration', registration))
273
+ .then(() => undefined),
274
+ drain: (queue, max) => this.#drainQueue(queue, max),
275
+ onError: this.#onError,
276
+ });
152
277
  }
153
- /** The writer a worker runtime's registry advertises its descriptor and heartbeat through. */
278
+ /** The writer a worker runtime's registry advertises its descriptor and heartbeat through: the key
279
+ * store when this instance has one. A store-less tenant pod (see {@link useTenantRole}) with no key
280
+ * store forwards it to the control plane instead, which records it in its own — so the control plane
281
+ * still sees the pod's workers. Otherwise, with no store, nothing is advertised. */
154
282
  createDescriptorRedis() {
283
+ if (this.#workerKeys === NO_WORKER_KEYS && this.#tenantRole) {
284
+ return this.#inboxes.remoteKeyWriter();
285
+ }
155
286
  return workerKeyWriter(this.#workerKeys, Date.now);
156
287
  }
288
+ /** Declare that this transport backs a store-less `tenant` pod (the provider calls it): worker
289
+ * advertisement is then forwarded to the control plane when this instance has no key store. Only a
290
+ * tenant pod is guaranteed a control plane that records the forwarded keys. */
291
+ useTenantRole() {
292
+ this.#tenantRole = true;
293
+ }
157
294
  /** The live worker descriptors advertised on `token`. */
158
295
  async listWorkerDescriptors(token) {
159
296
  return descriptorsOn(await this.#liveWorkerKeys(), token);
@@ -229,6 +366,18 @@ export class QueueTransport {
229
366
  #controlQueue() {
230
367
  return `${this.#effectivePrefix()}:control`;
231
368
  }
369
+ #decisionsQueue() {
370
+ return `${this.#effectivePrefix()}:decisions`;
371
+ }
372
+ #stepEventsQueue() {
373
+ return `${this.#effectivePrefix()}:step-events`;
374
+ }
375
+ #startRunQueue() {
376
+ return `${this.#effectivePrefix()}:start-run`;
377
+ }
378
+ #runRequestQueue() {
379
+ return `${this.#effectivePrefix()}:run-request`;
380
+ }
232
381
  /**
233
382
  * Build the adapter `JobData` envelope for a JSON-safe payload. A translated `priority` is added
234
383
  * only when set, so the default FIFO path is untouched for unprioritised dispatches.
@@ -261,14 +410,167 @@ export class QueueTransport {
261
410
  */
262
411
  handle(name, fn) {
263
412
  this.#handlers.set(name, fn);
264
- const token = tenantGroup(sanitizeQueueToken(name), this.#partition);
413
+ this.#ensureTaskLoop(tenantGroup(sanitizeQueueToken(name), this.#partition));
414
+ }
415
+ /**
416
+ * Register a WORKFLOW turn consumer (worker side). Shares the step task loop of the workflow name's
417
+ * routing token — the same queue {@link dispatchWorkflowTask} targets — and tells the two job kinds
418
+ * apart BY SHAPE: a `workflow`-shaped job runs `turn(task)` and its {@link WorkflowDecision} is pushed
419
+ * onto `${P}:decisions`. Re-registering swaps the turn fn.
420
+ */
421
+ handleWorkflow(name, turn) {
422
+ this.#workflowTurns.set(name, turn);
423
+ this.#ensureTaskLoop(tenantGroup(sanitizeQueueToken(name), this.#partition));
424
+ }
425
+ /** Start (once per routing token) the task loop shared by {@link handle} and {@link handleWorkflow}. */
426
+ #ensureTaskLoop(token) {
265
427
  if (this.#taskLoops.has(token))
266
428
  return;
267
- this.#taskLoops.set(token, this.#startLoop(this.#tasksQueue(token), async (job) => {
268
- const task = fromJson(job.payload);
269
- const result = await runStepHandler(task, this.#handlers.get(task.name), (beat) => this.heartbeat(beat));
270
- await this.#adapter.pushOn(this.#resultsQueue(), this.#job('result', result));
429
+ const queue = this.#tasksQueue(token);
430
+ this.#taskQueues.add(queue);
431
+ const controller = this.#controller;
432
+ this.#taskLoops.set(token, this.#startLoop(queue, async (job) => {
433
+ const payload = fromJson(job.payload);
434
+ const startedAt = Date.now();
435
+ controller.onStart();
436
+ // Only step durations feed the latency gradient; a workflow turn still counts as in-flight
437
+ // work and as forward progress for stall detection (as on bullmq).
438
+ if (isWorkflowTask(payload)) {
439
+ try {
440
+ await this.#runWorkflowTask(payload);
441
+ }
442
+ finally {
443
+ controller.onSettle(Date.now() - startedAt, true, 'workflow');
444
+ }
445
+ return;
446
+ }
447
+ const task = payload;
448
+ let ok = false;
449
+ try {
450
+ const result = await runStepHandler(task, this.#handlers.get(task.name), (beat) => this.heartbeat(beat));
451
+ await this.#adapter.pushOn(this.#resultsQueue(), this.#job('result', result));
452
+ ok = result.status === 'completed';
453
+ }
454
+ finally {
455
+ controller.onSettle(Date.now() - startedAt, ok, 'step');
456
+ }
457
+ }, {
458
+ // Adaptive: every tick reads the controller's live limit. Fixed: the plain number, so the
459
+ // one-slot loop keeps its historical inline shape.
460
+ concurrency: controller.isAdaptive ? () => controller.limit : controller.limit,
461
+ idleBackoffMax: this.#idleBackoffMax,
271
462
  }));
463
+ // Advertise liveness only once the loop really consumes: a deferred (producer-only) process must
464
+ // not look like a live worker.
465
+ if (this.#consumersDeferred)
466
+ this.#deferredStarts.push(() => this.#beginServing(token));
467
+ else
468
+ this.#beginServing(token);
469
+ }
470
+ /** A task loop for `token` is consuming: run the concurrency controller and beat its liveness key. */
471
+ #beginServing(token) {
472
+ this.#controller.start();
473
+ this.#startWorkerHeartbeat(token);
474
+ }
475
+ // ---------------------------------------------------------------------------
476
+ // worker liveness + health — `${P}-worker-heartbeat:<token>:<instanceId>` in the worker-key store
477
+ // ---------------------------------------------------------------------------
478
+ /**
479
+ * Refresh every served routing token's liveness key on one shared interval (default 10s, live 35s),
480
+ * stamped with the controller's {@link WorkerStatus} snapshot (concurrency mode and live limit,
481
+ * in-flight, RSS/CPU, throughput, p95, last adjustment) — the same `{ ts, status }` value and key
482
+ * the BullMQ transport writes, so the dashboard's worker cards read both alike. A new token beats
483
+ * at once. Best-effort: a failed write goes to `onError` and the key simply expires. A no-op without
484
+ * a configured {@link QueueTransportOptions.workerKeys} store.
485
+ */
486
+ #startWorkerHeartbeat(token) {
487
+ if (!this.#hasWorkerKeys)
488
+ return;
489
+ const ttlMs = Math.max(WORKER_HEARTBEAT_TTL_SECONDS * 1000, this.#workerHeartbeatIntervalMs * 3);
490
+ const beatOne = (t) => {
491
+ // One write per token at a time: a beat due while the last one is still in flight is skipped.
492
+ // Beats are not queued behind a slow store — every interval used to start a write regardless,
493
+ // so a store slower than the interval piled up writes without bound, each holding a pool
494
+ // connection the reads (groupHealth, the claim loop) then waited behind.
495
+ if (this.#beatsInFlight.has(t))
496
+ return;
497
+ this.#beatsInFlight.add(t);
498
+ const now = Date.now();
499
+ const key = workerHeartbeatKey(this.#effectivePrefix(), t, this.#instanceId);
500
+ const value = heartbeatKeyValue(now, this.#controller.snapshot());
501
+ const write = Promise.resolve()
502
+ .then(() => this.#workerKeys.put(key, value, now + ttlMs))
503
+ .catch((err) => this.#onError(err))
504
+ .finally(() => this.#beatsInFlight.delete(t));
505
+ this.#livenessWrites.add(write);
506
+ void write.finally(() => this.#livenessWrites.delete(write));
507
+ };
508
+ const isNew = !this.#heartbeatTokens.has(token);
509
+ this.#heartbeatTokens.add(token);
510
+ if (isNew)
511
+ beatOne(token);
512
+ if (this.#workerHeartbeatTimer)
513
+ return;
514
+ this.#workerHeartbeatTimer = setInterval(() => {
515
+ for (const t of this.#heartbeatTokens)
516
+ beatOne(t);
517
+ }, this.#workerHeartbeatIntervalMs);
518
+ this.#workerHeartbeatTimer.unref?.();
519
+ }
520
+ async #groupHealth(group) {
521
+ const [depth, entries] = await Promise.all([
522
+ this.#adapter.sizeOf ? this.#adapter.sizeOf(this.#tasksQueue(group)) : Promise.resolve(0),
523
+ this.#liveWorkerKeys(),
524
+ ]);
525
+ const marker = workerHeartbeatKey(this.#effectivePrefix(), group, '');
526
+ const liveWorkers = [];
527
+ for (const { key, value } of entries) {
528
+ if (!key.startsWith(marker))
529
+ continue;
530
+ const parsed = parseHeartbeatValue(value);
531
+ liveWorkers.push({
532
+ group,
533
+ instanceId: key.slice(marker.length),
534
+ lastBeatAt: parsed.lastBeatAt,
535
+ // Passed through as-is (worker-authored); a worker runtime's compact `{ ts, status: 'up' }`
536
+ // beat carries no status object and reads as liveness only.
537
+ ...(parsed.status !== undefined
538
+ ? { status: parsed.status }
539
+ : {}),
540
+ });
541
+ }
542
+ return { group, depth: Number(depth) || 0, liveWorkers };
543
+ }
544
+ /** Replay one workflow turn and push its decision. An unknown workflow or a throwing turn still
545
+ * yields a `failed` decision so the run settles; a failed push throws, so the job is redelivered. */
546
+ async #runWorkflowTask(task) {
547
+ let decision;
548
+ const turn = this.#workflowTurns.get(task.workflow);
549
+ try {
550
+ decision = turn
551
+ ? await withTurnHeartbeat(task, (beat) => this.heartbeat(beat), () => turn(task))
552
+ : {
553
+ taskId: task.taskId,
554
+ runId: task.runId,
555
+ status: 'failed',
556
+ commands: [],
557
+ error: {
558
+ message: `no workflow registered for '${task.workflow}'`,
559
+ code: 'no_workflow',
560
+ },
561
+ };
562
+ }
563
+ catch (err) {
564
+ this.#onError(err);
565
+ decision = {
566
+ taskId: task.taskId,
567
+ runId: task.runId,
568
+ status: 'failed',
569
+ commands: [],
570
+ error: { message: err instanceof Error ? err.message : String(err) },
571
+ };
572
+ }
573
+ await this.#adapter.pushOn(this.#decisionsQueue(), this.#job('decision', decision));
272
574
  }
273
575
  /** Worker side: publish a liveness heartbeat for an in-flight long step. */
274
576
  async heartbeat(beat) {
@@ -280,12 +582,12 @@ export class QueueTransport {
280
582
  onResult(handler) {
281
583
  this.#startLoop(this.#resultsQueue(), async (job) => {
282
584
  await handler(fromJson(job.payload));
283
- });
585
+ }, { concurrency: this.#resultConcurrency, idleBackoffMax: 1 });
284
586
  }
285
587
  onHeartbeat(handler) {
286
588
  this.#startLoop(this.#heartbeatsQueue(), async (job) => {
287
589
  await handler(fromJson(job.payload));
288
- });
590
+ }, { idleBackoffMax: this.#idleBackoffMax });
289
591
  }
290
592
  // ---------------------------------------------------------------------------
291
593
  // control plane (best-effort, point-to-point)
@@ -297,7 +599,243 @@ export class QueueTransport {
297
599
  onControl(handler) {
298
600
  this.#startLoop(this.#controlQueue(), async (job) => {
299
601
  handler(fromJson(job.payload));
602
+ }, { idleBackoffMax: this.#idleBackoffMax });
603
+ }
604
+ // ---------------------------------------------------------------------------
605
+ // remote workflows — engine ⇄ workflow worker (point-to-point, like the step queues)
606
+ // ---------------------------------------------------------------------------
607
+ /** engine → workflow worker: a {@link WorkflowTask} on the workflow's routing-token task queue (job
608
+ * `workflow`), the queue a {@link handleWorkflow} consumer polls. */
609
+ async dispatchWorkflowTask(task) {
610
+ await this.#adapter.pushOn(this.#tasksQueue(task.group), this.#job('workflow', task, task.priority));
611
+ }
612
+ /**
613
+ * engine ← workflow worker: consume {@link WorkflowDecision}s from `${P}:decisions`. ONE loop per
614
+ * transport; every registered handler sees every decision (the engine applies one by run id and drops
615
+ * any its run no longer awaits), so several remote workflows can share the transport.
616
+ */
617
+ onDecision(handler) {
618
+ this.#decisionHandlers.add(handler);
619
+ if (this.#decisionsLoop)
620
+ return;
621
+ this.#decisionsLoop = true;
622
+ this.#startLoop(this.#decisionsQueue(), async (job) => {
623
+ const decision = fromJson(job.payload);
624
+ // Every handler, concurrently; all settle before a failure redelivers the decision.
625
+ await fanOutDecision(this.#decisionHandlers, decision, this.#onError);
626
+ }, { idleBackoffMax: 1 });
627
+ }
628
+ /** workflow worker → engine: a local step's lifecycle on `${P}:step-events` (job `stepEvent`),
629
+ * point-to-point so ONE engine instance checkpoints each event once. */
630
+ async dispatchStepEvent(event) {
631
+ await this.#adapter.pushOn(this.#stepEventsQueue(), this.#job('stepEvent', event));
632
+ }
633
+ /** engine ← workflow worker: consume streamed step events. Idempotent — a second call is a no-op. */
634
+ onStepEvent(handler) {
635
+ if (this.#stepEventsLoop)
636
+ return;
637
+ this.#stepEventsLoop = true;
638
+ this.#startLoop(this.#stepEventsQueue(), async (job) => {
639
+ await handler(fromJson(job.payload));
640
+ }, { idleBackoffMax: this.#idleBackoffMax });
641
+ }
642
+ // ---------------------------------------------------------------------------
643
+ // store-less protocol (P4) — tenant pod ⇄ control plane
644
+ //
645
+ // start-run and run-request are point-to-point queues consumed by the control plane, exactly as on
646
+ // BullMQ. Run replies and tenant events — Redis PUB/SUB on BullMQ — go to per-instance inbox queues
647
+ // instead, since a queue job has one consumer: a reply to the requester's inbox (its id rides the
648
+ // request as `replyTo`), a tenant event to the inbox of each live subscriber of that tenant (read from
649
+ // the worker-key store). See `queue-remote.ts` for the design and its limits.
650
+ // ---------------------------------------------------------------------------
651
+ /** tenant → control plane: enqueue a {@link StartRunMessage} on `${P}:start-run` (job `startRun`),
652
+ * stamped with this instance as the reply target. */
653
+ async dispatchStartRun(msg) {
654
+ this.#ensureRunRepliesLoop();
655
+ const payload = { ...msg, replyTo: this.#instanceId };
656
+ await this.#adapter.pushOn(this.#startRunQueue(), this.#job('startRun', payload));
657
+ }
658
+ /** control plane ← tenant: consume {@link StartRunMessage}s. Idempotent — a second call is a no-op. */
659
+ onStartRun(handler) {
660
+ this.#ensureRegistrationsLoop();
661
+ if (this.#startRunLoop)
662
+ return;
663
+ this.#startRunLoop = true;
664
+ this.#startLoop(this.#startRunQueue(), async (job) => {
665
+ const { replyTo, ...msg } = fromJson(job.payload);
666
+ await this.#withReplyRoute(msg.runId, replyTo, () => handler(msg));
667
+ }, { idleBackoffMax: this.#idleBackoffMax });
668
+ }
669
+ /** tenant → control plane: enqueue a {@link RunRequest} on `${P}:run-request` (job `runRequest`),
670
+ * stamped with this instance as the reply target. */
671
+ async dispatchRunRequest(msg) {
672
+ this.#ensureRunRepliesLoop();
673
+ const payload = { ...msg, replyTo: this.#instanceId };
674
+ await this.#adapter.pushOn(this.#runRequestQueue(), this.#job('runRequest', payload));
675
+ }
676
+ /** control plane ← tenant: consume {@link RunRequest}s. Idempotent — a second call is a no-op. */
677
+ onRunRequest(handler) {
678
+ this.#ensureRegistrationsLoop();
679
+ if (this.#runRequestLoop)
680
+ return;
681
+ this.#runRequestLoop = true;
682
+ this.#startLoop(this.#runRequestQueue(), async (job) => {
683
+ const { replyTo, ...msg } = fromJson(job.payload);
684
+ await this.#withReplyRoute(msg.requestId, replyTo, () => handler(msg));
685
+ }, { idleBackoffMax: this.#idleBackoffMax });
686
+ }
687
+ /** Remember who asked for `correlationId` while `work` (the responder, which replies inside it) runs. */
688
+ async #withReplyRoute(correlationId, replyTo, work) {
689
+ if (correlationId === undefined || replyTo === undefined)
690
+ return work();
691
+ this.#replyRoutes.set(correlationId, replyTo);
692
+ try {
693
+ await work();
694
+ }
695
+ finally {
696
+ if (this.#replyRoutes.get(correlationId) === replyTo)
697
+ this.#replyRoutes.delete(correlationId);
698
+ }
699
+ }
700
+ /**
701
+ * control plane → tenant: push a correlated {@link RunReply} onto the inbox of the instance that sent
702
+ * the request (`${P}:run-replies:<instance>`). Only the instance that consumed the request knows the
703
+ * requester, so this must run while that request is being handled — which is what the responder does.
704
+ * A reply with no known requester (a request that carried no `replyTo`) is dropped and reported.
705
+ */
706
+ async publishRunReply(reply) {
707
+ const owner = this.#replyRoutes.get(reply.requestId);
708
+ if (owner === undefined) {
709
+ this.#onError(new Error(`[QueueTransport] dropped the run reply for "${reply.requestId}": no requester is known for it on this instance`));
710
+ return;
711
+ }
712
+ const queue = runRepliesQueue(this.#effectivePrefix(), owner);
713
+ await this.#adapter.pushOn(queue, this.#job('runReply', reply));
714
+ this.#inboxes.notePush(queue, owner);
715
+ }
716
+ /**
717
+ * tenant ← control plane: consume this instance's reply inbox. Idempotent — a second call is a no-op
718
+ * (one gateway per transport matches every reply by id). The inbox is private to this instance, so it
719
+ * is consumed even when consumers are deferred: no other process could take its jobs.
720
+ */
721
+ onRunReply(handler) {
722
+ if (this.#runReplyHandler)
723
+ return;
724
+ this.#runReplyHandler = handler;
725
+ this.#ensureRunRepliesLoop();
726
+ }
727
+ /** Start consuming the reply inbox (once). Also started on the first request, so a reply is never
728
+ * left for a dead queue just because the gateway subscribed late. */
729
+ #ensureRunRepliesLoop() {
730
+ if (this.#runRepliesLoop)
731
+ return;
732
+ this.#runRepliesLoop = true;
733
+ this.#inboxes.activateInbox();
734
+ // On the critical path of every proxied call: never back off (like the results loop).
735
+ this.#reallyStartLoop(runRepliesQueue(this.#effectivePrefix(), this.#instanceId), async (job) => {
736
+ const reply = fromJson(job.payload);
737
+ try {
738
+ this.#runReplyHandler?.(reply);
739
+ }
740
+ catch (err) {
741
+ this.#onError(err); // a throwing listener must not redeliver the reply
742
+ }
743
+ }, { idleBackoffMax: 1 });
744
+ }
745
+ /**
746
+ * control plane → tenant: push a {@link TenantEvent} onto the inbox of every instance with a live
747
+ * subscription to `evt.tenant` (none: nothing is pushed). Publishes are serialized, so each
748
+ * subscriber receives one publisher's events in publish order.
749
+ */
750
+ publishTenantEvent(evt) {
751
+ this.#ensureRegistrationsLoop();
752
+ const run = this.#tenantPublishChain.then(async () => {
753
+ const owners = await this.#inboxes.subscribersOf(evt.tenant);
754
+ for (const owner of owners) {
755
+ const queue = tenantEventsQueue(this.#effectivePrefix(), owner);
756
+ await this.#adapter.pushOn(queue, this.#job('tenantEvent', evt));
757
+ this.#inboxes.notePush(queue, owner);
758
+ }
300
759
  });
760
+ this.#tenantPublishChain = run.catch(() => undefined);
761
+ return run;
762
+ }
763
+ /**
764
+ * control plane: record the inbox registrations instances announce (their subscriptions and inbox
765
+ * liveness) in this instance's worker-key store, where {@link publishTenantEvent} and the janitor read
766
+ * them. Started by whichever control-plane surface comes up first (the responder's consumers, or the
767
+ * first publish). Point-to-point: control-plane replicas share the store, so one recording suffices.
768
+ */
769
+ #ensureRegistrationsLoop() {
770
+ if (this.#registrationsLoop)
771
+ return;
772
+ this.#registrationsLoop = true;
773
+ this.#startLoop(inboxRegistrationsQueue(this.#effectivePrefix()), (job) => this.#inboxes.record(fromJson(job.payload)), { idleBackoffMax: this.#idleBackoffMax });
774
+ }
775
+ /**
776
+ * tenant ← control plane: subscribe to `tenant`'s events. Announces the subscription to the control
777
+ * plane (re-announced every third of {@link QueueTransportOptions.inboxTtlMs} while it lasts) and
778
+ * consumes this instance's event inbox. Needs no store on this side. Returns an unsubscribe fn that
779
+ * detaches only this handler; the last one withdraws the subscription.
780
+ */
781
+ onTenantEvent(tenant, handler) {
782
+ let handlers = this.#tenantEventHandlers.get(tenant);
783
+ if (!handlers) {
784
+ handlers = new Set();
785
+ this.#tenantEventHandlers.set(tenant, handlers);
786
+ }
787
+ handlers.add(handler);
788
+ this.#inboxes.subscribe(tenant);
789
+ this.#ensureTenantEventsLoop();
790
+ return () => {
791
+ const set = this.#tenantEventHandlers.get(tenant);
792
+ if (!set?.delete(handler) || set.size > 0)
793
+ return;
794
+ this.#tenantEventHandlers.delete(tenant);
795
+ this.#inboxes.unsubscribe(tenant);
796
+ };
797
+ }
798
+ /** Consume the tenant-event inbox (once; private to this instance, so never deferred). Keeps running
799
+ * after the last unsubscribe so events pushed before the withdrawal are drained, not left behind. */
800
+ #ensureTenantEventsLoop() {
801
+ if (this.#tenantEventsLoop)
802
+ return;
803
+ this.#tenantEventsLoop = true;
804
+ this.#reallyStartLoop(tenantEventsQueue(this.#effectivePrefix(), this.#instanceId), async (job) => {
805
+ const evt = fromJson(job.payload);
806
+ for (const h of this.#tenantEventHandlers.get(evt.tenant) ?? []) {
807
+ try {
808
+ h(evt);
809
+ }
810
+ catch (err) {
811
+ this.#onError(err); // a throwing listener must not redeliver (and reorder) the event
812
+ }
813
+ }
814
+ }, { idleBackoffMax: this.#idleBackoffMax });
815
+ }
816
+ /** Withdraw this instance's advertisements, then drop what is left in its own inboxes: replies and
817
+ * events addressed to a process that is going away have no one else to go to. */
818
+ async #closeInboxes() {
819
+ await this.#inboxes.close();
820
+ const prefix = this.#effectivePrefix();
821
+ const own = [
822
+ ...(this.#runRepliesLoop ? [runRepliesQueue(prefix, this.#instanceId)] : []),
823
+ ...(this.#tenantEventsLoop ? [tenantEventsQueue(prefix, this.#instanceId)] : []),
824
+ ];
825
+ for (const queue of own) {
826
+ await this.#drainQueue(queue, 1_000).catch((err) => this.#onError(err));
827
+ }
828
+ }
829
+ /** Pop and finalize up to `max` jobs of `queue` without running them (the janitor, and close()).
830
+ * Resolves `true` once the queue came back empty. */
831
+ async #drainQueue(queue, max) {
832
+ for (let i = 0; i < max; i += 1) {
833
+ const job = await this.#adapter.popFrom(queue);
834
+ if (!job)
835
+ return true;
836
+ await this.#adapter.completeJob(jobHandle(job), queue);
837
+ }
838
+ return false;
301
839
  }
302
840
  // ---------------------------------------------------------------------------
303
841
  // poll loop
@@ -317,7 +855,7 @@ export class QueueTransport {
317
855
  * reconcile recovery instead of wedging or losing the result.) A job nobody can settle loops at
318
856
  * the poll rate and says so on every attempt, which is the failure we want — loud, not invisible.
319
857
  */
320
- #startLoop(queue, onJob) {
858
+ #startLoop(queue, onJob, opts = {}) {
321
859
  // Deferred consumption (see deferConsumers): park the start; startConsumers() flushes it. The
322
860
  // handle returned now still stops the loop whether it is flushed later or never.
323
861
  if (this.#consumersDeferred) {
@@ -325,7 +863,7 @@ export class QueueTransport {
325
863
  let live;
326
864
  this.#deferredStarts.push(() => {
327
865
  if (!cancelled)
328
- live = this.#reallyStartLoop(queue, onJob);
866
+ live = this.#reallyStartLoop(queue, onJob, opts);
329
867
  });
330
868
  return {
331
869
  stop: () => {
@@ -334,12 +872,23 @@ export class QueueTransport {
334
872
  },
335
873
  };
336
874
  }
337
- return this.#reallyStartLoop(queue, onJob);
875
+ return this.#reallyStartLoop(queue, onJob, opts);
338
876
  }
339
- #reallyStartLoop(queue, onJob) {
877
+ #reallyStartLoop(queue, onJob, { concurrency = 1, idleBackoffMax }) {
340
878
  // Record every queue we consume from so the reclaim sweep can walk exactly this set.
341
879
  this.#poppedQueues.add(queue);
880
+ // This loop's jobs still in a handler. With one fixed slot the tick awaits its job (the historical
881
+ // shape); otherwise it hands each job off and keeps popping until every slot is taken, so N
882
+ // slots cost ONE poll loop, not N. A live (adaptive) limit is re-read on every tick.
883
+ const limit = typeof concurrency === 'function' ? concurrency : () => concurrency;
884
+ const inFlight = new Set();
342
885
  return this.#pollers.start(async () => {
886
+ if (inFlight.size >= limit()) {
887
+ // Every slot busy: wait for one to free (or the limit to move), then report work so the
888
+ // loop re-ticks at once.
889
+ await this.#slotFreed(inFlight);
890
+ return true;
891
+ }
343
892
  let job;
344
893
  try {
345
894
  job = await this.#adapter.popFrom(queue);
@@ -350,11 +899,42 @@ export class QueueTransport {
350
899
  }
351
900
  if (!job)
352
901
  return false;
353
- // Track the popped-but-unfinished job so close() can hand it back to the broker instead of
354
- // destroying the adapter under it (which stranded it `active` until the stalled sweep —
355
- // every deploy of a busy fleet parked its mid-flight jobs for up to the stalled threshold).
356
- const handle = jobHandle(job);
357
- this.#openJobs.set(job.id, { queue, handle });
902
+ const run = this.#runJob(queue, job, onJob);
903
+ if (concurrency === 1) {
904
+ await run;
905
+ return true;
906
+ }
907
+ inFlight.add(run);
908
+ void run.finally(() => inFlight.delete(run));
909
+ return true;
910
+ }, idleBackoffMax === undefined ? undefined : { idleBackoffMax });
911
+ }
912
+ /** Resolve when one of `inFlight` settles or the adaptive controller moves the limit. */
913
+ async #slotFreed(inFlight) {
914
+ let wake = () => { };
915
+ const moved = new Promise((resolve) => {
916
+ wake = resolve;
917
+ });
918
+ this.#limitWaiters.add(wake);
919
+ try {
920
+ await Promise.race([...inFlight, moved]);
921
+ }
922
+ finally {
923
+ this.#limitWaiters.delete(wake);
924
+ }
925
+ }
926
+ /**
927
+ * Run one popped job and finalize it: complete on success, hand back for redelivery on a throw.
928
+ * Tracked in {@link #openJobs} (renewed, and handed back by `close()` if unfinished) and in
929
+ * {@link #runningJobs} (waited for by `close()`, bounded).
930
+ */
931
+ #runJob(queue, job, onJob) {
932
+ // Track the popped-but-unfinished job so close() can hand it back to the broker instead of
933
+ // destroying the adapter under it (which stranded it `active` until the stalled sweep —
934
+ // every deploy of a busy fleet parked its mid-flight jobs for up to the stalled threshold).
935
+ const handle = jobHandle(job);
936
+ this.#openJobs.set(job.id, { queue, handle });
937
+ const run = (async () => {
358
938
  try {
359
939
  await onJob(job);
360
940
  // A lease-based adapter answers `false` when the lease was lost (the reclaim sweep re-delivered
@@ -372,8 +952,10 @@ export class QueueTransport {
372
952
  finally {
373
953
  this.#openJobs.delete(job.id);
374
954
  }
375
- return true;
376
- });
955
+ })();
956
+ this.#runningJobs.add(run);
957
+ void run.finally(() => this.#runningJobs.delete(run));
958
+ return run;
377
959
  }
378
960
  // ---------------------------------------------------------------------------
379
961
  // stalled-job reclaim
@@ -395,8 +977,9 @@ export class QueueTransport {
395
977
  * engine ignores results for a checkpoint that already completed — so re-running a reclaimed step, or
396
978
  * re-delivering the two production shapes (result published but `completeJob` never reached; or claimed
397
979
  * and died before any result), cannot corrupt a run. The only cost is wasted work when a merely-slow
398
- * worker's job is re-delivered, which is why {@link DEFAULT_STALLED_THRESHOLD_MS} is deliberately
399
- * generous. A permanently lost job is a worse failure than a rare double-run of an idempotent step, so
980
+ * worker's job is re-delivered — which claim renewal ({@link #startClaimRenewal}) rules out on an
981
+ * adapter that supports it, and which is why {@link DEFAULT_STALLED_THRESHOLD_MS} is deliberately
982
+ * generous on one that doesn't. A permanently lost job is a worse failure than a rare double-run of an idempotent step, so
400
983
  * this defaults ON; set `stalledCheckIntervalMs: 0` to opt out.
401
984
  *
402
985
  * Feature-detected: an adapter that doesn't implement `recoverStalledJobs` (not every driver does)
@@ -415,13 +998,10 @@ export class QueueTransport {
415
998
  // `@adonisjs/queue` 0.7 (`@boringnode/queue` >= 0.8) no longer fails a job that stalled too
416
999
  // many times: it keeps it `active`, re-leases it to us and returns it in `exceeded`, so the
417
1000
  // caller fails it. Left alone it would stay `active` forever. 0.6 returns a count and has
418
- // already failed them — nothing to do.
1001
+ // already failed (deleted) them — nothing to do, and nothing left to bridge.
419
1002
  if (outcome && typeof outcome === 'object') {
420
- for (const job of outcome.exceeded) {
421
- await this.#adapter
422
- .failJob(jobHandle(job), queue, new Error(`stalled more than ${this.#maxStalledCount} times`))
423
- .catch((err) => this.#onError(err));
424
- }
1003
+ for (const job of outcome.exceeded)
1004
+ await this.#failPoisonJob(queue, job);
425
1005
  }
426
1006
  }
427
1007
  catch (err) {
@@ -432,6 +1012,100 @@ export class QueueTransport {
432
1012
  });
433
1013
  return pollers;
434
1014
  }
1015
+ /**
1016
+ * Fail a job that stalled more than `maxStalledCount` times and, for a STEP task, publish a failed
1017
+ * {@link StepResult} for it — the queue twin of the BullMQ transport's `worker.on('failed')` bridge.
1018
+ * No handler produced a result for that step (its workers kept dying), so without one the run would
1019
+ * wait on its `pending` checkpoint forever. The result is RETRYABLE: the engine re-dispatches while
1020
+ * the step has attempts left, then fails it into the workflow's error path. Only after `failJob`
1021
+ * succeeded (a lost lease means another sweep owns the job), and only for task queues: a stalled
1022
+ * result/heartbeat/control job carries no step to fail, and a workflow-turn payload has no step
1023
+ * identity, so both are just failed.
1024
+ */
1025
+ async #failPoisonJob(queue, job) {
1026
+ const reason = `stalled more than ${this.#maxStalledCount} times`;
1027
+ let failed;
1028
+ try {
1029
+ failed = await this.#adapter.failJob(jobHandle(job), queue, new Error(reason));
1030
+ }
1031
+ catch (err) {
1032
+ this.#onError(err);
1033
+ return;
1034
+ }
1035
+ if (failed === false || !this.#taskQueues.has(queue))
1036
+ return;
1037
+ let identity;
1038
+ try {
1039
+ identity = failedTaskIdentity(fromJson(job.payload));
1040
+ }
1041
+ catch {
1042
+ return; // an unparseable payload: nothing safe to publish
1043
+ }
1044
+ if (!identity)
1045
+ return;
1046
+ try {
1047
+ await this.#adapter.pushOn(this.#resultsQueue(), this.#job('result', infrastructureFailure(identity, reason)));
1048
+ }
1049
+ catch (err) {
1050
+ this.#onError(err);
1051
+ }
1052
+ }
1053
+ // ---------------------------------------------------------------------------
1054
+ // claim renewal
1055
+ // ---------------------------------------------------------------------------
1056
+ /**
1057
+ * Renew every claim this instance is still working on, each half {@link #stalledThresholdMs} —
1058
+ * the heartbeat `@boringnode/queue`'s own `Worker` runs, which this transport bypasses (see the
1059
+ * class header) exactly as it bypasses that class's stalled recovery.
1060
+ *
1061
+ * WHY: without it, a claim's age is the step's elapsed runtime, so the threshold had to exceed the
1062
+ * longest step — a 30min default that also left a dead worker's job unrecovered for 30min, while a
1063
+ * step that legitimately ran longer was re-delivered and ran twice. Renewing makes the age measure
1064
+ * the worker's silence instead, so a short threshold recovers crashes fast without ever re-running a
1065
+ * healthy long step.
1066
+ *
1067
+ * Each claim is renewed with the handle it is finalized with (lease on 0.9, job id on 0.6). The timer is `unref`'d, and a round still in flight skips the next tick instead of stacking.
1068
+ */
1069
+ #startClaimRenewal() {
1070
+ const intervalMs = Math.max(Math.floor(this.#stalledThresholdMs / 2), 1);
1071
+ this.#renewTimer = setInterval(() => {
1072
+ if (this.#renewal)
1073
+ return;
1074
+ const round = this.#renewOpenClaims().finally(() => {
1075
+ if (this.#renewal === round)
1076
+ this.#renewal = undefined;
1077
+ });
1078
+ this.#renewal = round;
1079
+ }, intervalMs);
1080
+ this.#renewTimer.unref?.();
1081
+ }
1082
+ async #renewOpenClaims() {
1083
+ if (this.#openJobs.size === 0)
1084
+ return;
1085
+ const byQueue = new Map();
1086
+ for (const { queue, handle } of this.#openJobs.values()) {
1087
+ const leases = byQueue.get(queue) ?? [];
1088
+ leases.push(handle);
1089
+ byQueue.set(queue, leases);
1090
+ }
1091
+ for (const [queue, leases] of byQueue) {
1092
+ try {
1093
+ const renewed = await this.#adapter.renewJobs?.(queue, leases);
1094
+ // A job that finished normally during the round-trip was finalized, not lost: count only the
1095
+ // claims still open under the same handle, or a healthy completion would raise a false alarm.
1096
+ const stillHeld = leases.filter((lease) => this.#openJobs.get(typeof lease === 'string' ? lease : lease.id)?.handle === lease).length;
1097
+ // Fewer renewed than still held: the sweep already re-delivered those claims (this worker went
1098
+ // silent past the threshold), so another worker may be running the same step right now. The
1099
+ // handler can't be aborted — durable steps are idempotent — but the double run must be loud.
1100
+ if (typeof renewed === 'number' && renewed < stillHeld) {
1101
+ this.#onError(new Error(`[QueueTransport] lost ${stillHeld - renewed} of ${stillHeld} claim(s) on "${queue}" before renewing them — the jobs were re-delivered and may run twice; raise stalledThresholdMs if this worker blocks its event loop`));
1102
+ }
1103
+ }
1104
+ catch (err) {
1105
+ this.#onError(err);
1106
+ }
1107
+ }
1108
+ }
435
1109
  /**
436
1110
  * Stop every poll loop, WAIT (bounded) for the job already in a handler's hands, and hand any
437
1111
  * still-unfinished job back to the broker before destroying the adapter. Without the wait, a
@@ -443,13 +1117,49 @@ export class QueueTransport {
443
1117
  this.#pollers.stopAll();
444
1118
  this.#reclaimPollers?.stopAll();
445
1119
  this.#taskLoops.clear();
446
- await this.#pollers.drain(5_000);
1120
+ this.#controller.stop();
1121
+ if (this.#workerHeartbeatTimer)
1122
+ clearInterval(this.#workerHeartbeatTimer);
1123
+ // A clean stop withdraws this instance's liveness keys at once, so the dashboard and
1124
+ // `listWorkerGroups` drop it now instead of after the 35s TTL. In-flight beats land first, or one
1125
+ // could re-create a key after its removal.
1126
+ if (this.#heartbeatTokens.size > 0) {
1127
+ await Promise.allSettled([...this.#livenessWrites]);
1128
+ const keys = [...this.#heartbeatTokens].map((t) => workerHeartbeatKey(this.#effectivePrefix(), t, this.#instanceId));
1129
+ this.#heartbeatTokens.clear();
1130
+ await Promise.resolve()
1131
+ .then(() => this.#workerKeys.remove(keys))
1132
+ .catch((err) => this.#onError(err));
1133
+ }
1134
+ this.#workerHeartbeatTimer = undefined;
1135
+ const drainDeadline = Date.now() + this.#closeTimeoutMs;
1136
+ await this.#pollers.drain(this.#closeTimeoutMs);
1137
+ // Jobs handed off by a multi-slot loop are not part of a poll tick: wait for them too, within the
1138
+ // same budget.
1139
+ if (this.#runningJobs.size > 0) {
1140
+ const left = Math.max(drainDeadline - Date.now(), 0);
1141
+ let timer;
1142
+ await Promise.race([
1143
+ Promise.allSettled([...this.#runningJobs]),
1144
+ new Promise((r) => {
1145
+ timer = setTimeout(r, left);
1146
+ }),
1147
+ ]);
1148
+ clearTimeout(timer);
1149
+ }
1150
+ // Renewal keeps running through the drain (a job finishing inside it must keep its claim), then
1151
+ // stops before the leftovers are handed back.
1152
+ if (this.#renewTimer)
1153
+ clearInterval(this.#renewTimer);
1154
+ this.#renewTimer = undefined;
1155
+ await this.#renewal;
447
1156
  // Anything still open after the bounded drain (a long step): re-deliver it now so another
448
1157
  // worker picks it up immediately instead of waiting out the stalled threshold.
449
1158
  for (const [jobId, { queue, handle }] of [...this.#openJobs]) {
450
1159
  this.#openJobs.delete(jobId);
451
1160
  await this.#adapter.retryJob(handle, queue, new Date()).catch((err) => this.#onError(err));
452
1161
  }
1162
+ await this.#closeInboxes();
453
1163
  await this.#adapter.destroy();
454
1164
  }
455
1165
  }