velocious 1.0.627 → 1.0.629
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 +2 -2
- package/build/background-jobs/adapter.js +16 -0
- package/build/background-jobs/job-runner.js +9 -1
- package/build/background-jobs/local-dispatcher.js +23 -1
- package/build/background-jobs/main.js +225 -26
- package/build/background-jobs/perform-job.js +10 -1
- package/build/background-jobs/platform-job.js +62 -0
- package/build/background-jobs/runtime.js +6 -3
- package/build/background-jobs/store.js +128 -19
- package/build/background-jobs/types.js +15 -0
- package/build/background-jobs/worker.js +3 -1
- package/build/src/background-jobs/adapter.d.ts +17 -0
- package/build/src/background-jobs/adapter.d.ts.map +1 -1
- package/build/src/background-jobs/adapter.js +15 -1
- package/build/src/background-jobs/job-runner.d.ts.map +1 -1
- package/build/src/background-jobs/job-runner.js +10 -2
- package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -1
- package/build/src/background-jobs/local-dispatcher.js +24 -2
- package/build/src/background-jobs/main.d.ts +71 -1
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +217 -28
- package/build/src/background-jobs/perform-job.d.ts +5 -1
- package/build/src/background-jobs/perform-job.d.ts.map +1 -1
- package/build/src/background-jobs/perform-job.js +11 -2
- package/build/src/background-jobs/platform-job.d.ts +34 -0
- package/build/src/background-jobs/platform-job.d.ts.map +1 -1
- package/build/src/background-jobs/platform-job.js +56 -1
- package/build/src/background-jobs/runtime.d.ts.map +1 -1
- package/build/src/background-jobs/runtime.js +7 -4
- package/build/src/background-jobs/store.d.ts +44 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +117 -19
- package/build/src/background-jobs/types.d.ts +55 -0
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +16 -1
- package/build/src/background-jobs/worker.d.ts.map +1 -1
- package/build/src/background-jobs/worker.js +4 -2
- package/package.json +1 -1
- package/src/background-jobs/adapter.js +16 -0
- package/src/background-jobs/job-runner.js +9 -1
- package/src/background-jobs/local-dispatcher.js +23 -1
- package/src/background-jobs/main.js +225 -26
- package/src/background-jobs/perform-job.js +10 -1
- package/src/background-jobs/platform-job.js +62 -0
- package/src/background-jobs/runtime.js +6 -3
- package/src/background-jobs/store.js +128 -19
- package/src/background-jobs/types.js +15 -0
- package/src/background-jobs/worker.js +3 -1
package/README.md
CHANGED
|
@@ -2310,7 +2310,7 @@ Create the file `src/routes/testing/another-action.ejs` and so something like th
|
|
|
2310
2310
|
|
|
2311
2311
|
Velocious includes a simple background jobs system inspired by Sidekiq.
|
|
2312
2312
|
|
|
2313
|
-
Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
|
|
2313
|
+
Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options, or by deriving the key in a hydrated job instance's non-static `concurrencyKey()` method. Explicit enqueue options win. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
|
|
2314
2314
|
|
|
2315
2315
|
Production apps can listen for `background-job-failed` (or its `all-error` mirror) to report accepted failed attempts, including retry and terminal-state metadata, and for `background-job-orphaned` to react to a specific job the main process reclaimed after its worker died mid-run — e.g. enqueue a targeted recovery for the work it left behind, instead of only polling for the aftermath. Orphan handlers run before the sweep waits for reclaimed jobs to be dispatched, so a stalled dispatcher does not delay application recovery. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).
|
|
2316
2316
|
|
|
@@ -2626,7 +2626,7 @@ Each job must define exactly one of `every` or `cron`. Cron times are evaluated
|
|
|
2626
2626
|
|
|
2627
2627
|
## Persistence and retries
|
|
2628
2628
|
|
|
2629
|
-
Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. If a 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. When the **main** itself restarts (every deploy),
|
|
2629
|
+
Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. If a 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. When the **main** itself restarts (every deploy), it snapshots exact pre-start leases and gives surviving workers 30 seconds to reconnect and adopt their active handoffs. At that deadline it waits up to one additional reconnect-grace interval for adoption queries that were already in flight, preventing a normal slow query from racing reclaim without letting a stuck query defer cleanup forever. A worker is excluded from startup reclaim only after its adoption query succeeds while that same socket is still connected; query failures and disconnects during adoption remain reclaimable. The main then orphans only unchanged startup snapshots belonging to worker ids that did not successfully adopt, using the normal retry/count/concurrency/event lifecycle and immediately waking queued work. A successfully adopted worker, a lease created after startup, and any newer re-handoff are fenced out. `BackgroundJobsMain` accepts `workerReconnectGraceMs` for tests and operational tuning; it must be an integer from 0 through 2,147,483,647 ms and invalid values throw. This startup cleanup does not lower the separate two-hour age sweep. 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).
|
|
2630
2630
|
|
|
2631
2631
|
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:
|
|
2632
2632
|
|
|
@@ -118,6 +118,22 @@ export default class BackgroundJobsAdapter {
|
|
|
118
118
|
*/
|
|
119
119
|
async handedOffJobsForWorker(_args) { throw new Error("BackgroundJobsAdapter#handedOffJobsForWorker is not implemented") }
|
|
120
120
|
|
|
121
|
+
/**
|
|
122
|
+
* Snapshots exact active handoffs before a new main generation accepts worker
|
|
123
|
+
* reconnects. Adapters that do not persist worker leases may return none.
|
|
124
|
+
* @returns {Promise<import("./types.js").BackgroundJobHandoffSnapshot[]>} - Exact active handoffs.
|
|
125
|
+
*/
|
|
126
|
+
async snapshotHandedOffJobs() { return [] }
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Applies orphan failure semantics to unchanged exact handoff snapshots.
|
|
130
|
+
* Adapters that return startup snapshots must implement the matching fenced
|
|
131
|
+
* transition.
|
|
132
|
+
* @param {{handoffs: import("./types.js").BackgroundJobHandoffSnapshot[], error: ReturnType<typeof JSON.parse>}} _args - Exact leases and orphan reason.
|
|
133
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow[]>} - Accepted transitions.
|
|
134
|
+
*/
|
|
135
|
+
async markOrphanedHandoffs(_args) { return [] }
|
|
136
|
+
|
|
121
137
|
/**
|
|
122
138
|
* Marks a handed-off job failed or retryable.
|
|
123
139
|
* @param {{jobId: string, error: ReturnType<typeof JSON.parse>, handoffId?: string, workerId?: string, handedOffAtMs?: number}} _args - Failure report.
|
|
@@ -93,6 +93,14 @@ export default async function runJobPayload(payload, {closeConnections = true, m
|
|
|
93
93
|
await registry.load()
|
|
94
94
|
const JobClass = registry.getJobByName(payload.jobName)
|
|
95
95
|
const jobInstance = new JobClass()
|
|
96
|
+
const jobArgs = payload.args || []
|
|
97
|
+
jobInstance._setBackgroundJobContext({
|
|
98
|
+
args: jobArgs,
|
|
99
|
+
jobClass: JobClass,
|
|
100
|
+
jobName: payload.jobName,
|
|
101
|
+
options: payload.options || {},
|
|
102
|
+
payload
|
|
103
|
+
})
|
|
96
104
|
/**
|
|
97
105
|
* Perform.
|
|
98
106
|
* @type {(...args: Array<ReturnType<typeof JSON.parse>>) => Promise<void>} */
|
|
@@ -107,7 +115,7 @@ export default async function runJobPayload(payload, {closeConnections = true, m
|
|
|
107
115
|
try {
|
|
108
116
|
try {
|
|
109
117
|
await configuration.withConnections({databaseIdentifiers: JobClass.databaseIdentifiers, name: `Background job runner: ${payload.jobName}`}, async () => {
|
|
110
|
-
await perform.apply(jobInstance,
|
|
118
|
+
await perform.apply(jobInstance, jobArgs)
|
|
111
119
|
})
|
|
112
120
|
} catch (error) {
|
|
113
121
|
if (error instanceof BackgroundJobRescheduleSignal) {
|
|
@@ -184,7 +184,29 @@ export default class LocalBackgroundJobsDispatcher {
|
|
|
184
184
|
configuration: this.configuration,
|
|
185
185
|
JobClass,
|
|
186
186
|
jobArgs: job.args,
|
|
187
|
-
|
|
187
|
+
jobOptions: {
|
|
188
|
+
concurrencyKey: job.concurrencyKey || undefined,
|
|
189
|
+
executionMode: job.executionMode,
|
|
190
|
+
maxConcurrency: job.maxConcurrency ?? undefined,
|
|
191
|
+
maxRetries: job.maxRetries ?? undefined,
|
|
192
|
+
queue: job.queue,
|
|
193
|
+
scheduledAtMs: job.scheduledAtMs ?? undefined,
|
|
194
|
+
timeoutMs: job.timeoutMs ?? undefined
|
|
195
|
+
},
|
|
196
|
+
name: `Local background job: ${job.jobName}`,
|
|
197
|
+
payload: {
|
|
198
|
+
args: job.args,
|
|
199
|
+
handedOffAtMs: handoff.handedOffAtMs,
|
|
200
|
+
handoffId: handoff.handoffId,
|
|
201
|
+
id: job.id,
|
|
202
|
+
jobName: job.jobName,
|
|
203
|
+
options: {
|
|
204
|
+
concurrencyKey: job.concurrencyKey || undefined,
|
|
205
|
+
executionMode: job.executionMode,
|
|
206
|
+
maxConcurrency: job.maxConcurrency ?? undefined,
|
|
207
|
+
queue: job.queue
|
|
208
|
+
}
|
|
209
|
+
}
|
|
188
210
|
})
|
|
189
211
|
} catch (error) {
|
|
190
212
|
if (error instanceof BackgroundJobRescheduleSignal) {
|
|
@@ -34,6 +34,24 @@ const MAX_TIMER_MS = 2_147_483_647 // ~24.8 days
|
|
|
34
34
|
const WORKER_STALE_TIMEOUT_MS = 60000
|
|
35
35
|
/** How often the main scans workers for staleness. */
|
|
36
36
|
const WORKER_LIVENESS_SWEEP_MS = 15000
|
|
37
|
+
/** Grace for workers from the previous main generation to reconnect and adopt leases. */
|
|
38
|
+
const WORKER_RECONNECT_GRACE_MS = 30000
|
|
39
|
+
const WORKER_RECONNECT_GRACE_VALIDATION_MESSAGE = `workerReconnectGraceMs must be an integer between 0 and ${MAX_TIMER_MS}`
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Resolves a startup reconnect grace without allowing Node's timer overflow to
|
|
43
|
+
* turn an intentionally long grace into an immediate reclaim.
|
|
44
|
+
* @param {number | undefined} workerReconnectGraceMs - Requested reconnect grace.
|
|
45
|
+
* @returns {number} - Valid timer delay.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeWorkerReconnectGraceMs(workerReconnectGraceMs) {
|
|
48
|
+
if (workerReconnectGraceMs === undefined) return WORKER_RECONNECT_GRACE_MS
|
|
49
|
+
if (!Number.isInteger(workerReconnectGraceMs) || workerReconnectGraceMs < 0 || workerReconnectGraceMs > MAX_TIMER_MS) {
|
|
50
|
+
throw new TypeError(WORKER_RECONNECT_GRACE_VALIDATION_MESSAGE)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return workerReconnectGraceMs
|
|
54
|
+
}
|
|
37
55
|
/**
|
|
38
56
|
* Worker execution mode capabilities.
|
|
39
57
|
* @type {WorkerExecutionModeCapability[]} */
|
|
@@ -61,10 +79,11 @@ export default class BackgroundJobsMain {
|
|
|
61
79
|
* @param {number} [args.port] - Port.
|
|
62
80
|
* @param {number} [args.workerStaleTimeoutMs] - Override how long a silent worker may go before being dropped (default 60000ms).
|
|
63
81
|
* @param {number} [args.workerLivenessSweepMs] - Override how often stale workers are swept for (default 15000ms).
|
|
82
|
+
* @param {number} [args.workerReconnectGraceMs] - Integer from 0 through 2,147,483,647 overriding how long previous-generation workers may reconnect before exact startup leases are reclaimed (default 30000ms).
|
|
64
83
|
* @param {boolean} [args.closeDatabaseConnectionsOnStop] - Whether stop owns closing the configuration's database pools (default true).
|
|
65
84
|
* @param {() => void | Promise<void>} [args.onStopped] - Lifecycle hook invoked after the main process finishes stopping.
|
|
66
85
|
*/
|
|
67
|
-
constructor({configuration, host, port, workerStaleTimeoutMs, workerLivenessSweepMs, closeDatabaseConnectionsOnStop = true, onStopped}) {
|
|
86
|
+
constructor({configuration, host, port, workerStaleTimeoutMs, workerLivenessSweepMs, workerReconnectGraceMs, closeDatabaseConnectionsOnStop = true, onStopped}) {
|
|
68
87
|
this.configuration = configuration
|
|
69
88
|
this.closeDatabaseConnectionsOnStop = closeDatabaseConnectionsOnStop
|
|
70
89
|
this.onStopped = onStopped
|
|
@@ -78,6 +97,7 @@ export default class BackgroundJobsMain {
|
|
|
78
97
|
// long is treated as wedged/dead: its leases are released and it is dropped.
|
|
79
98
|
this.workerStaleTimeoutMs = typeof workerStaleTimeoutMs === "number" && workerStaleTimeoutMs >= 1 ? workerStaleTimeoutMs : WORKER_STALE_TIMEOUT_MS
|
|
80
99
|
this.workerLivenessSweepMs = typeof workerLivenessSweepMs === "number" && workerLivenessSweepMs >= 1 ? workerLivenessSweepMs : WORKER_LIVENESS_SWEEP_MS
|
|
100
|
+
this.workerReconnectGraceMs = normalizeWorkerReconnectGraceMs(workerReconnectGraceMs)
|
|
81
101
|
/** @type {import("./adapter.js").default | undefined} */
|
|
82
102
|
this.adapter = undefined
|
|
83
103
|
this.logger = new Logger(this)
|
|
@@ -104,6 +124,17 @@ export default class BackgroundJobsMain {
|
|
|
104
124
|
* wait for these before closing the configuration's database pools.
|
|
105
125
|
* @type {Set<Promise<void>>} */
|
|
106
126
|
this.inflightWorkerHandoffAdoptions = new Set()
|
|
127
|
+
/**
|
|
128
|
+
* Worker ids whose handoffs were successfully adopted by a still-live
|
|
129
|
+
* connection in this main generation.
|
|
130
|
+
* @type {Set<string>}
|
|
131
|
+
*/
|
|
132
|
+
this.reconnectedWorkerIds = new Set()
|
|
133
|
+
/** @type {import("./types.js").BackgroundJobHandoffSnapshot[]} */
|
|
134
|
+
this.startupHandoffSnapshot = []
|
|
135
|
+
/** @type {Promise<void>[]} */
|
|
136
|
+
this._startupHandoffAdoptionsAtDeadline = []
|
|
137
|
+
this._startupHandoffGraceElapsed = false
|
|
107
138
|
/**
|
|
108
139
|
* Narrows the runtime value to the documented type.
|
|
109
140
|
* @type {net.Server | undefined} */
|
|
@@ -128,6 +159,10 @@ export default class BackgroundJobsMain {
|
|
|
128
159
|
* Narrows the runtime value to the documented type.
|
|
129
160
|
* @type {ReturnType<typeof setInterval> | undefined} */
|
|
130
161
|
this._workerStaleTimer = undefined
|
|
162
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
163
|
+
this._startupHandoffReclaimTimer = undefined
|
|
164
|
+
/** @type {Promise<void> | undefined} */
|
|
165
|
+
this._startupHandoffReclaimPromise = undefined
|
|
131
166
|
/**
|
|
132
167
|
* Narrows the runtime value to the documented type.
|
|
133
168
|
* @type {BackgroundJobsScheduler | undefined} */
|
|
@@ -178,6 +213,11 @@ export default class BackgroundJobsMain {
|
|
|
178
213
|
async start() {
|
|
179
214
|
this._stopped = false
|
|
180
215
|
this.stopPromise = undefined
|
|
216
|
+
this.reconnectedWorkerIds.clear()
|
|
217
|
+
this.startupHandoffSnapshot = []
|
|
218
|
+
this._startupHandoffAdoptionsAtDeadline = []
|
|
219
|
+
this._startupHandoffGraceElapsed = false
|
|
220
|
+
this._startupHandoffReclaimPromise = undefined
|
|
181
221
|
this.configuration.setCurrent()
|
|
182
222
|
|
|
183
223
|
try {
|
|
@@ -194,6 +234,7 @@ export default class BackgroundJobsMain {
|
|
|
194
234
|
// across processes with a database advisory lock, so concurrently started
|
|
195
235
|
// mains cannot interleave them.
|
|
196
236
|
await this.store.reconcileQueueConcurrency()
|
|
237
|
+
this.startupHandoffSnapshot = await this.store.snapshotHandedOffJobs()
|
|
197
238
|
const server = net.createServer((socket) => this._handleConnection(socket))
|
|
198
239
|
this.server = server
|
|
199
240
|
|
|
@@ -208,6 +249,7 @@ export default class BackgroundJobsMain {
|
|
|
208
249
|
}
|
|
209
250
|
|
|
210
251
|
this._setupDispatchTriggers()
|
|
252
|
+
this._setupStartupHandoffReclaim()
|
|
211
253
|
|
|
212
254
|
this._orphanTimer = setInterval(() => {
|
|
213
255
|
void this._sweepOrphans()
|
|
@@ -223,10 +265,7 @@ export default class BackgroundJobsMain {
|
|
|
223
265
|
await this.store.enqueue({
|
|
224
266
|
jobName: jobClass.jobName(),
|
|
225
267
|
args,
|
|
226
|
-
|
|
227
|
-
// scheduled job with `static queue = "..."` lands on its queue and
|
|
228
|
-
// honors the configured cap without every schedule repeating it.
|
|
229
|
-
options: jobClass._withQueue(options)
|
|
268
|
+
options: jobClass._withJobContext({jobArgs: args, jobOptions: options})
|
|
230
269
|
})
|
|
231
270
|
this._notifyEnqueued()
|
|
232
271
|
// Persistence is the scheduler enqueue boundary. Dispatch remains
|
|
@@ -300,7 +339,11 @@ export default class BackgroundJobsMain {
|
|
|
300
339
|
try {
|
|
301
340
|
await this._drainWorkerHandoffAdoptions()
|
|
302
341
|
} finally {
|
|
303
|
-
|
|
342
|
+
try {
|
|
343
|
+
await this._drainStartupHandoffReclaim()
|
|
344
|
+
} finally {
|
|
345
|
+
await this._stopBeaconAndServer()
|
|
346
|
+
}
|
|
304
347
|
}
|
|
305
348
|
}
|
|
306
349
|
}
|
|
@@ -328,11 +371,13 @@ export default class BackgroundJobsMain {
|
|
|
328
371
|
if (this._errorRetryTimer) clearTimeout(this._errorRetryTimer)
|
|
329
372
|
if (this._orphanTimer) clearInterval(this._orphanTimer)
|
|
330
373
|
if (this._workerStaleTimer) clearInterval(this._workerStaleTimer)
|
|
374
|
+
if (this._startupHandoffReclaimTimer) clearTimeout(this._startupHandoffReclaimTimer)
|
|
331
375
|
this._pollTimer = undefined
|
|
332
376
|
this._scheduledTimer = undefined
|
|
333
377
|
this._errorRetryTimer = undefined
|
|
334
378
|
this._orphanTimer = undefined
|
|
335
379
|
this._workerStaleTimer = undefined
|
|
380
|
+
this._startupHandoffReclaimTimer = undefined
|
|
336
381
|
}
|
|
337
382
|
|
|
338
383
|
/**
|
|
@@ -434,6 +479,123 @@ export default class BackgroundJobsMain {
|
|
|
434
479
|
beaconClient.on("connect", this._beaconConnectHandler)
|
|
435
480
|
}
|
|
436
481
|
|
|
482
|
+
/**
|
|
483
|
+
* Arms the bounded adoption grace only when startup found exact persisted
|
|
484
|
+
* handoffs. The timer is unrefed so an otherwise-finished process is never
|
|
485
|
+
* retained solely to perform this cleanup.
|
|
486
|
+
* @returns {void}
|
|
487
|
+
*/
|
|
488
|
+
_setupStartupHandoffReclaim() {
|
|
489
|
+
if (this.startupHandoffSnapshot.length === 0) return
|
|
490
|
+
|
|
491
|
+
this._startupHandoffReclaimTimer = setTimeout(() => {
|
|
492
|
+
this._startupHandoffReclaimTimer = undefined
|
|
493
|
+
this._startupHandoffAdoptionsAtDeadline = [...this.inflightWorkerHandoffAdoptions]
|
|
494
|
+
this._startupHandoffGraceElapsed = true
|
|
495
|
+
void this._startStartupHandoffReclaim()
|
|
496
|
+
}, this.workerReconnectGraceMs)
|
|
497
|
+
this._startupHandoffReclaimTimer.unref()
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Starts one tracked startup-reclaim pass, coalescing lifecycle and retry
|
|
502
|
+
* callers so shutdown can wait for durable mutation before closing pools.
|
|
503
|
+
* @returns {Promise<void>} - Resolves after this pass settles.
|
|
504
|
+
*/
|
|
505
|
+
_startStartupHandoffReclaim() {
|
|
506
|
+
if (this._startupHandoffReclaimPromise) return this._startupHandoffReclaimPromise
|
|
507
|
+
|
|
508
|
+
const reclaim = this._reclaimDisconnectedStartupHandoffs()
|
|
509
|
+
|
|
510
|
+
this._startupHandoffReclaimPromise = reclaim
|
|
511
|
+
const clearReclaim = () => {
|
|
512
|
+
if (this._startupHandoffReclaimPromise === reclaim) {
|
|
513
|
+
this._startupHandoffReclaimPromise = undefined
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
void reclaim.then(clearReclaim, clearReclaim)
|
|
517
|
+
|
|
518
|
+
return reclaim
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* Waits for an already-started startup reclaim before adapter shutdown.
|
|
523
|
+
* @returns {Promise<void>} - Resolves when no pass remains.
|
|
524
|
+
*/
|
|
525
|
+
async _drainStartupHandoffReclaim() {
|
|
526
|
+
while (this._startupHandoffReclaimPromise) {
|
|
527
|
+
await this._startupHandoffReclaimPromise
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Orphans only startup-snapshotted leases whose stable worker id has not been
|
|
533
|
+
* observed by this main generation. Store fencing rejects completed,
|
|
534
|
+
* returned, replaced, and re-handed-off rows.
|
|
535
|
+
* @returns {Promise<void>} - Resolves after reclaim or retained retry state.
|
|
536
|
+
*/
|
|
537
|
+
async _reclaimDisconnectedStartupHandoffs() {
|
|
538
|
+
if (this._stopped || !this._startupHandoffGraceElapsed) return
|
|
539
|
+
if (this.startupHandoffSnapshot.length === 0) return
|
|
540
|
+
|
|
541
|
+
await this._waitForStartupHandoffAdoptionsAtDeadline()
|
|
542
|
+
if (this._stopped) return
|
|
543
|
+
|
|
544
|
+
const handoffs = this.startupHandoffSnapshot.filter(({workerId}) => !this.reconnectedWorkerIds.has(workerId))
|
|
545
|
+
|
|
546
|
+
if (handoffs.length === 0) {
|
|
547
|
+
this.startupHandoffSnapshot = []
|
|
548
|
+
return
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
let orphanedJobs
|
|
552
|
+
|
|
553
|
+
try {
|
|
554
|
+
orphanedJobs = await this.store.markOrphanedHandoffs({
|
|
555
|
+
error: "Job orphaned after its pre-restart worker did not reconnect",
|
|
556
|
+
handoffs
|
|
557
|
+
})
|
|
558
|
+
} catch (error) {
|
|
559
|
+
this._reportStartupHandoffReclaimError(error)
|
|
560
|
+
this._scheduleErrorRetry()
|
|
561
|
+
return
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
this.startupHandoffSnapshot = []
|
|
565
|
+
await this._handleOrphanedJobs({
|
|
566
|
+
jobs: orphanedJobs,
|
|
567
|
+
warning: "Reclaimed background jobs from workers absent after main restart grace"
|
|
568
|
+
})
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Lets adoption queries already running at the reconnect deadline settle
|
|
573
|
+
* before worker ids are filtered. A second bounded grace prevents a stuck
|
|
574
|
+
* adapter query from deferring startup reclaim forever.
|
|
575
|
+
* @returns {Promise<void>} - Resolves when the deadline set settles or times out.
|
|
576
|
+
*/
|
|
577
|
+
async _waitForStartupHandoffAdoptionsAtDeadline() {
|
|
578
|
+
const adoptions = this._startupHandoffAdoptionsAtDeadline
|
|
579
|
+
|
|
580
|
+
this._startupHandoffAdoptionsAtDeadline = []
|
|
581
|
+
if (adoptions.length === 0) return
|
|
582
|
+
|
|
583
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
584
|
+
let timer
|
|
585
|
+
const waitLimit = new Promise((resolve) => {
|
|
586
|
+
// This lifecycle deadline must not keep the main process alive; the
|
|
587
|
+
// generic timeout helper intentionally uses a referenced timer.
|
|
588
|
+
timer = setTimeout(resolve, this.workerReconnectGraceMs)
|
|
589
|
+
timer.unref()
|
|
590
|
+
})
|
|
591
|
+
|
|
592
|
+
try {
|
|
593
|
+
await Promise.race([Promise.all(adoptions), waitLimit])
|
|
594
|
+
} finally {
|
|
595
|
+
if (timer) clearTimeout(timer)
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
437
599
|
/**
|
|
438
600
|
* Publishes a dispatch wake-up on the Beacon channel. No-op in polling
|
|
439
601
|
* mode or when Beacon is not connected; in those cases the direct
|
|
@@ -586,6 +748,7 @@ export default class BackgroundJobsMain {
|
|
|
586
748
|
for (const {jobId, handoffId} of handoffs) {
|
|
587
749
|
map.set(jobId, handoffId)
|
|
588
750
|
}
|
|
751
|
+
this.reconnectedWorkerIds.add(workerId)
|
|
589
752
|
} catch (error) {
|
|
590
753
|
this._reportHandoffAdoptError(error)
|
|
591
754
|
}
|
|
@@ -823,6 +986,22 @@ export default class BackgroundJobsMain {
|
|
|
823
986
|
errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
|
|
824
987
|
}
|
|
825
988
|
|
|
989
|
+
/**
|
|
990
|
+
* Reports an unexpected startup-snapshot reclaim failure while retaining the
|
|
991
|
+
* snapshot for the dispatcher's existing transient-error retry lifecycle.
|
|
992
|
+
* @param {ReturnType<typeof JSON.parse>} error - Reclaim failure.
|
|
993
|
+
* @returns {void}
|
|
994
|
+
*/
|
|
995
|
+
_reportStartupHandoffReclaimError(error) {
|
|
996
|
+
const normalizedError = error instanceof Error ? error : new Error(String(error))
|
|
997
|
+
const payload = {context: {stage: "background-job-startup-handoff-reclaim"}, error: normalizedError}
|
|
998
|
+
const errorEvents = this.configuration.getErrorEvents()
|
|
999
|
+
|
|
1000
|
+
this.logger.error(() => ["Failed to reclaim disconnected startup handoffs:", normalizedError])
|
|
1001
|
+
errorEvents.emit("framework-error", payload)
|
|
1002
|
+
errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
|
|
1003
|
+
}
|
|
1004
|
+
|
|
826
1005
|
/**
|
|
827
1006
|
* Runs handle enqueue.
|
|
828
1007
|
* @param {object} args - Options.
|
|
@@ -1240,6 +1419,7 @@ export default class BackgroundJobsMain {
|
|
|
1240
1419
|
* @returns {void} */
|
|
1241
1420
|
_clearErrorRetryTimer() {
|
|
1242
1421
|
if (this.pendingHandoffRecoveries.size > 0) return
|
|
1422
|
+
if (this._startupHandoffGraceElapsed && this.startupHandoffSnapshot.length > 0) return
|
|
1243
1423
|
|
|
1244
1424
|
for (const worker of this.workerHandoffs.keys()) {
|
|
1245
1425
|
if (!this.workers.has(worker)) return
|
|
@@ -1314,6 +1494,11 @@ export default class BackgroundJobsMain {
|
|
|
1314
1494
|
async _retryAfterError() {
|
|
1315
1495
|
if (this._stopped) return
|
|
1316
1496
|
|
|
1497
|
+
if (this._startupHandoffGraceElapsed && this.startupHandoffSnapshot.length > 0) {
|
|
1498
|
+
await this._startStartupHandoffReclaim()
|
|
1499
|
+
if (this.startupHandoffSnapshot.length > 0) return
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1317
1502
|
try {
|
|
1318
1503
|
await this._retryPendingHandoffRecoveries()
|
|
1319
1504
|
} catch {
|
|
@@ -1400,7 +1585,12 @@ export default class BackgroundJobsMain {
|
|
|
1400
1585
|
workerId: worker.workerId,
|
|
1401
1586
|
handedOffAtMs: handoff.handedOffAtMs,
|
|
1402
1587
|
options: {
|
|
1588
|
+
concurrencyKey: job.concurrencyKey || undefined,
|
|
1403
1589
|
executionMode: job.executionMode,
|
|
1590
|
+
maxConcurrency: job.maxConcurrency ?? undefined,
|
|
1591
|
+
maxRetries: job.maxRetries ?? undefined,
|
|
1592
|
+
queue: job.queue,
|
|
1593
|
+
scheduledAtMs: job.scheduledAtMs ?? undefined,
|
|
1404
1594
|
...(job.timeoutMs === null ? {} : {timeoutMs: job.timeoutMs})
|
|
1405
1595
|
}
|
|
1406
1596
|
}
|
|
@@ -1648,31 +1838,40 @@ export default class BackgroundJobsMain {
|
|
|
1648
1838
|
try {
|
|
1649
1839
|
const orphanedJobs = await this.store.markOrphanedJobs()
|
|
1650
1840
|
|
|
1651
|
-
|
|
1652
|
-
this.logger.warn(() => ["Marked orphaned background jobs", orphanedJobs.length])
|
|
1653
|
-
// Reclaimed orphans become `queued` again — wake the dispatcher first so
|
|
1654
|
-
// an application event handler that throws below cannot strand them
|
|
1655
|
-
// queued until the next external enqueue/reconnect.
|
|
1656
|
-
this._notifyEnqueued()
|
|
1657
|
-
// Emit an event per orphaned job so applications can react to a dead
|
|
1658
|
-
// worker's specific job (e.g. targeted recovery) instead of only polling
|
|
1659
|
-
// for its aftermath. Emit before awaiting the drain so a blocked
|
|
1660
|
-
// dispatcher cannot delay application recovery. Isolate each so one
|
|
1661
|
-
// throwing handler can't suppress the events for the rest.
|
|
1662
|
-
for (const job of orphanedJobs) {
|
|
1663
|
-
try {
|
|
1664
|
-
this._emitBackgroundJobOrphaned({job})
|
|
1665
|
-
} catch (error) {
|
|
1666
|
-
this.logger.error(() => ["A background-job-orphaned event handler threw:", error])
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1669
|
-
await this._drain()
|
|
1670
|
-
}
|
|
1841
|
+
await this._handleOrphanedJobs({jobs: orphanedJobs, warning: "Marked orphaned background jobs"})
|
|
1671
1842
|
} catch (error) {
|
|
1672
1843
|
this.logger.error(() => ["Failed to mark orphaned jobs:", error])
|
|
1673
1844
|
}
|
|
1674
1845
|
}
|
|
1675
1846
|
|
|
1847
|
+
/**
|
|
1848
|
+
* Publishes the common post-orphan lifecycle: wake queued retries, emit one
|
|
1849
|
+
* isolated event per accepted transition, and drain so released concurrency
|
|
1850
|
+
* can immediately admit other work.
|
|
1851
|
+
* @param {object} args - Options.
|
|
1852
|
+
* @param {import("./types.js").BackgroundJobRow[]} args.jobs - Accepted orphan transitions.
|
|
1853
|
+
* @param {string} args.warning - Lifecycle log message.
|
|
1854
|
+
* @returns {Promise<void>} - Resolves after the resulting drain.
|
|
1855
|
+
*/
|
|
1856
|
+
async _handleOrphanedJobs({jobs, warning}) {
|
|
1857
|
+
if (jobs.length === 0) return
|
|
1858
|
+
|
|
1859
|
+
this.logger.warn(() => [warning, jobs.length])
|
|
1860
|
+
// Reclaimed orphans can become `queued` again — wake the dispatcher first
|
|
1861
|
+
// so an application event handler that throws below cannot strand them.
|
|
1862
|
+
this._notifyEnqueued()
|
|
1863
|
+
// Emit before awaiting the drain so a blocked dispatcher cannot delay
|
|
1864
|
+
// application recovery. Isolate handlers so one cannot suppress the rest.
|
|
1865
|
+
for (const job of jobs) {
|
|
1866
|
+
try {
|
|
1867
|
+
this._emitBackgroundJobOrphaned({job})
|
|
1868
|
+
} catch (error) {
|
|
1869
|
+
this.logger.error(() => ["A background-job-orphaned event handler threw:", error])
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
await this._drain()
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1676
1875
|
/**
|
|
1677
1876
|
* Drops workers that have gone silent past `workerStaleTimeoutMs` (no
|
|
1678
1877
|
* heartbeat, ready, or report). A wedged worker keeps its socket open, so the
|
|
@@ -6,11 +6,20 @@
|
|
|
6
6
|
* @param {import("../configuration.js").default} args.configuration - Active configuration.
|
|
7
7
|
* @param {typeof import("./platform-job.js").default} args.JobClass - Job class.
|
|
8
8
|
* @param {Array<ReturnType<typeof JSON.parse>>} args.jobArgs - Job arguments.
|
|
9
|
+
* @param {import("./types.js").BackgroundJobOptions} [args.jobOptions] - Resolved runtime options.
|
|
9
10
|
* @param {string} args.name - Connection-scope label.
|
|
11
|
+
* @param {import("./types.js").BackgroundJobPayload} [args.payload] - Persisted runner payload.
|
|
10
12
|
* @returns {Promise<void>} - Resolves after performance.
|
|
11
13
|
*/
|
|
12
|
-
export default async function performBackgroundJob({configuration, JobClass, jobArgs, name}) {
|
|
14
|
+
export default async function performBackgroundJob({configuration, JobClass, jobArgs, jobOptions = {}, name, payload}) {
|
|
13
15
|
const jobInstance = new JobClass()
|
|
16
|
+
jobInstance._setBackgroundJobContext({
|
|
17
|
+
args: jobArgs,
|
|
18
|
+
jobClass: JobClass,
|
|
19
|
+
jobName: JobClass.jobName(),
|
|
20
|
+
options: jobOptions,
|
|
21
|
+
...(payload ? {payload} : {})
|
|
22
|
+
})
|
|
14
23
|
/**
|
|
15
24
|
* Narrows the generic subclass's runtime method to serialized job arguments.
|
|
16
25
|
* @type {(...args: Array<ReturnType<typeof JSON.parse>>) => Promise<void>}
|
|
@@ -14,6 +14,11 @@ import {cancelScheduledBackgroundJob, enqueueBackgroundJob, replaceScheduledBack
|
|
|
14
14
|
* @template {Array<ReturnType<typeof JSON.parse>>} [TArgs=[]]
|
|
15
15
|
*/
|
|
16
16
|
export default class VelociousJob {
|
|
17
|
+
constructor() {
|
|
18
|
+
/** @type {import("./types.js").BackgroundJobContext | undefined} */
|
|
19
|
+
this._backgroundJobContext = undefined
|
|
20
|
+
}
|
|
21
|
+
|
|
17
22
|
/**
|
|
18
23
|
* Database identifiers checked out while this job performs. Set an explicit
|
|
19
24
|
* list to avoid holding unrelated configured database connections, or `[]`
|
|
@@ -82,6 +87,63 @@ export default class VelociousJob {
|
|
|
82
87
|
return merged
|
|
83
88
|
}
|
|
84
89
|
|
|
90
|
+
/**
|
|
91
|
+
* Resolves class-derived enqueue options on a hydrated job instance. Explicit
|
|
92
|
+
* per-enqueue options take precedence over the instance concurrency key.
|
|
93
|
+
* @param {object} args - Job context.
|
|
94
|
+
* @param {Array<ReturnType<typeof JSON.parse>>} args.jobArgs - Job arguments.
|
|
95
|
+
* @param {import("./types.js").BackgroundJobOptions | undefined} args.jobOptions - Job options.
|
|
96
|
+
* @returns {import("./types.js").BackgroundJobOptions} - Resolved job options.
|
|
97
|
+
*/
|
|
98
|
+
static _withJobContext({jobArgs, jobOptions}) {
|
|
99
|
+
const options = this._withQueue(jobOptions)
|
|
100
|
+
|
|
101
|
+
if (options.concurrencyKey !== undefined) return options
|
|
102
|
+
|
|
103
|
+
const jobInstance = new this()
|
|
104
|
+
jobInstance._setBackgroundJobContext({
|
|
105
|
+
args: jobArgs,
|
|
106
|
+
jobClass: this,
|
|
107
|
+
jobName: this.jobName(),
|
|
108
|
+
options
|
|
109
|
+
})
|
|
110
|
+
const concurrencyKey = jobInstance.concurrencyKey()
|
|
111
|
+
|
|
112
|
+
if (concurrencyKey !== undefined) options.concurrencyKey = concurrencyKey
|
|
113
|
+
|
|
114
|
+
return options
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Sets the complete context available to this hydrated job instance.
|
|
119
|
+
* Framework enqueue/runner boundaries own this method.
|
|
120
|
+
* @param {import("./types.js").BackgroundJobContext} context - Job context.
|
|
121
|
+
* @returns {void}
|
|
122
|
+
*/
|
|
123
|
+
_setBackgroundJobContext(context) {
|
|
124
|
+
this._backgroundJobContext = context
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns this hydrated job's complete enqueue or runner context.
|
|
129
|
+
* @returns {import("./types.js").BackgroundJobContext} - Job context.
|
|
130
|
+
*/
|
|
131
|
+
backgroundJobContext() {
|
|
132
|
+
if (!this._backgroundJobContext) throw new Error("Background job context is not hydrated")
|
|
133
|
+
|
|
134
|
+
return this._backgroundJobContext
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Override to derive a durable concurrency key from `backgroundJobContext()`.
|
|
139
|
+
* Pair the derived key with `maxConcurrency` in enqueue options. An explicit
|
|
140
|
+
* per-enqueue `concurrencyKey` takes precedence and skips this method.
|
|
141
|
+
* @returns {string | undefined} - Derived concurrency key, or undefined for none.
|
|
142
|
+
*/
|
|
143
|
+
concurrencyKey() {
|
|
144
|
+
return undefined
|
|
145
|
+
}
|
|
146
|
+
|
|
85
147
|
/**
|
|
86
148
|
* Runs perform later.
|
|
87
149
|
* @param {...ReturnType<typeof JSON.parse>} args - Job args.
|
|
@@ -52,9 +52,10 @@ export async function enqueueBackgroundJob({JobClass, jobArgs, jobOptions}) {
|
|
|
52
52
|
* @returns {Promise<string>} - Durable job id or ephemeral inline performance id.
|
|
53
53
|
*/
|
|
54
54
|
export async function enqueueBackgroundJobForConfiguration({configuration, JobClass, jobArgs, jobOptions}) {
|
|
55
|
+
const resolvedJobOptions = JobClass._withJobContext({jobArgs, jobOptions})
|
|
55
56
|
|
|
56
57
|
if (configuration.getBackgroundJobsConfig().mode === "inline") {
|
|
57
|
-
validateInlineOptions(
|
|
58
|
+
validateInlineOptions(resolvedJobOptions)
|
|
58
59
|
configuration.setCurrent()
|
|
59
60
|
await configuration.initialize({type: "background-jobs-inline"})
|
|
60
61
|
|
|
@@ -63,6 +64,7 @@ export async function enqueueBackgroundJobForConfiguration({configuration, JobCl
|
|
|
63
64
|
configuration,
|
|
64
65
|
JobClass,
|
|
65
66
|
jobArgs,
|
|
67
|
+
jobOptions: resolvedJobOptions,
|
|
66
68
|
name: `Background job inline mode: ${JobClass.jobName()}`
|
|
67
69
|
})
|
|
68
70
|
} catch (error) {
|
|
@@ -81,7 +83,7 @@ export async function enqueueBackgroundJobForConfiguration({configuration, JobCl
|
|
|
81
83
|
return await client.enqueue({
|
|
82
84
|
jobName: JobClass.jobName(),
|
|
83
85
|
args: jobArgs,
|
|
84
|
-
options:
|
|
86
|
+
options: resolvedJobOptions
|
|
85
87
|
})
|
|
86
88
|
}
|
|
87
89
|
|
|
@@ -117,12 +119,13 @@ export async function replaceScheduledBackgroundJobForConfiguration({configurati
|
|
|
117
119
|
}
|
|
118
120
|
|
|
119
121
|
const client = configuration.getEnvironmentHandler().backgroundJobsClient({configuration})
|
|
122
|
+
const resolvedJobOptions = JobClass._withJobContext({jobArgs, jobOptions})
|
|
120
123
|
|
|
121
124
|
return await client.replaceScheduled({
|
|
122
125
|
scheduleKey,
|
|
123
126
|
jobName: JobClass.jobName(),
|
|
124
127
|
args: jobArgs,
|
|
125
|
-
options:
|
|
128
|
+
options: resolvedJobOptions
|
|
126
129
|
})
|
|
127
130
|
}
|
|
128
131
|
|