velocious 1.0.514 → 1.0.516

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 (255) hide show
  1. package/README.md +4 -3
  2. package/build/background-jobs/cron-expression.js +9 -9
  3. package/build/background-jobs/forked-runner-child.js +1 -1
  4. package/build/background-jobs/job-runner.js +21 -15
  5. package/build/background-jobs/json-socket.js +4 -0
  6. package/build/background-jobs/main.js +180 -18
  7. package/build/background-jobs/status-reporter.js +12 -5
  8. package/build/background-jobs/store.js +132 -50
  9. package/build/background-jobs/types.js +11 -3
  10. package/build/background-jobs/worker.js +9 -5
  11. package/build/beacon/client.js +13 -6
  12. package/build/configuration.js +54 -29
  13. package/build/controller.js +9 -3
  14. package/build/database/drivers/sqlite/base.js +1 -1
  15. package/build/database/drivers/sqlite/index.js +15 -14
  16. package/build/database/migrations-ledger.js +16 -16
  17. package/build/database/pool/async-tracked-multi-connection.js +16 -0
  18. package/build/database/pool/base.js +26 -0
  19. package/build/database/query/where-model-class-hash.js +2 -0
  20. package/build/database/query/with-count.js +1 -1
  21. package/build/database/record/acts-as-list.js +2 -2
  22. package/build/database/record/auditing.js +23 -6
  23. package/build/database/record/index.js +7 -7
  24. package/build/database/record/validators/uniqueness.js +3 -3
  25. package/build/database/tenants/data-copier.js +23 -23
  26. package/build/database/tenants/schema-cloner.js +27 -27
  27. package/build/environment-handlers/browser.js +0 -1
  28. package/build/environment-handlers/node/cli/commands/background-jobs-runner.js +2 -1
  29. package/build/environment-handlers/node/cli/commands/lint/relationships.js +2 -2
  30. package/build/environment-handlers/node.js +1 -1
  31. package/build/frontend-model-controller.js +26 -18
  32. package/build/frontend-model-resource/base-resource.js +4 -4
  33. package/build/frontend-models/base.js +5 -5
  34. package/build/frontend-models/query.js +12 -12
  35. package/build/http-server/client/index.js +61 -15
  36. package/build/http-server/client/request-runner.js +7 -1
  37. package/build/http-server/client/response.js +17 -1
  38. package/build/http-server/client/websocket-session.js +13 -13
  39. package/build/http-server/server-client.js +96 -0
  40. package/build/http-server/websocket-channel.js +12 -11
  41. package/build/http-server/websocket-connection.js +12 -11
  42. package/build/http-server/websocket-events-host.js +1 -1
  43. package/build/http-server/worker-handler/in-process.js +48 -9
  44. package/build/http-server/worker-handler/index.js +67 -7
  45. package/build/http-server/worker-handler/worker-thread.js +68 -0
  46. package/build/src/background-jobs/cron-expression.d.ts +2 -2
  47. package/build/src/background-jobs/cron-expression.js +10 -10
  48. package/build/src/background-jobs/forked-runner-child.js +2 -2
  49. package/build/src/background-jobs/job-runner.d.ts +5 -1
  50. package/build/src/background-jobs/job-runner.d.ts.map +1 -1
  51. package/build/src/background-jobs/job-runner.js +22 -16
  52. package/build/src/background-jobs/json-socket.d.ts +4 -0
  53. package/build/src/background-jobs/json-socket.d.ts.map +1 -1
  54. package/build/src/background-jobs/json-socket.js +5 -1
  55. package/build/src/background-jobs/main.d.ts +62 -10
  56. package/build/src/background-jobs/main.d.ts.map +1 -1
  57. package/build/src/background-jobs/main.js +171 -19
  58. package/build/src/background-jobs/status-reporter.d.ts +6 -2
  59. package/build/src/background-jobs/status-reporter.d.ts.map +1 -1
  60. package/build/src/background-jobs/status-reporter.js +13 -6
  61. package/build/src/background-jobs/store.d.ts +48 -25
  62. package/build/src/background-jobs/store.d.ts.map +1 -1
  63. package/build/src/background-jobs/store.js +123 -50
  64. package/build/src/background-jobs/types.d.ts +36 -3
  65. package/build/src/background-jobs/types.d.ts.map +1 -1
  66. package/build/src/background-jobs/types.js +12 -4
  67. package/build/src/background-jobs/worker.d.ts +7 -5
  68. package/build/src/background-jobs/worker.d.ts.map +1 -1
  69. package/build/src/background-jobs/worker.js +10 -6
  70. package/build/src/beacon/client.d.ts +6 -3
  71. package/build/src/beacon/client.d.ts.map +1 -1
  72. package/build/src/beacon/client.js +14 -7
  73. package/build/src/configuration-types.d.ts +1 -1
  74. package/build/src/configuration.d.ts +42 -29
  75. package/build/src/configuration.d.ts.map +1 -1
  76. package/build/src/configuration.js +52 -30
  77. package/build/src/controller.d.ts +3 -0
  78. package/build/src/controller.d.ts.map +1 -1
  79. package/build/src/controller.js +9 -4
  80. package/build/src/database/drivers/sqlite/base.js +2 -2
  81. package/build/src/database/drivers/sqlite/index.d.ts +7 -6
  82. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  83. package/build/src/database/drivers/sqlite/index.js +16 -15
  84. package/build/src/database/migrations-ledger.d.ts +16 -16
  85. package/build/src/database/migrations-ledger.js +17 -17
  86. package/build/src/database/pool/async-tracked-multi-connection.d.ts +0 -11
  87. package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
  88. package/build/src/database/pool/async-tracked-multi-connection.js +16 -1
  89. package/build/src/database/pool/base.d.ts +21 -0
  90. package/build/src/database/pool/base.d.ts.map +1 -1
  91. package/build/src/database/pool/base.js +24 -1
  92. package/build/src/database/query/where-model-class-hash.d.ts.map +1 -1
  93. package/build/src/database/query/where-model-class-hash.js +3 -1
  94. package/build/src/database/query/with-count.js +2 -2
  95. package/build/src/database/record/acts-as-list.js +3 -3
  96. package/build/src/database/record/auditing.d.ts +1 -1
  97. package/build/src/database/record/auditing.d.ts.map +1 -1
  98. package/build/src/database/record/auditing.js +24 -7
  99. package/build/src/database/record/index.d.ts +7 -7
  100. package/build/src/database/record/index.js +8 -8
  101. package/build/src/database/record/validators/uniqueness.d.ts +3 -3
  102. package/build/src/database/record/validators/uniqueness.js +4 -4
  103. package/build/src/database/tenants/data-copier.d.ts +18 -18
  104. package/build/src/database/tenants/data-copier.js +24 -24
  105. package/build/src/database/tenants/schema-cloner.d.ts +27 -27
  106. package/build/src/database/tenants/schema-cloner.js +28 -28
  107. package/build/src/environment-handlers/browser.d.ts +9 -0
  108. package/build/src/environment-handlers/browser.d.ts.map +1 -1
  109. package/build/src/environment-handlers/browser.js +1 -2
  110. package/build/src/environment-handlers/node/cli/commands/background-jobs-runner.d.ts.map +1 -1
  111. package/build/src/environment-handlers/node/cli/commands/background-jobs-runner.js +3 -2
  112. package/build/src/environment-handlers/node/cli/commands/lint/relationships.js +3 -3
  113. package/build/src/environment-handlers/node.js +2 -2
  114. package/build/src/frontend-model-controller.d.ts +9 -8
  115. package/build/src/frontend-model-controller.d.ts.map +1 -1
  116. package/build/src/frontend-model-controller.js +26 -19
  117. package/build/src/frontend-model-resource/base-resource.js +5 -5
  118. package/build/src/frontend-models/base.d.ts +4 -4
  119. package/build/src/frontend-models/base.d.ts.map +1 -1
  120. package/build/src/frontend-models/base.js +6 -6
  121. package/build/src/frontend-models/query.d.ts +7 -7
  122. package/build/src/frontend-models/query.js +13 -13
  123. package/build/src/http-server/client/index.d.ts +23 -1
  124. package/build/src/http-server/client/index.d.ts.map +1 -1
  125. package/build/src/http-server/client/index.js +55 -17
  126. package/build/src/http-server/client/request-runner.d.ts.map +1 -1
  127. package/build/src/http-server/client/request-runner.js +8 -2
  128. package/build/src/http-server/client/response.d.ts +11 -1
  129. package/build/src/http-server/client/response.d.ts.map +1 -1
  130. package/build/src/http-server/client/response.js +16 -2
  131. package/build/src/http-server/client/websocket-session.d.ts +13 -13
  132. package/build/src/http-server/client/websocket-session.js +14 -14
  133. package/build/src/http-server/server-client.d.ts +13 -0
  134. package/build/src/http-server/server-client.d.ts.map +1 -1
  135. package/build/src/http-server/server-client.js +94 -1
  136. package/build/src/http-server/websocket-channel.d.ts +12 -11
  137. package/build/src/http-server/websocket-channel.d.ts.map +1 -1
  138. package/build/src/http-server/websocket-channel.js +13 -12
  139. package/build/src/http-server/websocket-connection.d.ts +12 -11
  140. package/build/src/http-server/websocket-connection.d.ts.map +1 -1
  141. package/build/src/http-server/websocket-connection.js +13 -12
  142. package/build/src/http-server/websocket-events-host.d.ts +1 -1
  143. package/build/src/http-server/websocket-events-host.js +2 -2
  144. package/build/src/http-server/worker-handler/in-process.d.ts +2 -0
  145. package/build/src/http-server/worker-handler/in-process.d.ts.map +1 -1
  146. package/build/src/http-server/worker-handler/in-process.js +42 -11
  147. package/build/src/http-server/worker-handler/index.d.ts +21 -0
  148. package/build/src/http-server/worker-handler/index.d.ts.map +1 -1
  149. package/build/src/http-server/worker-handler/index.js +61 -7
  150. package/build/src/http-server/worker-handler/worker-thread.d.ts +30 -0
  151. package/build/src/http-server/worker-handler/worker-thread.d.ts.map +1 -1
  152. package/build/src/http-server/worker-handler/worker-thread.js +61 -1
  153. package/build/src/sync/device-identity.d.ts +2 -2
  154. package/build/src/sync/device-identity.d.ts.map +1 -1
  155. package/build/src/sync/device-identity.js +4 -4
  156. package/build/src/sync/local-mutation-log.d.ts +0 -1
  157. package/build/src/sync/local-mutation-log.d.ts.map +1 -1
  158. package/build/src/sync/local-mutation-log.js +1 -2
  159. package/build/src/sync/query-scope.d.ts +5 -0
  160. package/build/src/sync/query-scope.d.ts.map +1 -1
  161. package/build/src/sync/query-scope.js +7 -2
  162. package/build/src/sync/sync-client-types.d.ts +2 -2
  163. package/build/src/sync/sync-client-types.d.ts.map +1 -1
  164. package/build/src/sync/sync-client-types.js +1 -1
  165. package/build/src/sync/sync-client.d.ts +17 -2
  166. package/build/src/sync/sync-client.d.ts.map +1 -1
  167. package/build/src/sync/sync-client.js +28 -14
  168. package/build/src/sync/sync-realtime-bridge.d.ts +6 -2
  169. package/build/src/sync/sync-realtime-bridge.d.ts.map +1 -1
  170. package/build/src/sync/sync-realtime-bridge.js +14 -3
  171. package/build/src/sync/sync-resource-base.d.ts +14 -3
  172. package/build/src/sync/sync-resource-base.d.ts.map +1 -1
  173. package/build/src/sync/sync-resource-base.js +20 -4
  174. package/build/src/sync/sync-scope-store.d.ts +3 -3
  175. package/build/src/sync/sync-scope-store.d.ts.map +1 -1
  176. package/build/src/sync/sync-scope-store.js +7 -5
  177. package/build/src/sync/sync-websocket-channel.d.ts +10 -0
  178. package/build/src/sync/sync-websocket-channel.d.ts.map +1 -1
  179. package/build/src/sync/sync-websocket-channel.js +20 -3
  180. package/build/src/tenants/tenant-iterator.d.ts +8 -8
  181. package/build/src/tenants/tenant-iterator.js +9 -9
  182. package/build/src/tenants/tenant.d.ts +6 -6
  183. package/build/src/tenants/tenant.js +7 -7
  184. package/build/src/testing/test-runner.d.ts +16 -0
  185. package/build/src/testing/test-runner.d.ts.map +1 -1
  186. package/build/src/testing/test-runner.js +79 -19
  187. package/build/sync/device-identity.js +3 -3
  188. package/build/sync/local-mutation-log.js +0 -1
  189. package/build/sync/query-scope.js +6 -1
  190. package/build/sync/sync-client-types.js +1 -1
  191. package/build/sync/sync-client.js +28 -15
  192. package/build/sync/sync-realtime-bridge.js +13 -2
  193. package/build/sync/sync-resource-base.js +20 -3
  194. package/build/sync/sync-scope-store.js +6 -4
  195. package/build/sync/sync-websocket-channel.js +18 -2
  196. package/build/tenants/tenant-iterator.js +8 -8
  197. package/build/tenants/tenant.js +6 -6
  198. package/build/testing/test-runner.js +86 -20
  199. package/package.json +2 -2
  200. package/src/background-jobs/cron-expression.js +9 -9
  201. package/src/background-jobs/forked-runner-child.js +1 -1
  202. package/src/background-jobs/job-runner.js +21 -15
  203. package/src/background-jobs/json-socket.js +4 -0
  204. package/src/background-jobs/main.js +180 -18
  205. package/src/background-jobs/status-reporter.js +12 -5
  206. package/src/background-jobs/store.js +132 -50
  207. package/src/background-jobs/types.js +11 -3
  208. package/src/background-jobs/worker.js +9 -5
  209. package/src/beacon/client.js +13 -6
  210. package/src/configuration.js +54 -29
  211. package/src/controller.js +9 -3
  212. package/src/database/drivers/sqlite/base.js +1 -1
  213. package/src/database/drivers/sqlite/index.js +15 -14
  214. package/src/database/migrations-ledger.js +16 -16
  215. package/src/database/pool/async-tracked-multi-connection.js +16 -0
  216. package/src/database/pool/base.js +26 -0
  217. package/src/database/query/where-model-class-hash.js +2 -0
  218. package/src/database/query/with-count.js +1 -1
  219. package/src/database/record/acts-as-list.js +2 -2
  220. package/src/database/record/auditing.js +23 -6
  221. package/src/database/record/index.js +7 -7
  222. package/src/database/record/validators/uniqueness.js +3 -3
  223. package/src/database/tenants/data-copier.js +23 -23
  224. package/src/database/tenants/schema-cloner.js +27 -27
  225. package/src/environment-handlers/browser.js +0 -1
  226. package/src/environment-handlers/node/cli/commands/background-jobs-runner.js +2 -1
  227. package/src/environment-handlers/node/cli/commands/lint/relationships.js +2 -2
  228. package/src/environment-handlers/node.js +1 -1
  229. package/src/frontend-model-controller.js +26 -18
  230. package/src/frontend-model-resource/base-resource.js +4 -4
  231. package/src/frontend-models/base.js +5 -5
  232. package/src/frontend-models/query.js +12 -12
  233. package/src/http-server/client/index.js +61 -15
  234. package/src/http-server/client/request-runner.js +7 -1
  235. package/src/http-server/client/response.js +17 -1
  236. package/src/http-server/client/websocket-session.js +13 -13
  237. package/src/http-server/server-client.js +96 -0
  238. package/src/http-server/websocket-channel.js +12 -11
  239. package/src/http-server/websocket-connection.js +12 -11
  240. package/src/http-server/websocket-events-host.js +1 -1
  241. package/src/http-server/worker-handler/in-process.js +48 -9
  242. package/src/http-server/worker-handler/index.js +67 -7
  243. package/src/http-server/worker-handler/worker-thread.js +68 -0
  244. package/src/sync/device-identity.js +3 -3
  245. package/src/sync/local-mutation-log.js +0 -1
  246. package/src/sync/query-scope.js +6 -1
  247. package/src/sync/sync-client-types.js +1 -1
  248. package/src/sync/sync-client.js +28 -15
  249. package/src/sync/sync-realtime-bridge.js +13 -2
  250. package/src/sync/sync-resource-base.js +20 -3
  251. package/src/sync/sync-scope-store.js +6 -4
  252. package/src/sync/sync-websocket-channel.js +18 -2
  253. package/src/tenants/tenant-iterator.js +8 -8
  254. package/src/tenants/tenant.js +6 -6
  255. package/src/testing/test-runner.js +86 -20
package/README.md CHANGED
@@ -23,7 +23,7 @@
23
23
  * Per-record ability checks via `.abilities(...)` on frontend queries + `record.can(action)` (see [docs/abilities.md](docs/abilities.md))
24
24
  * Translated model attributes with current-locale relationship sorting (see [docs/translations.md](docs/translations.md))
25
25
  * Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
26
- * Configurable HTTP server worker handlers for higher request and websocket throughput (see [docs/http-server.md](docs/http-server.md))
26
+ * Configurable HTTP server worker handlers plus backpressured, descriptor-only file responses with completion callbacks (see [docs/http-server.md](docs/http-server.md))
27
27
  * Background jobs with failure events for production reporting (see [docs/background-jobs.md](docs/background-jobs.md))
28
28
  * Rails-style request and database query logging (see [docs/logging.md](docs/logging.md))
29
29
  * EJS-backed mailers with delivery, queueing, and payload rendering support (see [docs/mailers.md](docs/mailers.md))
@@ -590,6 +590,7 @@ Frontend-model HTTP requests always use `credentials: "include"` so shared custo
590
590
 
591
591
  Unexpected frontend-model endpoint failures stay client-safe in production with `errorMessage: "Request failed."`.
592
592
  Invalid client query descriptors, such as unknown `select`, `where`, `search`, `joins`, `preload`, `group`, `sort`, `pluck`, or Ransack attributes, return the specific frontend-model query error message with `velocious.code: "frontend-model-query-error"` and are not emitted as framework errors.
593
+ Invalid frontend-model write attributes and attachment names, including attributes rejected by `permittedParams()`, return the specific safe error message with `velocious.code: "frontend-model-attribute-error"` and are not emitted as framework errors.
593
594
  In `development` and `test`, Velocious also includes `debugErrorClass`, `debugErrorMessage`, and `debugBacktrace` fields so browser/system-test failures are easier to diagnose without exposing those details in production.
594
595
  Other non-production environments, such as `staging`, keep the same client-safe default unless you explicitly opt in with `exposeInternalErrorsToClients: true`:
595
596
 
@@ -1986,7 +1987,7 @@ VELOCIOUS_BACKGROUND_JOBS_WORKER_SHUTDOWN_TIMEOUT_MS=indefinite
1986
1987
 
1987
1988
  `maxConcurrentInlineJobs` (default: `4`) caps how many `executionMode: "inline"` jobs a single `background-jobs-worker` process runs in parallel. Concurrency is at the JS event-loop level: every job in flight shares the worker's process and DB connection pool, so the cap should fit the pool, not the CPU count. Forking remains the right tool when you need memory isolation across long-running jobs or want to use more cores. The older `forked: false` option still maps to inline mode.
1988
1989
 
1989
- `maxConcurrentForkedJobs` (default: `4`) caps how many out-of-process `executionMode: "forked"` or `executionMode: "spawned"` jobs one worker may keep in flight. Forked jobs use `child_process.fork()` with an attached IPC channel and exit after the job runner closes its framework resources. Spawned jobs use the legacy `background-jobs-runner` CLI process via `child_process.spawn()` and are only for callers that intentionally want that spawned behavior.
1990
+ `maxConcurrentForkedJobs` (default: `4`) caps how many out-of-process `executionMode: "forked"` or `executionMode: "spawned"` jobs one worker may keep in flight. Forked jobs use `child_process.fork()` with an attached IPC channel. After the main process acknowledges their durable status report, forked and spawned one-shot runners exit without waiting for graceful Beacon/database teardown; the OS closes their process-owned resources. A missing or rejected status acknowledgement makes the runner exit as failed instead of reporting clean success. Spawned jobs use the legacy `background-jobs-runner` CLI process via `child_process.spawn()` and are only for callers that intentionally want that spawned behavior.
1990
1991
 
1991
1992
  ### Dispatch strategy
1992
1993
 
@@ -2107,7 +2108,7 @@ Each job must define exactly one of `every` or `cron`. Cron times are evaluated
2107
2108
 
2108
2109
  ## Persistence and retries
2109
2110
 
2110
- Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the job is marked as handed off and the worker reports completion or failure back to the main process.
2111
+ Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the job is marked as handed off with a unique lease id and the worker reports completion or failure back to the main process. If that worker socket disconnects unexpectedly, only the leases handed to that exact socket are immediately returned to the queue; late reports are fenced by lease id so they cannot mutate a newer attempt. This recovery is at-least-once and may repeat application side effects if the disconnected attempt had already started them. Gracefully draining workers keep their leases while they finish. For rolling upgrades, upgrade workers before the main process: a lease-aware main keeps legacy workers connected for old reports but dispatches new jobs only to workers advertising lease-reporting support. See [docs/background-jobs.md](docs/background-jobs.md#worker-disconnect-recovery).
2111
2112
 
2112
2113
  Failed jobs are re-queued with backoff and retried up to 10 times by default (10s, 1m, 10m, 1h, then +1h per retry). You can override the retry limit per job:
2113
2114
 
@@ -52,7 +52,7 @@ const FIELDS = [
52
52
  /**
53
53
  * Runs the parseCronExpression helper.
54
54
  * @param {string} expression - Cron expression or shortcut.
55
- * @returns {ParsedCron}
55
+ * @returns {ParsedCron} - Parsed cron schedule fields.
56
56
  */
57
57
  export function parseCronExpression(expression) {
58
58
  if (typeof expression !== "string" || !expression.trim()) {
@@ -87,8 +87,8 @@ export function parseCronExpression(expression) {
87
87
 
88
88
  /**
89
89
  * Runs normalize day of week.
90
- * @param {Set<number>} dayOfWeek
91
- * @returns {Set<number>}
90
+ * @param {Set<number>} dayOfWeek - Day-of-week values.
91
+ * @returns {Set<number>} - Normalized day-of-week values.
92
92
  */
93
93
  function normalizeDayOfWeek(dayOfWeek) {
94
94
  if (dayOfWeek.has(7)) {
@@ -104,7 +104,7 @@ function normalizeDayOfWeek(dayOfWeek) {
104
104
  * @param {string} field - Field expression.
105
105
  * @param {{name: string, min: number, max: number, names?: string[]}} fieldSpec - Field spec.
106
106
  * @param {string} expression - Whole cron expression for error messages.
107
- * @returns {Set<number>}
107
+ * @returns {Set<number>} - Parsed allowed field values.
108
108
  */
109
109
  function parseField(field, fieldSpec, expression) {
110
110
  const result = new Set()
@@ -147,7 +147,7 @@ function addPartValues(part, fieldSpec, expression, result) {
147
147
  * @param {string} value - Step value.
148
148
  * @param {{name: string, min: number, max: number}} fieldSpec - Field spec.
149
149
  * @param {string} expression - Original expression for errors.
150
- * @returns {number}
150
+ * @returns {number} - Parsed positive step size.
151
151
  */
152
152
  function parseStep(value, fieldSpec, expression) {
153
153
  const step = Number(value)
@@ -165,7 +165,7 @@ function parseStep(value, fieldSpec, expression) {
165
165
  * @param {{name: string, min: number, max: number, names?: string[]}} fieldSpec - Field spec.
166
166
  * @param {string} expression - Original expression for errors.
167
167
  * @param {boolean} hasStep - Whether the part had a `/step` suffix.
168
- * @returns {[number, number]}
168
+ * @returns {[number, number]} - Inclusive field range.
169
169
  */
170
170
  function parseRange(rangePart, fieldSpec, expression, hasStep) {
171
171
  if (rangePart === "*") {
@@ -196,7 +196,7 @@ function parseRange(rangePart, fieldSpec, expression, hasStep) {
196
196
  * @param {string} rawValue - Raw value (may be a name).
197
197
  * @param {{name: string, min: number, max: number, names?: string[]}} fieldSpec - Field spec.
198
198
  * @param {string} expression - Original expression for errors.
199
- * @returns {number}
199
+ * @returns {number} - Parsed numeric field value.
200
200
  */
201
201
  function parseValue(rawValue, fieldSpec, expression) {
202
202
  if (!rawValue) {
@@ -230,7 +230,7 @@ const MAX_NEXT_FIRE_ITERATIONS = 5 * 366 * 24 * 60
230
230
  * real time (e.g., `0 0 31 2 *` — Feb 31st).
231
231
  * @param {ParsedCron} parsed - Parsed cron expression.
232
232
  * @param {Date} from - Reference Date — the next match is strictly after this.
233
- * @returns {Date}
233
+ * @returns {Date} - Next date matching the expression.
234
234
  */
235
235
  export function nextCronFireDate(parsed, from) {
236
236
  const candidate = new Date(from.getTime())
@@ -251,7 +251,7 @@ export function nextCronFireDate(parsed, from) {
251
251
  * Runs candidate matches.
252
252
  * @param {Date} candidate - Candidate Date (in local time).
253
253
  * @param {ParsedCron} parsed - Parsed expression.
254
- * @returns {boolean}
254
+ * @returns {boolean} - Whether the candidate matches the parsed schedule.
255
255
  */
256
256
  function candidateMatches(candidate, parsed) {
257
257
  if (!parsed.minute.has(candidate.getMinutes())) return false
@@ -51,7 +51,7 @@ async function runJobMessage(message) {
51
51
  throw new Error("Forked background job runner received invalid payload")
52
52
  }
53
53
 
54
- await runJobPayload(message.payload)
54
+ await runJobPayload(message.payload, {closeConnections: false})
55
55
  }
56
56
 
57
57
  /**
@@ -50,9 +50,11 @@ async function connectBeacon(configuration) {
50
50
  /**
51
51
  * Runs run job payload.
52
52
  * @param {import("./types.js").BackgroundJobPayload} payload - Payload.
53
+ * @param {object} [options] - Runner options.
54
+ * @param {boolean} [options.closeConnections] - Whether to gracefully close framework connections after the job.
53
55
  * @returns {Promise<void>} - Resolves when complete.
54
56
  */
55
- export default async function runJobPayload(payload) {
57
+ export default async function runJobPayload(payload, {closeConnections = true} = {}) {
56
58
  const configuration = await configurationResolver()
57
59
  configuration.setCurrent()
58
60
  await configuration.initialize({type: "background-jobs-runner"})
@@ -73,22 +75,13 @@ export default async function runJobPayload(payload) {
73
75
  await configuration.withConnections({name: `Background job runner: ${payload.jobName}`}, async () => {
74
76
  await perform.apply(jobInstance, payload.args || [])
75
77
  })
76
-
77
- if (payload.id) {
78
- await reporter.reportWithRetry({
79
- jobId: payload.id,
80
- status: "completed",
81
- workerId: payload.workerId,
82
- handedOffAtMs: payload.handedOffAtMs,
83
- maxDurationMs: 30000
84
- })
85
- }
86
78
  } catch (error) {
87
79
  if (payload.id) {
88
80
  await reporter.reportWithRetry({
89
81
  jobId: payload.id,
90
82
  status: "failed",
91
83
  error,
84
+ handoffId: payload.handoffId,
92
85
  workerId: payload.workerId,
93
86
  handedOffAtMs: payload.handedOffAtMs,
94
87
  maxDurationMs: 30000
@@ -97,11 +90,24 @@ export default async function runJobPayload(payload) {
97
90
 
98
91
  throw error
99
92
  }
93
+
94
+ if (payload.id) {
95
+ await reporter.reportWithRetry({
96
+ jobId: payload.id,
97
+ status: "completed",
98
+ handoffId: payload.handoffId,
99
+ workerId: payload.workerId,
100
+ handedOffAtMs: payload.handedOffAtMs,
101
+ maxDurationMs: 30000
102
+ })
103
+ }
100
104
  } finally {
101
- try {
102
- await configuration.disconnectBeacon()
103
- } finally {
104
- await configuration.closeDatabaseConnections()
105
+ if (closeConnections) {
106
+ try {
107
+ await configuration.disconnectBeacon()
108
+ } finally {
109
+ await configuration.closeDatabaseConnections()
110
+ }
105
111
  }
106
112
  }
107
113
  }
@@ -14,6 +14,10 @@ export default class JsonSocket extends EventEmitter {
14
14
  * Narrows the runtime value to the documented type.
15
15
  * @type {string | undefined} */
16
16
  this.workerId = undefined
17
+ /**
18
+ * Narrows the runtime value to the documented type.
19
+ * @type {boolean} */
20
+ this.supportsHandoffIdReporting = false
17
21
  /**
18
22
  * Narrows the runtime value to the documented type.
19
23
  * @type {boolean} */
@@ -64,25 +64,29 @@ export default class BackgroundJobsMain {
64
64
  * Narrows the runtime value to the documented type.
65
65
  * @type {Set<JsonSocket>} */
66
66
  this.readyWorkers = new Set()
67
+ /**
68
+ * Active durable handoffs keyed by the exact worker socket that received them.
69
+ * @type {Map<JsonSocket, Map<string, string>>} */
70
+ this.workerHandoffs = new Map()
67
71
  /**
68
72
  * Narrows the runtime value to the documented type.
69
73
  * @type {net.Server | undefined} */
70
74
  this.server = undefined
71
75
  /**
72
76
  * Narrows the runtime value to the documented type.
73
- * @type {NodeJS.Timeout | undefined} */
77
+ * @type {ReturnType<typeof setTimeout> | undefined} */
74
78
  this._pollTimer = undefined
75
79
  /**
76
80
  * Narrows the runtime value to the documented type.
77
- * @type {NodeJS.Timeout | undefined} */
81
+ * @type {ReturnType<typeof setTimeout> | undefined} */
78
82
  this._scheduledTimer = undefined
79
83
  /**
80
84
  * Narrows the runtime value to the documented type.
81
- * @type {NodeJS.Timeout | undefined} */
85
+ * @type {ReturnType<typeof setTimeout> | undefined} */
82
86
  this._errorRetryTimer = undefined
83
87
  /**
84
88
  * Narrows the runtime value to the documented type.
85
- * @type {NodeJS.Timeout | undefined} */
89
+ * @type {ReturnType<typeof setTimeout> | undefined} */
86
90
  this._orphanTimer = undefined
87
91
  /**
88
92
  * Narrows the runtime value to the documented type.
@@ -270,7 +274,7 @@ export default class BackgroundJobsMain {
270
274
  _setupDispatchTriggers() {
271
275
  if (this.dispatchStrategy === "polling") {
272
276
  this._pollTimer = setInterval(() => {
273
- void this._drain()
277
+ void this._retryAfterError()
274
278
  }, this.pollIntervalMs)
275
279
  return
276
280
  }
@@ -330,11 +334,12 @@ export default class BackgroundJobsMain {
330
334
  * @type {import("./types.js").BackgroundJobSocketRole | null} */
331
335
  let role = null
332
336
 
337
+ let cleanedUp = false
333
338
  const cleanup = () => {
334
- if (role === "worker") {
335
- this.workers.delete(jsonSocket)
336
- this.readyWorkers.delete(jsonSocket)
337
- }
339
+ if (cleanedUp) return
340
+ cleanedUp = true
341
+
342
+ if (role === "worker") void this._handleWorkerSocketClosed(jsonSocket)
338
343
  }
339
344
 
340
345
  jsonSocket.on("close", cleanup)
@@ -377,7 +382,9 @@ export default class BackgroundJobsMain {
377
382
 
378
383
  if (message.role === "worker") {
379
384
  jsonSocket.workerId = message.workerId
385
+ jsonSocket.supportsHandoffIdReporting = message.supportsHandoffIdReporting === true
380
386
  this.workers.add(jsonSocket)
387
+ this.workerHandoffs.set(jsonSocket, new Map())
381
388
  }
382
389
 
383
390
  return message.role
@@ -446,7 +453,11 @@ export default class BackgroundJobsMain {
446
453
  jsonSocket.acceptsSpawnedJobs = message.acceptsSpawned !== false && message.acceptsForked !== false
447
454
  jsonSocket.acceptsForkedJobs = message.acceptsForked !== false
448
455
  jsonSocket.acceptsInlineJobs = message.acceptsInline !== false
449
- this.readyWorkers.add(jsonSocket)
456
+ if (jsonSocket.supportsHandoffIdReporting) {
457
+ this.readyWorkers.add(jsonSocket)
458
+ } else {
459
+ this.readyWorkers.delete(jsonSocket)
460
+ }
450
461
  void this._drain()
451
462
  }
452
463
 
@@ -463,6 +474,98 @@ export default class BackgroundJobsMain {
463
474
  this.readyWorkers.delete(jsonSocket)
464
475
  }
465
476
 
477
+ /**
478
+ * Removes a lost worker socket and releases only leases dispatched through it.
479
+ * @param {JsonSocket} worker - Disconnected worker socket.
480
+ * @returns {Promise<void>} - Resolves after its active leases are released.
481
+ */
482
+ async _handleWorkerSocketClosed(worker) {
483
+ this.workers.delete(worker)
484
+ this.readyWorkers.delete(worker)
485
+
486
+ if (this._stopped) {
487
+ this.workerHandoffs.delete(worker)
488
+ return
489
+ }
490
+
491
+ try {
492
+ await this._releaseWorkerHandoffs(worker)
493
+ } catch (error) {
494
+ this._reportHandoffReleaseError(error)
495
+ this._scheduleErrorRetry()
496
+ }
497
+ }
498
+
499
+ /**
500
+ * Releases all leases still owned by one exact worker socket.
501
+ * @param {JsonSocket} worker - Worker socket.
502
+ * @returns {Promise<void>} - Resolves after fenced releases and dispatch wake-up.
503
+ */
504
+ async _releaseWorkerHandoffs(worker) {
505
+ const handoffs = this.workerHandoffs.get(worker)
506
+
507
+ if (!handoffs || handoffs.size === 0) {
508
+ this.workerHandoffs.delete(worker)
509
+ return
510
+ }
511
+
512
+ for (const [jobId, handoffId] of handoffs) {
513
+ await this._releaseHandoff({handoffId, jobId, worker})
514
+ }
515
+
516
+ this.workerHandoffs.delete(worker)
517
+ this._notifyEnqueued()
518
+ await this._drain()
519
+ }
520
+
521
+ /**
522
+ * Runs one idempotent conditional lease release.
523
+ * @param {object} args - Options.
524
+ * @param {string} args.handoffId - Handoff lease id.
525
+ * @param {string} args.jobId - Job id.
526
+ * @param {JsonSocket} args.worker - Socket that received the lease.
527
+ * @returns {Promise<void>} - Resolves after the fenced transition.
528
+ */
529
+ async _releaseHandoff({handoffId, jobId, worker}) {
530
+ await this.store.markReturnedToQueue({handoffId, jobId})
531
+
532
+ const handoffs = this.workerHandoffs.get(worker)
533
+
534
+ if (handoffs?.get(jobId) === handoffId) handoffs.delete(jobId)
535
+ }
536
+
537
+ /**
538
+ * Forgets a successfully reported lease without relying on worker ids.
539
+ * @param {object} args - Options.
540
+ * @param {string} args.handoffId - Handoff lease id.
541
+ * @param {string} args.jobId - Job id.
542
+ * @returns {void}
543
+ */
544
+ _forgetHandoff({handoffId, jobId}) {
545
+ for (const [worker, handoffs] of this.workerHandoffs) {
546
+ if (handoffs.get(jobId) !== handoffId) continue
547
+
548
+ handoffs.delete(jobId)
549
+ if (handoffs.size === 0 && !this.workers.has(worker)) this.workerHandoffs.delete(worker)
550
+ return
551
+ }
552
+ }
553
+
554
+ /**
555
+ * Reports an unexpected lease-release failure on framework error channels.
556
+ * @param {?} error - Release failure.
557
+ * @returns {void}
558
+ */
559
+ _reportHandoffReleaseError(error) {
560
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
561
+ const payload = {context: {stage: "background-job-handoff-release"}, error: normalizedError}
562
+ const errorEvents = this.configuration.getErrorEvents()
563
+
564
+ this.logger.error(() => ["Failed to release disconnected worker handoffs:", normalizedError])
565
+ errorEvents.emit("framework-error", payload)
566
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
567
+ }
568
+
466
569
  /**
467
570
  * Runs handle enqueue.
468
571
  * @param {object} args - Options.
@@ -496,11 +599,15 @@ export default class BackgroundJobsMain {
496
599
  */
497
600
  async _handleJobComplete({jsonSocket, message}) {
498
601
  try {
499
- await this.store.markCompleted({
602
+ const accepted = await this.store.markCompleted({
500
603
  jobId: message.jobId,
604
+ handoffId: message.handoffId,
501
605
  workerId: message.workerId,
502
606
  handedOffAtMs: message.handedOffAtMs
503
607
  })
608
+ if (accepted && message.handoffId) {
609
+ this._forgetHandoff({handoffId: message.handoffId, jobId: message.jobId})
610
+ }
504
611
  jsonSocket.send({type: "job-updated", jobId: message.jobId})
505
612
  } catch (error) {
506
613
  this.logger.error(() => ["Failed to update job completion:", error])
@@ -520,13 +627,18 @@ export default class BackgroundJobsMain {
520
627
  const failedJob = await this.store.markFailed({
521
628
  jobId: message.jobId,
522
629
  error: message.error,
630
+ handoffId: message.handoffId,
523
631
  workerId: message.workerId,
524
632
  handedOffAtMs: message.handedOffAtMs
525
633
  })
526
634
 
527
635
  if (failedJob) {
636
+ if (message.handoffId) {
637
+ this._forgetHandoff({handoffId: message.handoffId, jobId: message.jobId})
638
+ }
528
639
  this._emitBackgroundJobFailed({
529
640
  error: message.error,
641
+ handoffId: message.handoffId,
530
642
  handedOffAtMs: message.handedOffAtMs,
531
643
  job: failedJob,
532
644
  workerId: message.workerId
@@ -546,14 +658,15 @@ export default class BackgroundJobsMain {
546
658
 
547
659
  /**
548
660
  * Runs emit background job failed.
549
- * @param {{error: ?, handedOffAtMs?: number, job: import("./types.js").BackgroundJobRow, workerId?: string}} args - Failure event data.
661
+ * @param {{error: ?, handoffId?: string, handedOffAtMs?: number, job: import("./types.js").BackgroundJobRow, workerId?: string}} args - Failure event data.
550
662
  * @returns {void}
551
663
  */
552
- _emitBackgroundJobFailed({error, handedOffAtMs, job, workerId}) {
664
+ _emitBackgroundJobFailed({error, handoffId, handedOffAtMs, job, workerId}) {
553
665
  const normalizedError = this._normalizeFailureError(error)
554
666
  const payload = {
555
667
  context: {
556
668
  attempts: job.attempts,
669
+ handoffId,
557
670
  handedOffAtMs,
558
671
  jobArgs: job.args,
559
672
  jobId: job.id,
@@ -699,6 +812,10 @@ export default class BackgroundJobsMain {
699
812
  * Runs clear error retry timer.
700
813
  * @returns {void} */
701
814
  _clearErrorRetryTimer() {
815
+ for (const worker of this.workerHandoffs.keys()) {
816
+ if (!this.workers.has(worker)) return
817
+ }
818
+
702
819
  if (this._errorRetryTimer) {
703
820
  clearTimeout(this._errorRetryTimer)
704
821
  this._errorRetryTimer = undefined
@@ -771,10 +888,30 @@ export default class BackgroundJobsMain {
771
888
 
772
889
  this._errorRetryTimer = setTimeout(() => {
773
890
  this._errorRetryTimer = undefined
774
- void this._drain()
891
+ void this._retryAfterError()
775
892
  }, this.pollIntervalMs)
776
893
  }
777
894
 
895
+ /**
896
+ * Retries failed disconnected-socket releases before draining queued work.
897
+ * @returns {Promise<void>} - Resolves after retry work.
898
+ */
899
+ async _retryAfterError() {
900
+ if (this._stopped) return
901
+
902
+ try {
903
+ for (const worker of this.workerHandoffs.keys()) {
904
+ if (!this.workers.has(worker)) await this._releaseWorkerHandoffs(worker)
905
+ }
906
+ } catch (error) {
907
+ this._reportHandoffReleaseError(error)
908
+ this._scheduleErrorRetry()
909
+ return
910
+ }
911
+
912
+ await this._drain()
913
+ }
914
+
778
915
  /**
779
916
  * Inner drain loop: pulls eligible queued jobs and hands them off to
780
917
  * ready workers until one of them runs out.
@@ -790,7 +927,23 @@ export default class BackgroundJobsMain {
790
927
 
791
928
  this.readyWorkers.delete(worker)
792
929
 
793
- const handedOffAtMs = await this.store.markHandedOff({jobId: job.id, workerId: worker.workerId})
930
+ const handoff = await this.store.markHandedOff({jobId: job.id, workerId: worker.workerId})
931
+
932
+ if (!handoff) {
933
+ if (this.workers.has(worker)) this.readyWorkers.add(worker)
934
+ continue
935
+ }
936
+
937
+ const handoffs = this.workerHandoffs.get(worker)
938
+
939
+ if (!handoffs || !this.workers.has(worker)) {
940
+ await this.store.markReturnedToQueue({handoffId: handoff.handoffId, jobId: job.id})
941
+ this._notifyEnqueued()
942
+ await this._drain()
943
+ continue
944
+ }
945
+
946
+ handoffs.set(job.id, handoff.handoffId)
794
947
 
795
948
  try {
796
949
  worker.send({
@@ -799,8 +952,9 @@ export default class BackgroundJobsMain {
799
952
  id: job.id,
800
953
  jobName: job.jobName,
801
954
  args: job.args,
955
+ handoffId: handoff.handoffId,
802
956
  workerId: worker.workerId,
803
- handedOffAtMs,
957
+ handedOffAtMs: handoff.handedOffAtMs,
804
958
  options: {
805
959
  executionMode: job.executionMode,
806
960
  forked: job.forked
@@ -809,8 +963,12 @@ export default class BackgroundJobsMain {
809
963
  })
810
964
  } catch (error) {
811
965
  this.logger.warn(() => ["Failed to send job to worker, re-queueing:", error])
812
- await this.store.markReturnedToQueue({jobId: job.id})
813
- this.readyWorkers.add(worker)
966
+ try {
967
+ worker.close()
968
+ } catch (closeError) {
969
+ this.logger.warn(() => ["Failed to close worker after job send failure:", closeError])
970
+ }
971
+ await this._handleWorkerSocketClosed(worker)
814
972
  }
815
973
  }
816
974
  }
@@ -850,6 +1008,8 @@ export default class BackgroundJobsMain {
850
1008
  * @returns {void}
851
1009
  */
852
1010
  _addAcceptedExecutionModes({executionModes, worker}) {
1011
+ if (!worker.supportsHandoffIdReporting) return
1012
+
853
1013
  for (const capability of WORKER_EXECUTION_MODE_CAPABILITIES) {
854
1014
  if (capability.accepts(worker)) executionModes.add(capability.executionMode)
855
1015
  }
@@ -874,6 +1034,8 @@ export default class BackgroundJobsMain {
874
1034
  * @returns {boolean} - Whether the worker accepts the job mode.
875
1035
  */
876
1036
  _workerAcceptsJob({job, worker}) {
1037
+ if (!worker.supportsHandoffIdReporting) return false
1038
+
877
1039
  const capability = WORKER_EXECUTION_MODE_CAPABILITIES_BY_MODE.get(job.executionMode)
878
1040
 
879
1041
  if (!capability) return false
@@ -6,6 +6,8 @@ import Logger from "../logger.js"
6
6
  import normalizeBackgroundJobError from "./normalize-error.js"
7
7
  import BackgroundJobsSocketRequest from "./socket-request.js"
8
8
 
9
+ class BackgroundJobUpdateError extends Error {}
10
+
9
11
  export default class BackgroundJobsStatusReporter {
10
12
  /**
11
13
  * Runs constructor.
@@ -27,11 +29,12 @@ export default class BackgroundJobsStatusReporter {
27
29
  * @param {string} args.jobId - Job id.
28
30
  * @param {"completed" | "failed"} args.status - Status.
29
31
  * @param {?} [args.error] - Error.
32
+ * @param {string} [args.handoffId] - Handoff lease id.
30
33
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
31
34
  * @param {string} [args.workerId] - Worker id.
32
35
  * @returns {Promise<void>} - Resolves when reported.
33
36
  */
34
- async report({jobId, status, error, handedOffAtMs, workerId}) {
37
+ async report({jobId, status, error, handoffId, handedOffAtMs, workerId}) {
35
38
  const config = this.configuration.getBackgroundJobsConfig()
36
39
  const host = this.host || config.host
37
40
  const port = typeof this.port === "number" ? this.port : config.port
@@ -44,6 +47,7 @@ export default class BackgroundJobsStatusReporter {
44
47
  jsonSocket.send({
45
48
  type: status === "completed" ? "job-complete" : "job-failed",
46
49
  jobId,
50
+ handoffId,
47
51
  workerId,
48
52
  handedOffAtMs,
49
53
  error: error ? normalizeBackgroundJobError(error) : undefined
@@ -56,7 +60,7 @@ export default class BackgroundJobsStatusReporter {
56
60
  }
57
61
 
58
62
  if (message?.type === "job-update-error" && message.jobId === jobId) {
59
- reject(new Error(message.error || "Job update failed"))
63
+ reject(new BackgroundJobUpdateError(message.error || "Job update failed"))
60
64
  }
61
65
  }
62
66
  })
@@ -69,20 +73,23 @@ export default class BackgroundJobsStatusReporter {
69
73
  * @param {string} args.jobId - Job id.
70
74
  * @param {"completed" | "failed"} args.status - Status.
71
75
  * @param {?} [args.error] - Error.
76
+ * @param {string} [args.handoffId] - Handoff lease id.
72
77
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
73
78
  * @param {string} [args.workerId] - Worker id.
74
79
  * @param {number} [args.maxDurationMs] - Max duration for retries.
75
80
  * @returns {Promise<void>} - Resolves when reported.
76
81
  */
77
- async reportWithRetry({jobId, status, error, handedOffAtMs, workerId, maxDurationMs}) {
82
+ async reportWithRetry({jobId, status, error, handoffId, handedOffAtMs, workerId, maxDurationMs}) {
78
83
  let attempt = 0
79
84
  const startTime = Date.now()
80
85
 
81
86
  while (true) {
82
87
  try {
83
- await this.report({jobId, status, error, handedOffAtMs, workerId})
88
+ await this.report({jobId, status, error, handoffId, handedOffAtMs, workerId})
84
89
  return
85
90
  } catch (error) {
91
+ if (error instanceof BackgroundJobUpdateError) throw error
92
+
86
93
  attempt += 1
87
94
  const delaySeconds = Math.min(30, 0.5 * attempt)
88
95
 
@@ -90,7 +97,7 @@ export default class BackgroundJobsStatusReporter {
90
97
 
91
98
  if (maxDurationMs && Date.now() - startTime >= maxDurationMs) {
92
99
  this.logger.warn(() => ["Background job status report timed out, giving up", error])
93
- return
100
+ throw error
94
101
  }
95
102
 
96
103
  await wait(delaySeconds)