velocious 1.0.589 → 1.0.591

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 (96) hide show
  1. package/README.md +24 -0
  2. package/build/background-jobs/job-runner.js +20 -1
  3. package/build/background-jobs/job.js +15 -0
  4. package/build/background-jobs/main.js +39 -0
  5. package/build/background-jobs/pooled-runner-broker-identity.js +76 -0
  6. package/build/background-jobs/pooled-runner-child.js +17 -7
  7. package/build/background-jobs/reschedule-signal.js +14 -0
  8. package/build/background-jobs/status-reporter.js +9 -6
  9. package/build/background-jobs/store.js +80 -14
  10. package/build/background-jobs/types.js +2 -1
  11. package/build/background-jobs/worker.js +38 -8
  12. package/build/database/pool/base.js +18 -1
  13. package/build/environment-handlers/base.js +8 -0
  14. package/build/environment-handlers/node.js +12 -0
  15. package/build/src/background-jobs/job-runner.d.ts +2 -2
  16. package/build/src/background-jobs/job-runner.d.ts.map +1 -1
  17. package/build/src/background-jobs/job-runner.js +19 -2
  18. package/build/src/background-jobs/job.d.ts +7 -0
  19. package/build/src/background-jobs/job.d.ts.map +1 -1
  20. package/build/src/background-jobs/job.js +14 -1
  21. package/build/src/background-jobs/main.d.ts +11 -0
  22. package/build/src/background-jobs/main.d.ts.map +1 -1
  23. package/build/src/background-jobs/main.js +38 -1
  24. package/build/src/background-jobs/pooled-runner-broker-identity.d.ts +44 -0
  25. package/build/src/background-jobs/pooled-runner-broker-identity.d.ts.map +1 -0
  26. package/build/src/background-jobs/pooled-runner-broker-identity.js +76 -0
  27. package/build/src/background-jobs/pooled-runner-child.js +18 -8
  28. package/build/src/background-jobs/reschedule-signal.d.ts +10 -0
  29. package/build/src/background-jobs/reschedule-signal.d.ts.map +1 -0
  30. package/build/src/background-jobs/reschedule-signal.js +14 -0
  31. package/build/src/background-jobs/status-reporter.d.ts +10 -6
  32. package/build/src/background-jobs/status-reporter.d.ts.map +1 -1
  33. package/build/src/background-jobs/status-reporter.js +10 -7
  34. package/build/src/background-jobs/store.d.ts +30 -0
  35. package/build/src/background-jobs/store.d.ts.map +1 -1
  36. package/build/src/background-jobs/store.js +75 -14
  37. package/build/src/background-jobs/types.d.ts +11 -2
  38. package/build/src/background-jobs/types.d.ts.map +1 -1
  39. package/build/src/background-jobs/types.js +3 -2
  40. package/build/src/background-jobs/worker.d.ts +16 -6
  41. package/build/src/background-jobs/worker.d.ts.map +1 -1
  42. package/build/src/background-jobs/worker.js +37 -9
  43. package/build/src/database/pool/base.d.ts +6 -0
  44. package/build/src/database/pool/base.d.ts.map +1 -1
  45. package/build/src/database/pool/base.js +18 -2
  46. package/build/src/environment-handlers/base.d.ts +12 -0
  47. package/build/src/environment-handlers/base.d.ts.map +1 -1
  48. package/build/src/environment-handlers/base.js +8 -1
  49. package/build/src/environment-handlers/node.d.ts +11 -0
  50. package/build/src/environment-handlers/node.d.ts.map +1 -1
  51. package/build/src/environment-handlers/node.js +13 -1
  52. package/build/src/testing/shared-transaction-broker-client.d.ts +53 -0
  53. package/build/src/testing/shared-transaction-broker-client.d.ts.map +1 -0
  54. package/build/src/testing/shared-transaction-broker-client.js +105 -0
  55. package/build/src/testing/shared-transaction-broker.d.ts +153 -0
  56. package/build/src/testing/shared-transaction-broker.d.ts.map +1 -0
  57. package/build/src/testing/shared-transaction-broker.js +414 -0
  58. package/build/src/testing/shared-transaction-codec.d.ts +21 -0
  59. package/build/src/testing/shared-transaction-codec.d.ts.map +1 -0
  60. package/build/src/testing/shared-transaction-codec.js +114 -0
  61. package/build/src/testing/shared-transaction-connection-coordinator.d.ts +23 -0
  62. package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -0
  63. package/build/src/testing/shared-transaction-connection-coordinator.js +36 -0
  64. package/build/src/testing/shared-transaction-proxy-driver.d.ts +42 -0
  65. package/build/src/testing/shared-transaction-proxy-driver.d.ts.map +1 -0
  66. package/build/src/testing/shared-transaction-proxy-driver.js +195 -0
  67. package/build/src/testing/test-runner.d.ts +20 -0
  68. package/build/src/testing/test-runner.d.ts.map +1 -1
  69. package/build/src/testing/test-runner.js +64 -3
  70. package/build/testing/shared-transaction-broker-client.js +107 -0
  71. package/build/testing/shared-transaction-broker.js +401 -0
  72. package/build/testing/shared-transaction-codec.js +110 -0
  73. package/build/testing/shared-transaction-connection-coordinator.js +38 -0
  74. package/build/testing/shared-transaction-proxy-driver.js +205 -0
  75. package/build/testing/test-runner.js +65 -2
  76. package/build/tsconfig.tsbuildinfo +1 -1
  77. package/package.json +4 -2
  78. package/src/background-jobs/job-runner.js +20 -1
  79. package/src/background-jobs/job.js +15 -0
  80. package/src/background-jobs/main.js +39 -0
  81. package/src/background-jobs/pooled-runner-broker-identity.js +76 -0
  82. package/src/background-jobs/pooled-runner-child.js +17 -7
  83. package/src/background-jobs/reschedule-signal.js +14 -0
  84. package/src/background-jobs/status-reporter.js +9 -6
  85. package/src/background-jobs/store.js +80 -14
  86. package/src/background-jobs/types.js +2 -1
  87. package/src/background-jobs/worker.js +38 -8
  88. package/src/database/pool/base.js +18 -1
  89. package/src/environment-handlers/base.js +8 -0
  90. package/src/environment-handlers/node.js +12 -0
  91. package/src/testing/shared-transaction-broker-client.js +107 -0
  92. package/src/testing/shared-transaction-broker.js +401 -0
  93. package/src/testing/shared-transaction-codec.js +110 -0
  94. package/src/testing/shared-transaction-connection-coordinator.js +38 -0
  95. package/src/testing/shared-transaction-proxy-driver.js +205 -0
  96. package/src/testing/test-runner.js +65 -2
package/README.md CHANGED
@@ -2072,6 +2072,10 @@ Tests default to a 60-second timeout. Override per test with `{timeoutSeconds: 5
2072
2072
 
2073
2073
  Request tests share transaction-active, non-tenant database connections with their in-process HTTP handlers. Eligibility is evaluated when each request is dispatched, so a hook can start a transaction and issue a request in the same callback. This makes uncommitted setup visible to handlers while preserving rollback isolation. Without an active transaction, handlers use independent pooled connections, so concurrency and locking tests can opt out of transaction cleanup and exercise production-style connections. Shared connection state is scoped to the test lifecycle and cleared around each test. See [docs/testing-guidelines.md](docs/testing-guidelines.md#request-test-database-connections).
2074
2074
 
2075
+ Transactional tests also share active non-tenant connections with real forked, reusable pooled, and spawned background-job child runners through a per-attempt test-only loopback broker. Parent setup and child writes therefore occupy the same physical transaction and roll back together, including background-job persistence. Multiple configured databases route by identifier; tenant-only databases remain excluded. Tests using `{transaction: false, truncate: true}` retain ordinary independent physical connections for concurrency and locking coverage. See [docs/testing-guidelines.md](docs/testing-guidelines.md#request-test-database-connections).
2076
+
2077
+ Warm pooled children receive the active broker capability per job, discard retained proxy state when the capability changes, and fail closed if a transactional dispatch lacks coordinates. Child transaction/savepoint work holds a FIFO lease on the parent physical connection until the matching root release or rollback.
2078
+
2075
2079
  # Writing a request test
2076
2080
 
2077
2081
  First create a test file under something like the following path 'src/routes/accounts/create-test.js' with something like the following content:
@@ -2345,6 +2349,26 @@ await MyJob.performLaterWithOptions({
2345
2349
 
2346
2350
  Until `scheduledAtMs` is reached, the job remains queued but is not eligible for dispatch. The event-driven dispatcher arms its timer for the earliest future job and wakes at that timestamp. Omitting `scheduledAtMs` keeps the immediate-enqueue behavior.
2347
2351
 
2352
+ A running job that cannot proceed yet can reschedule its same durable row without
2353
+ recording a failure—for example, when a non-blocking lock is busy:
2354
+
2355
+ ```js
2356
+ async perform(accountId) {
2357
+ if (!(await Account.tryAcquireRefreshLock(accountId))) {
2358
+ this.rescheduleIn(30_000)
2359
+ }
2360
+
2361
+ await refreshAccount(accountId)
2362
+ }
2363
+ ```
2364
+
2365
+ `rescheduleIn(delayMs)` requires a finite, non-negative safe-integer millisecond
2366
+ delay and never returns: it stops the current `perform`, releases its worker and
2367
+ concurrency slots, and makes the same job eligible again after the delay. This is
2368
+ normal control flow, not failure retry: attempts and failure metadata remain
2369
+ unchanged, retries are not consumed, and failure/error events are not emitted.
2370
+ See [Rescheduling a running job](docs/background-jobs.md#rescheduling-a-running-job).
2371
+
2348
2372
  Use a durable stable key when the same logical one-off schedule must be moved or cancelled without retaining its transient job id:
2349
2373
 
2350
2374
  ```js
@@ -3,6 +3,7 @@
3
3
  import configurationResolver from "../configuration-resolver.js"
4
4
  import BackgroundJobRegistry from "./job-registry.js"
5
5
  import BackgroundJobsStatusReporter from "./status-reporter.js"
6
+ import BackgroundJobRescheduleSignal from "./reschedule-signal.js"
6
7
 
7
8
  const BEACON_READY_TIMEOUT_MS = 5000
8
9
 
@@ -79,7 +80,7 @@ function runnerProcessTitle(JobClass, payload) {
79
80
  * @param {object} [options] - Runner options.
80
81
  * @param {boolean} [options.closeConnections] - Whether to gracefully close framework connections after the job.
81
82
  * @param {boolean} [options.manageProcessTitle] - Whether to set the per-job process title and restore it afterwards. Off for concurrent pooled runners, where interleaved snapshot/restore of the single process-wide `process.title` would corrupt it; the pooled child owns an aggregate title instead.
82
- * @returns {Promise<void>} - Resolves when complete.
83
+ * @returns {Promise<"completed" | "rescheduled">} - Acknowledged outcome.
83
84
  */
84
85
  export default async function runJobPayload(payload, {closeConnections = true, manageProcessTitle = true} = {}) {
85
86
  const configuration = await configurationResolver()
@@ -109,6 +110,23 @@ export default async function runJobPayload(payload, {closeConnections = true, m
109
110
  await perform.apply(jobInstance, payload.args || [])
110
111
  })
111
112
  } catch (error) {
113
+ if (error instanceof BackgroundJobRescheduleSignal) {
114
+ if (payload.id) {
115
+ await reporter.reportWithRetry({
116
+ jobId: payload.id,
117
+ status: "rescheduled",
118
+ delayMs: error.delayMs,
119
+ handoffId: payload.handoffId,
120
+ workerId: payload.workerId,
121
+ handedOffAtMs: payload.handedOffAtMs,
122
+ maxDurationMs: 30000,
123
+ retryPersistErrors: true
124
+ })
125
+ }
126
+
127
+ return "rescheduled"
128
+ }
129
+
112
130
  const performedError = error instanceof Error ? error : new Error(String(error))
113
131
  if (payload.id) {
114
132
  await reporter.reportWithRetry({
@@ -135,6 +153,7 @@ export default async function runJobPayload(payload, {closeConnections = true, m
135
153
  maxDurationMs: 30000
136
154
  })
137
155
  }
156
+ return "completed"
138
157
  } finally {
139
158
  // Restore the runner's base title so a lingering/idle runner (or a reused
140
159
  // one) doesn't misreport a finished job as still running.
@@ -1,6 +1,7 @@
1
1
  // @ts-check
2
2
 
3
3
  import BackgroundJobsClient from "./client.js"
4
+ import BackgroundJobRescheduleSignal from "./reschedule-signal.js"
4
5
 
5
6
  /**
6
7
  * Base class for background jobs.
@@ -43,6 +44,20 @@ export default class VelociousJob {
43
44
  */
44
45
  static processTitle = undefined
45
46
 
47
+ /**
48
+ * Stops this performance and reschedules the same logical job row. This is
49
+ * normal control flow: it does not count as a failure or consume a retry.
50
+ * @param {number} delayMs - Non-negative safe-integer delay in milliseconds.
51
+ * @returns {never} - This method never returns.
52
+ */
53
+ rescheduleIn(delayMs) {
54
+ if (!Number.isSafeInteger(delayMs) || delayMs < 0) {
55
+ throw new TypeError("background job reschedule delayMs must be a non-negative safe integer")
56
+ }
57
+
58
+ throw new BackgroundJobRescheduleSignal(delayMs)
59
+ }
60
+
46
61
  /**
47
62
  * Runs job name.
48
63
  * @returns {string} - Job name.
@@ -601,6 +601,11 @@ export default class BackgroundJobsMain {
601
601
 
602
602
  if (message?.type === "job-failed") {
603
603
  this._handleJobFailed({jsonSocket, message})
604
+ return
605
+ }
606
+
607
+ if (message?.type === "job-reschedule") {
608
+ this._handleJobReschedule({jsonSocket, message})
604
609
  }
605
610
  }
606
611
 
@@ -885,6 +890,40 @@ export default class BackgroundJobsMain {
885
890
  }
886
891
  }
887
892
 
893
+ /**
894
+ * Persists a normal job reschedule outcome and wakes scheduled dispatch.
895
+ * @param {object} args - Options.
896
+ * @param {JsonSocket} args.jsonSocket - JSON socket.
897
+ * @param {import("./types.js").BackgroundJobRescheduleMessage} args.message - Message.
898
+ * @returns {Promise<void>} - Resolves when handled.
899
+ */
900
+ async _handleJobReschedule({jsonSocket, message}) {
901
+ try {
902
+ const accepted = await this.store.markRescheduled({
903
+ jobId: message.jobId,
904
+ delayMs: message.delayMs,
905
+ handoffId: message.handoffId,
906
+ workerId: message.workerId,
907
+ handedOffAtMs: message.handedOffAtMs
908
+ })
909
+ if (accepted && message.handoffId) {
910
+ this._forgetHandoff({handoffId: message.handoffId, jobId: message.jobId})
911
+ }
912
+ jsonSocket.send({type: "job-updated", jobId: message.jobId})
913
+ this._notifyEnqueued()
914
+ await this._drain()
915
+ } catch (error) {
916
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
917
+ const payload = {context: {jobId: message.jobId, stage: "background-job-reschedule"}, error: normalizedError}
918
+ const errorEvents = this.configuration.getErrorEvents()
919
+
920
+ this.logger.error(() => ["Failed to update job reschedule:", normalizedError])
921
+ errorEvents.emit("framework-error", payload)
922
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
923
+ jsonSocket.send({type: "job-update-error", jobId: message.jobId, error: "Failed to update job"})
924
+ }
925
+ }
926
+
888
927
  /**
889
928
  * Runs handle job failed.
890
929
  * @param {object} args - Options.
@@ -0,0 +1,76 @@
1
+ // @ts-check
2
+
3
+ export default class PooledRunnerBrokerIdentity {
4
+ /**
5
+ * Creates a pooled runner identity coordinator.
6
+ * @param {{closeConnections: () => Promise<void>}} args - Connection cleanup hook.
7
+ */
8
+ constructor({closeConnections}) {
9
+ this.closeConnections = closeConnections
10
+ /** @type {string | undefined} */
11
+ this.activeIdentity = undefined
12
+ /** @type {{identity: string, promise: Promise<void>} | undefined} */
13
+ this.pending = undefined
14
+ this.activeUsers = 0
15
+ }
16
+
17
+ /**
18
+ * Gets the current prepared identity.
19
+ * @returns {string | undefined} - Current prepared identity.
20
+ */
21
+ current() { return this.activeIdentity }
22
+
23
+ /**
24
+ * Prepares one identity, sharing an in-flight same-identity rotation.
25
+ * @param {import("../testing/shared-transaction-proxy-driver.js").SharedTransactionBrokerJobConfig} config - Dispatch configuration.
26
+ * @returns {Promise<void>} - Resolves after stale connections close.
27
+ */
28
+ async prepare(config) {
29
+ const identity = JSON.stringify(config)
30
+ if (this.pending) {
31
+ if (this.pending.identity !== identity) throw new Error("Pooled runner cannot mix shared transaction broker capabilities concurrently")
32
+ return await this.pending.promise
33
+ }
34
+ if (this.activeIdentity === identity) return
35
+ if (this.activeUsers > 0) throw new Error("Pooled runner cannot mix shared transaction broker capabilities concurrently")
36
+ if (this.activeIdentity === undefined) {
37
+ this.activeIdentity = identity
38
+ return
39
+ }
40
+
41
+ const promise = this.rotate(identity)
42
+ this.pending = {identity, promise}
43
+ try {
44
+ await promise
45
+ } finally {
46
+ this.pending = undefined
47
+ }
48
+ }
49
+
50
+ /**
51
+ * Runs work while preventing a different identity from replacing its connections.
52
+ * @template T
53
+ * @param {import("../testing/shared-transaction-proxy-driver.js").SharedTransactionBrokerJobConfig} config - Dispatch configuration.
54
+ * @param {() => Promise<T>} callback - Job callback.
55
+ * @returns {Promise<T>} - Job result.
56
+ */
57
+ async run(config, callback) {
58
+ await this.prepare(config)
59
+ this.activeUsers++
60
+ try {
61
+ return await callback()
62
+ } finally {
63
+ this.activeUsers--
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Rotates retained connection state to an identity.
69
+ * @param {string} identity - Target identity.
70
+ * @returns {Promise<void>} - Resolves after rotation.
71
+ */
72
+ async rotate(identity) {
73
+ await this.closeConnections()
74
+ this.activeIdentity = identity
75
+ }
76
+ }
@@ -3,6 +3,8 @@
3
3
  import runJobPayload, { BackgroundJobPerformedFailure } from "./job-runner.js"
4
4
  import { closeRunnerConnections, currentConfigurationOrNull } from "./runner-graceful-shutdown.js"
5
5
  import setRunnerProcessTitle from "./runner-process-title.js"
6
+ import PooledRunnerBrokerIdentity from "./pooled-runner-broker-identity.js"
7
+ import { runWithSharedTransactionBrokerConfig } from "../testing/shared-transaction-proxy-driver.js"
6
8
 
7
9
  const BASE_PROCESS_TITLE = "velocious background-jobs-runner"
8
10
 
@@ -33,6 +35,9 @@ async function shutdownRunner(exitCode) {
33
35
  * @type {Set<string>}
34
36
  */
35
37
  const runningJobIds = new Set()
38
+ const brokerIdentity = new PooledRunnerBrokerIdentity({
39
+ closeConnections: async () => await closeRunnerConnections(currentConfigurationOrNull())
40
+ })
36
41
 
37
42
  /**
38
43
  * Sets an aggregate process title from the current in-flight count. A child runs
@@ -51,11 +56,11 @@ function updateProcessTitle() {
51
56
  /**
52
57
  * Checks whether an IPC value is a runnable pooled job message.
53
58
  * @param {ReturnType<typeof JSON.parse>} message - IPC message.
54
- * @returns {message is {type: "job", payload: import("./types.js").BackgroundJobPayload & {id: string}}} - Whether this is a valid job message.
59
+ * @returns {message is {type: "job", payload: import("./types.js").BackgroundJobPayload & {id: string}, sharedTransactionBroker?: import("../testing/shared-transaction-proxy-driver.js").SharedTransactionBrokerJobConfig}} - Whether this is a valid job message.
55
60
  */
56
61
  function isJobMessage(message) {
57
62
  if (!message || typeof message !== "object") return false
58
- const record = /** @type {{type?: ReturnType<typeof JSON.parse>, payload?: ReturnType<typeof JSON.parse>}} */ (message)
63
+ const record = /** @type {{type?: ReturnType<typeof JSON.parse>, payload?: ReturnType<typeof JSON.parse>, sharedTransactionBroker?: ReturnType<typeof JSON.parse>}} */ (message)
59
64
 
60
65
  return record.type === "job" && !!record.payload && typeof record.payload === "object" && typeof record.payload.id === "string"
61
66
  }
@@ -65,7 +70,7 @@ function isJobMessage(message) {
65
70
  * @param {object} args - Outcome.
66
71
  * @param {string} args.jobId - Job id.
67
72
  * @param {boolean} args.acknowledged - Whether the terminal report was acknowledged.
68
- * @param {"completed" | "failed"} [args.status] - Acknowledged terminal status.
73
+ * @param {"completed" | "failed" | "rescheduled"} [args.status] - Acknowledged outcome.
69
74
  * @param {Error} [args.error] - Reporting error when acknowledgement was not obtained.
70
75
  * @returns {Promise<void>} - Resolves after IPC accepts the message.
71
76
  */
@@ -95,12 +100,17 @@ function sendOutcome({jobId, acknowledged, status, error}) {
95
100
  * per-job try/catch) ends the child, which the worker sees as an exit and
96
101
  * reclaims for the whole in-flight set.
97
102
  * @param {import("./types.js").BackgroundJobPayload & {id: string}} payload - Job payload.
103
+ * @param {import("../testing/shared-transaction-proxy-driver.js").SharedTransactionBrokerJobConfig} sharedTransactionBroker - Per-job broker configuration.
98
104
  * @returns {Promise<void>} - Resolves after reporting.
99
105
  */
100
- async function runJob(payload) {
106
+ async function runJob(payload, sharedTransactionBroker) {
101
107
  try {
102
- await runJobPayload(payload, {closeConnections: false, manageProcessTitle: false})
103
- await sendOutcome({jobId: payload.id, acknowledged: true, status: "completed"})
108
+ const status = await runWithSharedTransactionBrokerConfig(sharedTransactionBroker, async () => {
109
+ return await brokerIdentity.run(sharedTransactionBroker, async () => {
110
+ return await runJobPayload(payload, {closeConnections: false, manageProcessTitle: false})
111
+ })
112
+ })
113
+ await sendOutcome({jobId: payload.id, acknowledged: true, status})
104
114
  } catch (error) {
105
115
  if (error instanceof BackgroundJobPerformedFailure) {
106
116
  await sendOutcome({jobId: payload.id, acknowledged: true, status: "failed"})
@@ -125,7 +135,7 @@ function handleMessage(message) {
125
135
 
126
136
  runningJobIds.add(message.payload.id)
127
137
  updateProcessTitle()
128
- void runJob(message.payload)
138
+ void runJob(message.payload, message.sharedTransactionBroker || {expected: false})
129
139
  }
130
140
 
131
141
  process.on("message", (message) => handleMessage(message))
@@ -0,0 +1,14 @@
1
+ // @ts-check
2
+
3
+ /** Internal control flow raised by `VelociousJob#rescheduleIn`. */
4
+ export default class BackgroundJobRescheduleSignal extends Error {
5
+ /**
6
+ * Creates a reschedule control signal.
7
+ * @param {number} delayMs - Reschedule delay in milliseconds.
8
+ */
9
+ constructor(delayMs) {
10
+ super(`Reschedule background job in ${delayMs}ms`)
11
+ this.name = "BackgroundJobRescheduleSignal"
12
+ this.delayMs = delayMs
13
+ }
14
+ }
@@ -36,14 +36,15 @@ export default class BackgroundJobsStatusReporter {
36
36
  * Runs report.
37
37
  * @param {object} args - Options.
38
38
  * @param {string} args.jobId - Job id.
39
- * @param {"completed" | "failed"} args.status - Status.
39
+ * @param {"completed" | "failed" | "rescheduled"} args.status - Status.
40
+ * @param {number} [args.delayMs] - Reschedule delay in milliseconds.
40
41
  * @param {ReturnType<typeof JSON.parse>} [args.error] - Error.
41
42
  * @param {string} [args.handoffId] - Handoff lease id.
42
43
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
43
44
  * @param {string} [args.workerId] - Worker id.
44
45
  * @returns {Promise<void>} - Resolves when reported.
45
46
  */
46
- async report({jobId, status, error, handoffId, handedOffAtMs, workerId}) {
47
+ async report({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}) {
47
48
  const config = this.configuration.getBackgroundJobsConfig()
48
49
  const host = this.host || config.host
49
50
  const port = typeof this.port === "number" ? this.port : config.port
@@ -57,8 +58,9 @@ export default class BackgroundJobsStatusReporter {
57
58
  signal: control.signal,
58
59
  onConnect: (jsonSocket) => {
59
60
  jsonSocket.send({
60
- type: status === "completed" ? "job-complete" : "job-failed",
61
+ type: status === "completed" ? "job-complete" : status === "rescheduled" ? "job-reschedule" : "job-failed",
61
62
  jobId,
63
+ delayMs,
62
64
  handoffId,
63
65
  workerId,
64
66
  handedOffAtMs,
@@ -83,7 +85,8 @@ export default class BackgroundJobsStatusReporter {
83
85
  * Runs report with retry.
84
86
  * @param {object} args - Options.
85
87
  * @param {string} args.jobId - Job id.
86
- * @param {"completed" | "failed"} args.status - Status.
88
+ * @param {"completed" | "failed" | "rescheduled"} args.status - Status.
89
+ * @param {number} [args.delayMs] - Reschedule delay in milliseconds.
87
90
  * @param {ReturnType<typeof JSON.parse>} [args.error] - Error.
88
91
  * @param {string} [args.handoffId] - Handoff lease id.
89
92
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
@@ -92,13 +95,13 @@ export default class BackgroundJobsStatusReporter {
92
95
  * @param {boolean} [args.retryPersistErrors] - Retry a `BackgroundJobUpdateError` (main's `job-update-error`, i.e. a transient DB failure while persisting the terminal status) instead of throwing immediately. Off by default so short-lived forked/spawned runners keep failing loudly and exit non-zero to be reclaimed; on for the long-lived worker, which cannot exit-to-reclaim and would otherwise strand the job in `handed_off`.
93
96
  * @returns {Promise<void>} - Resolves when reported.
94
97
  */
95
- async reportWithRetry({jobId, status, error, handoffId, handedOffAtMs, workerId, maxDurationMs, retryPersistErrors = false}) {
98
+ async reportWithRetry({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, maxDurationMs, retryPersistErrors = false}) {
96
99
  let attempt = 0
97
100
  const startTime = Date.now()
98
101
 
99
102
  while (true) {
100
103
  try {
101
- await this.report({jobId, status, error, handoffId, handedOffAtMs, workerId})
104
+ await this.report({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId})
102
105
  return
103
106
  } catch (error) {
104
107
  // A `BackgroundJobUpdateError` means main answered `job-update-error`, which it
@@ -6,6 +6,7 @@ import TableData from "../database/table-data/index.js"
6
6
  import VelociousError from "../velocious-error.js"
7
7
  import BackgroundJobRecord from "./job-record.js"
8
8
  import normalizeBackgroundJobError from "./normalize-error.js"
9
+ import {coordinateSharedTransactionConnection} from "../testing/shared-transaction-connection-coordinator.js"
9
10
 
10
11
  /**
11
12
  * PreparedBackgroundJob type.
@@ -694,6 +695,48 @@ export default class BackgroundJobsStore {
694
695
  }))
695
696
  }
696
697
 
698
+ /**
699
+ * Returns an active handoff to the queue at a caller-requested future time.
700
+ * This is normal job control flow: it preserves failure attempts and metadata.
701
+ * @param {object} args - Options.
702
+ * @param {string} args.jobId - Job id.
703
+ * @param {number} args.delayMs - Delay from persistence time in milliseconds.
704
+ * @param {string} [args.handoffId] - Handoff lease id.
705
+ * @param {string} [args.workerId] - Worker id.
706
+ * @param {number} [args.handedOffAtMs] - Handed off timestamp.
707
+ * @returns {Promise<boolean>} - Whether the fenced report was accepted.
708
+ */
709
+ async markRescheduled({jobId, delayMs, handoffId, workerId, handedOffAtMs}) {
710
+ await this.ensureReady()
711
+ this._validateRescheduleDelayMs(delayMs)
712
+
713
+ return await this._withDb(async (db) => await this._serializedCountMutation(db, async () => {
714
+ const job = await this._getJobRowById(db, jobId)
715
+
716
+ if (!job) return false
717
+ if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return false
718
+
719
+ await this._lockConcurrencyRow(db, job.concurrencyKey)
720
+ const scheduledAtMs = this._rescheduledAtMs(delayMs)
721
+ const affectedRows = await this._updateAffectedRows(db, {
722
+ tableName: JOBS_TABLE,
723
+ data: {
724
+ status: "queued",
725
+ scheduled_at_ms: scheduledAtMs,
726
+ handed_off_at_ms: null,
727
+ handoff_id: null,
728
+ worker_id: null
729
+ },
730
+ conditions: this._activeHandoffConditions(job)
731
+ })
732
+
733
+ if (affectedRows !== 1) return false
734
+ await this._releaseConcurrency(db, job.concurrencyKey)
735
+ await this._recordStatusTransition(db, "handed_off", "queued")
736
+ return true
737
+ }))
738
+ }
739
+
697
740
  /**
698
741
  * Runs mark returned to queue.
699
742
  * @param {object} args - Options.
@@ -1067,6 +1110,33 @@ export default class BackgroundJobsStore {
1067
1110
  throw VelociousError.safe("background job scheduledAtMs must be a non-negative safe integer")
1068
1111
  }
1069
1112
 
1113
+ /**
1114
+ * Resolves a reschedule delay against persistence time.
1115
+ * @param {number} delayMs - Delay in milliseconds.
1116
+ * @returns {number} - Future eligibility timestamp.
1117
+ */
1118
+ _rescheduledAtMs(delayMs) {
1119
+ this._validateRescheduleDelayMs(delayMs)
1120
+
1121
+ const scheduledAtMs = Date.now() + delayMs
1122
+ if (!Number.isSafeInteger(scheduledAtMs)) {
1123
+ throw VelociousError.safe("background job reschedule scheduledAtMs must be a safe integer")
1124
+ }
1125
+
1126
+ return scheduledAtMs
1127
+ }
1128
+
1129
+ /**
1130
+ * Validates a public reschedule delay before persistence work begins.
1131
+ * @param {number} delayMs - Delay in milliseconds.
1132
+ * @returns {void}
1133
+ */
1134
+ _validateRescheduleDelayMs(delayMs) {
1135
+ if (!Number.isSafeInteger(delayMs) || delayMs < 0) {
1136
+ throw VelociousError.safe("background job reschedule delayMs must be a non-negative safe integer")
1137
+ }
1138
+ }
1139
+
1070
1140
  /**
1071
1141
  * Validates a stable schedule key at the public storage boundary.
1072
1142
  * @param {string} scheduleKey - Stable logical schedule key.
@@ -2307,23 +2377,19 @@ export default class BackgroundJobsStore {
2307
2377
  * @returns {Promise<T>} - Callback result.
2308
2378
  */
2309
2379
  async _withDb(callback) {
2310
- const pool = this.configuration.getDatabasePool(this.getDatabaseIdentifier())
2311
- let callbackCalled = false
2312
- /**
2313
- * Defines result.
2314
- * @type {T | undefined} */
2315
- let result
2316
-
2317
- await pool.withConnection({name: "Background jobs store"}, async (db) => {
2318
- callbackCalled = true
2319
- result = await callback(db)
2320
- })
2380
+ const databaseIdentifier = this.getDatabaseIdentifier()
2381
+ const pool = this.configuration.getDatabasePool(databaseIdentifier)
2321
2382
 
2322
- if (!callbackCalled) {
2323
- throw new Error("Background jobs store callback was not invoked")
2383
+ if (!pool.testSharedConnection()) {
2384
+ return await pool.withConnection({name: "Background jobs store"}, callback)
2324
2385
  }
2325
2386
 
2326
- return /** @type {T} */ (result)
2387
+ return await this.configuration.runWithTestSharedConnectionContexts(async () => {
2388
+ return await this.configuration.ensureConnections({databaseIdentifiers: [databaseIdentifier], name: "Background jobs store"}, async (dbs) => {
2389
+ const connection = dbs[databaseIdentifier]
2390
+ return await coordinateSharedTransactionConnection(connection, async () => await callback(connection))
2391
+ })
2392
+ })
2327
2393
  }
2328
2394
 
2329
2395
  /**
@@ -99,11 +99,12 @@
99
99
  * @typedef {{type: "job", payload: BackgroundJobPayload}} BackgroundJobJobMessage
100
100
  * @typedef {{type: "job-complete", jobId: string, handoffId?: string, workerId?: string, handedOffAtMs?: number}} BackgroundJobCompleteMessage
101
101
  * @typedef {{type: "job-failed", jobId: string, error?: ReturnType<typeof JSON.parse>, handoffId?: string, workerId?: string, handedOffAtMs?: number}} BackgroundJobFailedMessage
102
+ * @typedef {{type: "job-reschedule", jobId: string, delayMs: number, handoffId?: string, workerId?: string, handedOffAtMs?: number}} BackgroundJobRescheduleMessage
102
103
  * @typedef {{type: "job-updated", jobId: string}} BackgroundJobUpdatedMessage
103
104
  * @typedef {{type: "job-update-error", jobId: string, error?: string}} BackgroundJobUpdateErrorMessage
104
105
  */
105
106
  /**
106
- * @typedef {BackgroundJobHelloMessage | BackgroundJobReadyMessage | BackgroundJobDrainingMessage | BackgroundJobHeartbeatMessage | BackgroundJobEnqueueMessage | BackgroundJobEnqueuedMessage | BackgroundJobEnqueueErrorMessage | BackgroundJobReplaceScheduledMessage | BackgroundJobScheduleReplacedMessage | BackgroundJobReplaceScheduledErrorMessage | BackgroundJobCancelScheduledMessage | BackgroundJobScheduleCancelledMessage | BackgroundJobCancelScheduledErrorMessage | BackgroundJobJobMessage | BackgroundJobCompleteMessage | BackgroundJobFailedMessage | BackgroundJobUpdatedMessage | BackgroundJobUpdateErrorMessage} BackgroundJobSocketMessage
107
+ * @typedef {BackgroundJobHelloMessage | BackgroundJobReadyMessage | BackgroundJobDrainingMessage | BackgroundJobHeartbeatMessage | BackgroundJobEnqueueMessage | BackgroundJobEnqueuedMessage | BackgroundJobEnqueueErrorMessage | BackgroundJobReplaceScheduledMessage | BackgroundJobScheduleReplacedMessage | BackgroundJobReplaceScheduledErrorMessage | BackgroundJobCancelScheduledMessage | BackgroundJobScheduleCancelledMessage | BackgroundJobCancelScheduledErrorMessage | BackgroundJobJobMessage | BackgroundJobCompleteMessage | BackgroundJobFailedMessage | BackgroundJobRescheduleMessage | BackgroundJobUpdatedMessage | BackgroundJobUpdateErrorMessage} BackgroundJobSocketMessage
107
108
  */
108
109
 
109
110
  export const nothing = {}
@@ -9,6 +9,7 @@ import BackgroundJobsStatusReporter from "./status-reporter.js"
9
9
  import {randomUUID} from "crypto"
10
10
  import {fileURLToPath} from "node:url"
11
11
  import shutdownLifecycle from "../utils/shutdown-lifecycle.js"
12
+ import BackgroundJobRescheduleSignal from "./reschedule-signal.js"
12
13
 
13
14
  /**
14
15
  * Per-forked-child timeout bookkeeping.
@@ -574,6 +575,18 @@ export default class BackgroundJobsWorker {
574
575
  workerId: payload.workerId || this.workerId
575
576
  })
576
577
  } catch (error) {
578
+ if (error instanceof BackgroundJobRescheduleSignal) {
579
+ this._reportJobResultInBackground({
580
+ jobId: payload.id,
581
+ status: "rescheduled",
582
+ delayMs: error.delayMs,
583
+ handoffId: payload.handoffId,
584
+ handedOffAtMs: payload.handedOffAtMs,
585
+ workerId: payload.workerId || this.workerId
586
+ })
587
+ return
588
+ }
589
+
577
590
  this._reportJobResultInBackground({
578
591
  jobId: payload.id,
579
592
  status: "failed",
@@ -680,13 +693,26 @@ export default class BackgroundJobsWorker {
680
693
 
681
694
  state.inflight.set(payload.id, {payload, resolve, timeoutTimer})
682
695
  try {
683
- child.send({type: "job", payload})
696
+ child.send({type: "job", payload, sharedTransactionBroker: this._pooledJobSharedTransactionBrokerConfig()})
684
697
  } catch (error) {
685
698
  void this._handlePooledChildFailure({child, error})
686
699
  }
687
700
  })
688
701
  }
689
702
 
703
+ /**
704
+ * Captures the current test attempt's broker mode at dispatch time. A warm
705
+ * pooled child must never rely on its immutable fork-time environment.
706
+ * @returns {import("../testing/shared-transaction-proxy-driver.js").SharedTransactionBrokerJobConfig} - Per-job broker configuration.
707
+ */
708
+ _pooledJobSharedTransactionBrokerConfig() {
709
+ const serialized = process.env.VELOCIOUS_TEST_SHARED_TRANSACTION_BROKER
710
+ if (!serialized) return {expected: false}
711
+
712
+ const config = JSON.parse(Buffer.from(serialized, "base64url").toString("utf8"))
713
+ return {...config, expected: true}
714
+ }
715
+
690
716
  /**
691
717
  * Selects a pooled child to run the next job, or undefined when every non-retiring
692
718
  * child is already full (the caller then lazily spawns one). Among children with a
@@ -779,7 +805,7 @@ export default class BackgroundJobsWorker {
779
805
  const config = configuration.getBackgroundJobsConfig()
780
806
  const child = fork(POOLED_RUNNER_ENTRY_PATH, [], {
781
807
  cwd: configuration.getDirectory(), execArgv: [], stdio: ["ignore", "ignore", "ignore", "ipc"],
782
- env: Object.assign({}, process.env, {VELOCIOUS_ENV: configuration.getEnvironment(), VELOCIOUS_BACKGROUND_JOBS_HOST: config.host, VELOCIOUS_BACKGROUND_JOBS_PORT: `${config.port}`})
808
+ env: Object.assign({}, process.env, {VELOCIOUS_BACKGROUND_JOB_CHILD: "1", VELOCIOUS_ENV: configuration.getEnvironment(), VELOCIOUS_BACKGROUND_JOBS_HOST: config.host, VELOCIOUS_BACKGROUND_JOBS_PORT: `${config.port}`})
783
809
  })
784
810
  this.pooledChildren.add(child)
785
811
  this.inflightProcessChildren.add(child)
@@ -976,6 +1002,7 @@ export default class BackgroundJobsWorker {
976
1002
  execArgv: [],
977
1003
  stdio: ["ignore", "ignore", "ignore", "ipc"],
978
1004
  env: Object.assign({}, process.env, {
1005
+ VELOCIOUS_BACKGROUND_JOB_CHILD: "1",
979
1006
  VELOCIOUS_ENV: configuration.getEnvironment(),
980
1007
  VELOCIOUS_BACKGROUND_JOBS_HOST: backgroundJobsConfig.host,
981
1008
  VELOCIOUS_BACKGROUND_JOBS_PORT: `${backgroundJobsConfig.port}`
@@ -1203,6 +1230,7 @@ export default class BackgroundJobsWorker {
1203
1230
  detached: true,
1204
1231
  stdio: "ignore",
1205
1232
  env: Object.assign({}, process.env, {
1233
+ VELOCIOUS_BACKGROUND_JOB_CHILD: "1",
1206
1234
  VELOCIOUS_ENV: configuration.getEnvironment(),
1207
1235
  VELOCIOUS_BACKGROUND_JOBS_HOST: backgroundJobsConfig.host,
1208
1236
  VELOCIOUS_BACKGROUND_JOBS_PORT: `${backgroundJobsConfig.port}`,
@@ -1233,14 +1261,15 @@ export default class BackgroundJobsWorker {
1233
1261
  * Runs report job result.
1234
1262
  * @param {object} args - Options.
1235
1263
  * @param {string} args.jobId - Job id.
1236
- * @param {"completed" | "failed"} args.status - Status.
1264
+ * @param {"completed" | "failed" | "rescheduled"} args.status - Status.
1265
+ * @param {number} [args.delayMs] - Reschedule delay in milliseconds.
1237
1266
  * @param {ReturnType<typeof JSON.parse>} [args.error] - Error.
1238
1267
  * @param {string} [args.handoffId] - Handoff lease id.
1239
1268
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
1240
1269
  * @param {string} [args.workerId] - Worker id.
1241
1270
  * @returns {Promise<void>} - Resolves when reported.
1242
1271
  */
1243
- async _reportJobResult({jobId, status, error, handoffId, handedOffAtMs, workerId}) {
1272
+ async _reportJobResult({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}) {
1244
1273
  if (!this.statusReporter) return
1245
1274
 
1246
1275
  try {
@@ -1248,7 +1277,7 @@ export default class BackgroundJobsWorker {
1248
1277
  // long-lived and cannot exit to trigger orphan reclaim, so dropping the
1249
1278
  // completion here would strand the job in `handed_off` forever — fatal for a
1250
1279
  // `max_concurrency: 1` job (a stranded row blocks every future run).
1251
- await this.statusReporter.reportWithRetry({jobId, status, error, handoffId, handedOffAtMs, workerId, retryPersistErrors: true})
1280
+ await this.statusReporter.reportWithRetry({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, retryPersistErrors: true})
1252
1281
  } catch (reportError) {
1253
1282
  console.error("Background job status reporting failed:", reportError)
1254
1283
  }
@@ -1260,20 +1289,21 @@ export default class BackgroundJobsWorker {
1260
1289
  * graceful `stop()` can drain in-flight reports before closing the socket.
1261
1290
  * @param {object} args - Options.
1262
1291
  * @param {string} args.jobId - Job id.
1263
- * @param {"completed" | "failed"} args.status - Status.
1292
+ * @param {"completed" | "failed" | "rescheduled"} args.status - Status.
1293
+ * @param {number} [args.delayMs] - Reschedule delay in milliseconds.
1264
1294
  * @param {ReturnType<typeof JSON.parse>} [args.error] - Error.
1265
1295
  * @param {string} [args.handoffId] - Handoff lease id.
1266
1296
  * @param {number} [args.handedOffAtMs] - Handed off timestamp.
1267
1297
  * @param {string} [args.workerId] - Worker id.
1268
1298
  * @returns {void}
1269
1299
  */
1270
- _reportJobResultInBackground({jobId, status, error, handoffId, handedOffAtMs, workerId}) {
1300
+ _reportJobResultInBackground({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}) {
1271
1301
  /**
1272
1302
  * Defines report.
1273
1303
  * @type {Promise<void>} */
1274
1304
  let report
1275
1305
 
1276
- report = this._reportJobResult({jobId, status, error, handoffId, handedOffAtMs, workerId}).finally(() => {
1306
+ report = this._reportJobResult({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}).finally(() => {
1277
1307
  this.inflightReports.delete(report)
1278
1308
  })
1279
1309