velocious 1.0.588 → 1.0.590
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.
- package/README.md +21 -1
- package/build/background-jobs/job-runner.js +20 -1
- package/build/background-jobs/job.js +15 -0
- package/build/background-jobs/main.js +45 -0
- package/build/background-jobs/pooled-runner-child.js +3 -3
- package/build/background-jobs/reschedule-signal.js +14 -0
- package/build/background-jobs/status-reporter.js +9 -6
- package/build/background-jobs/store.js +124 -14
- package/build/background-jobs/types.js +2 -1
- package/build/background-jobs/worker.js +21 -6
- package/build/environment-handlers/node.js +5 -6
- package/build/src/background-jobs/job-runner.d.ts +2 -2
- package/build/src/background-jobs/job-runner.d.ts.map +1 -1
- package/build/src/background-jobs/job-runner.js +19 -2
- package/build/src/background-jobs/job.d.ts +7 -0
- package/build/src/background-jobs/job.d.ts.map +1 -1
- package/build/src/background-jobs/job.js +14 -1
- package/build/src/background-jobs/main.d.ts +11 -0
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +44 -1
- package/build/src/background-jobs/pooled-runner-child.js +4 -4
- package/build/src/background-jobs/reschedule-signal.d.ts +10 -0
- package/build/src/background-jobs/reschedule-signal.d.ts.map +1 -0
- package/build/src/background-jobs/reschedule-signal.js +14 -0
- package/build/src/background-jobs/status-reporter.d.ts +10 -6
- package/build/src/background-jobs/status-reporter.d.ts.map +1 -1
- package/build/src/background-jobs/status-reporter.js +10 -7
- package/build/src/background-jobs/store.d.ts +61 -10
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +116 -14
- package/build/src/background-jobs/types.d.ts +11 -2
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +3 -2
- package/build/src/background-jobs/worker.d.ts +10 -6
- package/build/src/background-jobs/worker.d.ts.map +1 -1
- package/build/src/background-jobs/worker.js +21 -7
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +6 -7
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/scripts/verify-docker-dev-environment.js +22 -8
- package/src/background-jobs/job-runner.js +20 -1
- package/src/background-jobs/job.js +15 -0
- package/src/background-jobs/main.js +45 -0
- package/src/background-jobs/pooled-runner-child.js +3 -3
- package/src/background-jobs/reschedule-signal.js +14 -0
- package/src/background-jobs/status-reporter.js +9 -6
- package/src/background-jobs/store.js +124 -14
- package/src/background-jobs/types.js +2 -1
- package/src/background-jobs/worker.js +21 -6
- package/src/environment-handlers/node.js +5 -6
package/README.md
CHANGED
|
@@ -2345,6 +2345,26 @@ await MyJob.performLaterWithOptions({
|
|
|
2345
2345
|
|
|
2346
2346
|
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
2347
|
|
|
2348
|
+
A running job that cannot proceed yet can reschedule its same durable row without
|
|
2349
|
+
recording a failure—for example, when a non-blocking lock is busy:
|
|
2350
|
+
|
|
2351
|
+
```js
|
|
2352
|
+
async perform(accountId) {
|
|
2353
|
+
if (!(await Account.tryAcquireRefreshLock(accountId))) {
|
|
2354
|
+
this.rescheduleIn(30_000)
|
|
2355
|
+
}
|
|
2356
|
+
|
|
2357
|
+
await refreshAccount(accountId)
|
|
2358
|
+
}
|
|
2359
|
+
```
|
|
2360
|
+
|
|
2361
|
+
`rescheduleIn(delayMs)` requires a finite, non-negative safe-integer millisecond
|
|
2362
|
+
delay and never returns: it stops the current `perform`, releases its worker and
|
|
2363
|
+
concurrency slots, and makes the same job eligible again after the delay. This is
|
|
2364
|
+
normal control flow, not failure retry: attempts and failure metadata remain
|
|
2365
|
+
unchanged, retries are not consumed, and failure/error events are not emitted.
|
|
2366
|
+
See [Rescheduling a running job](docs/background-jobs.md#rescheduling-a-running-job).
|
|
2367
|
+
|
|
2348
2368
|
Use a durable stable key when the same logical one-off schedule must be moved or cancelled without retaining its transient job id:
|
|
2349
2369
|
|
|
2350
2370
|
```js
|
|
@@ -2470,7 +2490,7 @@ backgroundJobs: {
|
|
|
2470
2490
|
}
|
|
2471
2491
|
```
|
|
2472
2492
|
|
|
2473
|
-
A job with no queue runs on `"default"`; a queue with no cap is unlimited. Caps are enforced through the durable per-key concurrency mechanism (the reserved `queue:<name>` key)
|
|
2493
|
+
A job with no queue runs on `"default"`; a queue with no cap is unlimited. Caps are enforced through the durable per-key concurrency mechanism (the reserved `queue:<name>` key) and hold regardless of how many worker processes run. Changing a cap is reconciled against the existing backlog only when `background-jobs-main` starts (serialized across processes with a database advisory lock); `db:migrate`, `db:tenants:*`, and routine store/application initialization never reconcile the backlog and stay read-only regarding queued jobs. Scheduled jobs honor a job's `static queue` too.
|
|
2474
2494
|
|
|
2475
2495
|
Set `priority` (default `0`) to dispatch a queue ahead of lower-priority ones regardless of enqueue order, so a small time-critical queue is never starved by a flood of low-priority work sharing a worker pool. Unlike Sidekiq's strict queue ordering, priority composes with the caps: a higher-priority queue already at its `maxConcurrent` is skipped and dispatch falls through to the next eligible job. See [docs/background-jobs.md](docs/background-jobs.md#queues-per-queue-concurrency-caps).
|
|
2476
2496
|
|
|
@@ -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<
|
|
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.
|
|
@@ -155,6 +155,12 @@ export default class BackgroundJobsMain {
|
|
|
155
155
|
await this.configuration.initialize({type: "background-jobs-main"})
|
|
156
156
|
await this.configuration.connectBeacon({peerType: "background-jobs-main"})
|
|
157
157
|
await this.store.ensureReady()
|
|
158
|
+
// Queue-cap changes are reconciled against the persisted backlog here, at
|
|
159
|
+
// main-process startup — the explicit lifecycle for applying queue
|
|
160
|
+
// configuration changes. The store serializes the adoption/release UPDATEs
|
|
161
|
+
// across processes with a database advisory lock, so concurrently started
|
|
162
|
+
// mains cannot interleave them.
|
|
163
|
+
await this.store.reconcileQueueConcurrency()
|
|
158
164
|
const server = net.createServer((socket) => this._handleConnection(socket))
|
|
159
165
|
this.server = server
|
|
160
166
|
|
|
@@ -595,6 +601,11 @@ export default class BackgroundJobsMain {
|
|
|
595
601
|
|
|
596
602
|
if (message?.type === "job-failed") {
|
|
597
603
|
this._handleJobFailed({jsonSocket, message})
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
if (message?.type === "job-reschedule") {
|
|
608
|
+
this._handleJobReschedule({jsonSocket, message})
|
|
598
609
|
}
|
|
599
610
|
}
|
|
600
611
|
|
|
@@ -879,6 +890,40 @@ export default class BackgroundJobsMain {
|
|
|
879
890
|
}
|
|
880
891
|
}
|
|
881
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
|
+
|
|
882
927
|
/**
|
|
883
928
|
* Runs handle job failed.
|
|
884
929
|
* @param {object} args - Options.
|
|
@@ -65,7 +65,7 @@ function isJobMessage(message) {
|
|
|
65
65
|
* @param {object} args - Outcome.
|
|
66
66
|
* @param {string} args.jobId - Job id.
|
|
67
67
|
* @param {boolean} args.acknowledged - Whether the terminal report was acknowledged.
|
|
68
|
-
* @param {"completed" | "failed"} [args.status] - Acknowledged
|
|
68
|
+
* @param {"completed" | "failed" | "rescheduled"} [args.status] - Acknowledged outcome.
|
|
69
69
|
* @param {Error} [args.error] - Reporting error when acknowledgement was not obtained.
|
|
70
70
|
* @returns {Promise<void>} - Resolves after IPC accepts the message.
|
|
71
71
|
*/
|
|
@@ -99,8 +99,8 @@ function sendOutcome({jobId, acknowledged, status, error}) {
|
|
|
99
99
|
*/
|
|
100
100
|
async function runJob(payload) {
|
|
101
101
|
try {
|
|
102
|
-
await runJobPayload(payload, {closeConnections: false, manageProcessTitle: false})
|
|
103
|
-
await sendOutcome({jobId: payload.id, acknowledged: true, status
|
|
102
|
+
const status = await runJobPayload(payload, {closeConnections: false, manageProcessTitle: false})
|
|
103
|
+
await sendOutcome({jobId: payload.id, acknowledged: true, status})
|
|
104
104
|
} catch (error) {
|
|
105
105
|
if (error instanceof BackgroundJobPerformedFailure) {
|
|
106
106
|
await sendOutcome({jobId: payload.id, acknowledged: true, status: "failed"})
|
|
@@ -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
|
|
@@ -158,6 +158,45 @@ export default class BackgroundJobsStore {
|
|
|
158
158
|
await this._ensureSchema(db)
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Reconciles queue-derived concurrency with the current configuration: the
|
|
163
|
+
* explicit lifecycle path that adopts/releases persisted queued jobs onto
|
|
164
|
+
* queue concurrency keys when `queues[name].maxConcurrent` is added, removed,
|
|
165
|
+
* or changed. Called by the background-jobs main process on startup — the
|
|
166
|
+
* deploy-time moment queue configuration changes take effect. Schema/tenant
|
|
167
|
+
* checks and routine connection initialization deliberately never run this:
|
|
168
|
+
* they stay read-only regarding queued job rows, because the broad
|
|
169
|
+
* adoption/release UPDATEs deadlock against active job processes under
|
|
170
|
+
* concurrent tenant initialization. Serialized across processes with a
|
|
171
|
+
* database advisory lock so concurrently started mains cannot interleave the
|
|
172
|
+
* UPDATEs; the per-instance memo only skips repeat work within this process.
|
|
173
|
+
* @returns {Promise<void>} - Resolves when reconciled.
|
|
174
|
+
*/
|
|
175
|
+
async reconcileQueueConcurrency() {
|
|
176
|
+
if (this._queueConcurrencyReconciled) return
|
|
177
|
+
|
|
178
|
+
await this.ensureReady()
|
|
179
|
+
|
|
180
|
+
await this._withDb(async (db) => {
|
|
181
|
+
const lockName = "background-jobs:queue-concurrency-reconcile"
|
|
182
|
+
const acquired = await db.acquireAdvisoryLock(lockName)
|
|
183
|
+
|
|
184
|
+
if (!acquired) throw new Error("Failed to acquire background job queue-concurrency reconcile lock")
|
|
185
|
+
|
|
186
|
+
try {
|
|
187
|
+
await this._reconcileQueueConcurrency(db)
|
|
188
|
+
await this._reconcileConcurrency(db)
|
|
189
|
+
|
|
190
|
+
// Latch the memo only after BOTH steps succeed: if the count rebuild
|
|
191
|
+
// fails after adoption, a retry on this store must re-enter and repair
|
|
192
|
+
// the counts (adoption itself is idempotent).
|
|
193
|
+
this._queueConcurrencyReconciled = true
|
|
194
|
+
} finally {
|
|
195
|
+
await db.releaseAdvisoryLock(lockName)
|
|
196
|
+
}
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
|
|
161
200
|
/**
|
|
162
201
|
* Runs enqueue.
|
|
163
202
|
* @param {object} args - Options.
|
|
@@ -655,6 +694,48 @@ export default class BackgroundJobsStore {
|
|
|
655
694
|
}))
|
|
656
695
|
}
|
|
657
696
|
|
|
697
|
+
/**
|
|
698
|
+
* Returns an active handoff to the queue at a caller-requested future time.
|
|
699
|
+
* This is normal job control flow: it preserves failure attempts and metadata.
|
|
700
|
+
* @param {object} args - Options.
|
|
701
|
+
* @param {string} args.jobId - Job id.
|
|
702
|
+
* @param {number} args.delayMs - Delay from persistence time in milliseconds.
|
|
703
|
+
* @param {string} [args.handoffId] - Handoff lease id.
|
|
704
|
+
* @param {string} [args.workerId] - Worker id.
|
|
705
|
+
* @param {number} [args.handedOffAtMs] - Handed off timestamp.
|
|
706
|
+
* @returns {Promise<boolean>} - Whether the fenced report was accepted.
|
|
707
|
+
*/
|
|
708
|
+
async markRescheduled({jobId, delayMs, handoffId, workerId, handedOffAtMs}) {
|
|
709
|
+
await this.ensureReady()
|
|
710
|
+
this._validateRescheduleDelayMs(delayMs)
|
|
711
|
+
|
|
712
|
+
return await this._withDb(async (db) => await this._serializedCountMutation(db, async () => {
|
|
713
|
+
const job = await this._getJobRowById(db, jobId)
|
|
714
|
+
|
|
715
|
+
if (!job) return false
|
|
716
|
+
if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return false
|
|
717
|
+
|
|
718
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
719
|
+
const scheduledAtMs = this._rescheduledAtMs(delayMs)
|
|
720
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
721
|
+
tableName: JOBS_TABLE,
|
|
722
|
+
data: {
|
|
723
|
+
status: "queued",
|
|
724
|
+
scheduled_at_ms: scheduledAtMs,
|
|
725
|
+
handed_off_at_ms: null,
|
|
726
|
+
handoff_id: null,
|
|
727
|
+
worker_id: null
|
|
728
|
+
},
|
|
729
|
+
conditions: this._activeHandoffConditions(job)
|
|
730
|
+
})
|
|
731
|
+
|
|
732
|
+
if (affectedRows !== 1) return false
|
|
733
|
+
await this._releaseConcurrency(db, job.concurrencyKey)
|
|
734
|
+
await this._recordStatusTransition(db, "handed_off", "queued")
|
|
735
|
+
return true
|
|
736
|
+
}))
|
|
737
|
+
}
|
|
738
|
+
|
|
658
739
|
/**
|
|
659
740
|
* Runs mark returned to queue.
|
|
660
741
|
* @param {object} args - Options.
|
|
@@ -1028,6 +1109,33 @@ export default class BackgroundJobsStore {
|
|
|
1028
1109
|
throw VelociousError.safe("background job scheduledAtMs must be a non-negative safe integer")
|
|
1029
1110
|
}
|
|
1030
1111
|
|
|
1112
|
+
/**
|
|
1113
|
+
* Resolves a reschedule delay against persistence time.
|
|
1114
|
+
* @param {number} delayMs - Delay in milliseconds.
|
|
1115
|
+
* @returns {number} - Future eligibility timestamp.
|
|
1116
|
+
*/
|
|
1117
|
+
_rescheduledAtMs(delayMs) {
|
|
1118
|
+
this._validateRescheduleDelayMs(delayMs)
|
|
1119
|
+
|
|
1120
|
+
const scheduledAtMs = Date.now() + delayMs
|
|
1121
|
+
if (!Number.isSafeInteger(scheduledAtMs)) {
|
|
1122
|
+
throw VelociousError.safe("background job reschedule scheduledAtMs must be a safe integer")
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
return scheduledAtMs
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
/**
|
|
1129
|
+
* Validates a public reschedule delay before persistence work begins.
|
|
1130
|
+
* @param {number} delayMs - Delay in milliseconds.
|
|
1131
|
+
* @returns {void}
|
|
1132
|
+
*/
|
|
1133
|
+
_validateRescheduleDelayMs(delayMs) {
|
|
1134
|
+
if (!Number.isSafeInteger(delayMs) || delayMs < 0) {
|
|
1135
|
+
throw VelociousError.safe("background job reschedule delayMs must be a non-negative safe integer")
|
|
1136
|
+
}
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1031
1139
|
/**
|
|
1032
1140
|
* Validates a stable schedule key at the public storage boundary.
|
|
1033
1141
|
* @param {string} scheduleKey - Stable logical schedule key.
|
|
@@ -1119,7 +1227,6 @@ export default class BackgroundJobsStore {
|
|
|
1119
1227
|
await this._ensureScheduleKeysTable(db)
|
|
1120
1228
|
await this._ensureConcurrencyTable(db)
|
|
1121
1229
|
await this._ensureCountRevisionTable(db)
|
|
1122
|
-
await this._reconcileQueueConcurrency(db)
|
|
1123
1230
|
await this._reconcileConcurrency(db)
|
|
1124
1231
|
|
|
1125
1232
|
return
|
|
@@ -1130,7 +1237,6 @@ export default class BackgroundJobsStore {
|
|
|
1130
1237
|
await this._ensureScheduleKeysTable(db)
|
|
1131
1238
|
await this._ensureConcurrencyTable(db)
|
|
1132
1239
|
await this._ensureCountRevisionTable(db)
|
|
1133
|
-
await this._reconcileQueueConcurrency(db)
|
|
1134
1240
|
await this._reconcileConcurrency(db)
|
|
1135
1241
|
|
|
1136
1242
|
if (alreadyApplied) return
|
|
@@ -2114,16 +2220,22 @@ export default class BackgroundJobsStore {
|
|
|
2114
2220
|
}
|
|
2115
2221
|
|
|
2116
2222
|
/**
|
|
2117
|
-
* Reconciles queue-derived concurrency with the current configuration
|
|
2118
|
-
*
|
|
2119
|
-
*
|
|
2120
|
-
*
|
|
2121
|
-
* stay
|
|
2122
|
-
*
|
|
2123
|
-
*
|
|
2124
|
-
*
|
|
2125
|
-
*
|
|
2126
|
-
*
|
|
2223
|
+
* Reconciles queue-derived concurrency with the current configuration. Only
|
|
2224
|
+
* invoked through {@link reconcileQueueConcurrency} — the explicit lifecycle
|
|
2225
|
+
* path run at main-process startup under a cross-process advisory lock —
|
|
2226
|
+
* never from schema/tenant checks or routine connection initialization,
|
|
2227
|
+
* which stay read-only regarding queued job rows. The per-process memo is
|
|
2228
|
+
* latched by {@link reconcileQueueConcurrency} only after the following
|
|
2229
|
+
* count rebuild also succeeds, so a failed rebuild re-enters here on retry
|
|
2230
|
+
* (the adoption UPDATEs below are idempotent). Enqueue only consults config for new jobs, so a cap added, removed, or changed
|
|
2231
|
+
* while a backlog exists otherwise leaves persisted rows stale: pre-cap jobs
|
|
2232
|
+
* keep a null key and bypass the cap, post-removal jobs stay capped under a
|
|
2233
|
+
* now-unconfigured key, and a changed numeric cap stays stale until the next
|
|
2234
|
+
* enqueue. Bring the durable state in line with config: sync each configured
|
|
2235
|
+
* queue's stored cap, adopt not-yet-keyed non-terminal jobs onto their queue
|
|
2236
|
+
* key, and release non-terminal jobs from queue keys whose queue is no
|
|
2237
|
+
* longer capped. Runs before {@link _reconcileConcurrency} so the rebuilt
|
|
2238
|
+
* active counts reflect the adopted/released keys.
|
|
2127
2239
|
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
2128
2240
|
* @returns {Promise<void>} - Resolves when reconciled.
|
|
2129
2241
|
*/
|
|
@@ -2168,8 +2280,6 @@ export default class BackgroundJobsStore {
|
|
|
2168
2280
|
`WHERE ${keyColumn} = ${db.quote(concurrencyKey)} AND ${nonTerminal}`
|
|
2169
2281
|
)
|
|
2170
2282
|
}
|
|
2171
|
-
|
|
2172
|
-
this._queueConcurrencyReconciled = true
|
|
2173
2283
|
}
|
|
2174
2284
|
|
|
2175
2285
|
/**
|
|
@@ -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",
|
|
@@ -1233,14 +1246,15 @@ export default class BackgroundJobsWorker {
|
|
|
1233
1246
|
* Runs report job result.
|
|
1234
1247
|
* @param {object} args - Options.
|
|
1235
1248
|
* @param {string} args.jobId - Job id.
|
|
1236
|
-
* @param {"completed" | "failed"} args.status - Status.
|
|
1249
|
+
* @param {"completed" | "failed" | "rescheduled"} args.status - Status.
|
|
1250
|
+
* @param {number} [args.delayMs] - Reschedule delay in milliseconds.
|
|
1237
1251
|
* @param {ReturnType<typeof JSON.parse>} [args.error] - Error.
|
|
1238
1252
|
* @param {string} [args.handoffId] - Handoff lease id.
|
|
1239
1253
|
* @param {number} [args.handedOffAtMs] - Handed off timestamp.
|
|
1240
1254
|
* @param {string} [args.workerId] - Worker id.
|
|
1241
1255
|
* @returns {Promise<void>} - Resolves when reported.
|
|
1242
1256
|
*/
|
|
1243
|
-
async _reportJobResult({jobId, status, error, handoffId, handedOffAtMs, workerId}) {
|
|
1257
|
+
async _reportJobResult({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}) {
|
|
1244
1258
|
if (!this.statusReporter) return
|
|
1245
1259
|
|
|
1246
1260
|
try {
|
|
@@ -1248,7 +1262,7 @@ export default class BackgroundJobsWorker {
|
|
|
1248
1262
|
// long-lived and cannot exit to trigger orphan reclaim, so dropping the
|
|
1249
1263
|
// completion here would strand the job in `handed_off` forever — fatal for a
|
|
1250
1264
|
// `max_concurrency: 1` job (a stranded row blocks every future run).
|
|
1251
|
-
await this.statusReporter.reportWithRetry({jobId, status, error, handoffId, handedOffAtMs, workerId, retryPersistErrors: true})
|
|
1265
|
+
await this.statusReporter.reportWithRetry({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId, retryPersistErrors: true})
|
|
1252
1266
|
} catch (reportError) {
|
|
1253
1267
|
console.error("Background job status reporting failed:", reportError)
|
|
1254
1268
|
}
|
|
@@ -1260,20 +1274,21 @@ export default class BackgroundJobsWorker {
|
|
|
1260
1274
|
* graceful `stop()` can drain in-flight reports before closing the socket.
|
|
1261
1275
|
* @param {object} args - Options.
|
|
1262
1276
|
* @param {string} args.jobId - Job id.
|
|
1263
|
-
* @param {"completed" | "failed"} args.status - Status.
|
|
1277
|
+
* @param {"completed" | "failed" | "rescheduled"} args.status - Status.
|
|
1278
|
+
* @param {number} [args.delayMs] - Reschedule delay in milliseconds.
|
|
1264
1279
|
* @param {ReturnType<typeof JSON.parse>} [args.error] - Error.
|
|
1265
1280
|
* @param {string} [args.handoffId] - Handoff lease id.
|
|
1266
1281
|
* @param {number} [args.handedOffAtMs] - Handed off timestamp.
|
|
1267
1282
|
* @param {string} [args.workerId] - Worker id.
|
|
1268
1283
|
* @returns {void}
|
|
1269
1284
|
*/
|
|
1270
|
-
_reportJobResultInBackground({jobId, status, error, handoffId, handedOffAtMs, workerId}) {
|
|
1285
|
+
_reportJobResultInBackground({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}) {
|
|
1271
1286
|
/**
|
|
1272
1287
|
* Defines report.
|
|
1273
1288
|
* @type {Promise<void>} */
|
|
1274
1289
|
let report
|
|
1275
1290
|
|
|
1276
|
-
report = this._reportJobResult({jobId, status, error, handoffId, handedOffAtMs, workerId}).finally(() => {
|
|
1291
|
+
report = this._reportJobResult({jobId, status, delayMs, error, handoffId, handedOffAtMs, workerId}).finally(() => {
|
|
1277
1292
|
this.inflightReports.delete(report)
|
|
1278
1293
|
})
|
|
1279
1294
|
|
|
@@ -1052,12 +1052,11 @@ export default class VelociousEnvironmentHandlerNode extends Base{
|
|
|
1052
1052
|
// `db:tenants:migrate <tenant>`, which migrates only tenant databases — the
|
|
1053
1053
|
// framework store lives elsewhere (typically the default DB) and was already
|
|
1054
1054
|
// ensured by the plain `db:migrate` that precedes it. Reaching into it here would
|
|
1055
|
-
// open a fresh connection to that shared database
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
//
|
|
1059
|
-
// (
|
|
1060
|
-
// store still creates it lazily if a plain migrate never ran.
|
|
1055
|
+
// open a fresh connection to that shared database once per tenant worker for
|
|
1056
|
+
// schema work that is already applied. So skip when the framework DB isn't in
|
|
1057
|
+
// this set; the runtime store still creates it lazily if a plain migrate never
|
|
1058
|
+
// ran. Queue-cap reconciliation never runs on this path at all — it belongs to
|
|
1059
|
+
// main-process startup (`BackgroundJobsStore#reconcileQueueConcurrency`).
|
|
1061
1060
|
if (!frameworkDb) return
|
|
1062
1061
|
|
|
1063
1062
|
// Reuse the connection db:migrate already holds for this database; opening a
|
|
@@ -11,10 +11,10 @@ export declare class BackgroundJobPerformedFailure extends Error {
|
|
|
11
11
|
* @param {object} [options] - Runner options.
|
|
12
12
|
* @param {boolean} [options.closeConnections] - Whether to gracefully close framework connections after the job.
|
|
13
13
|
* @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.
|
|
14
|
-
* @returns {Promise<
|
|
14
|
+
* @returns {Promise<"completed" | "rescheduled">} - Acknowledged outcome.
|
|
15
15
|
*/
|
|
16
16
|
export default function runJobPayload(payload: import("./types.js").BackgroundJobPayload, { closeConnections, manageProcessTitle }?: {
|
|
17
17
|
closeConnections?: boolean;
|
|
18
18
|
manageProcessTitle?: boolean;
|
|
19
|
-
}): Promise<
|
|
19
|
+
}): Promise<"completed" | "rescheduled">;
|
|
20
20
|
//# sourceMappingURL=job-runner.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"job-runner.d.ts","sourceRoot":"","sources":["../../../src/background-jobs/job-runner.js"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"job-runner.d.ts","sourceRoot":"","sources":["../../../src/background-jobs/job-runner.js"],"names":[],"mappings":"AASA,qBAAa,6BAA8B,SAAQ,KAAK;IACtD;;;OAGG;IACH,YAAY,KAAK,EAFN,KAEM,EAGhB;CACF;AA0DD;;;;;;;GAOG;AACH,wBAA8B,aAAa,CAAC,OAAO,EANxC,OAAO,YAAY,EAAE,oBAMmB,EAAE,EAAC,gBAAuB,EAAE,kBAAyB,EAAC,AALtG,CAGA,EAFA;IAA0B,gBAAgB,AAA1C,CACA,EADQ,OAAO,CACf;IAA0B,kBAAkB,AAA5C,CACA,EADQ,OAAO,CACf;CAE2G,GAFjG,OAAO,CAAC,WAAW,GAAG,aAAa,CAAC,CAsFhD"}
|