velocious 1.0.518 → 1.0.520
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 +43 -1
- package/build/background-jobs/json-socket.js +12 -0
- package/build/background-jobs/main.js +88 -2
- package/build/background-jobs/store.js +177 -8
- package/build/background-jobs/types.js +4 -2
- package/build/background-jobs/worker.js +113 -23
- package/build/configuration-types.js +27 -0
- package/build/configuration.js +18 -3
- package/build/database/record/attachments/handle.js +18 -0
- package/build/database/record/attachments/store.js +45 -0
- package/build/environment-handlers/node/cli/commands/generate/base-models.js +1 -1
- package/build/http-server/client/index.js +1 -1
- package/build/jobs/prune-terminal-background-jobs.js +67 -0
- package/build/src/background-jobs/json-socket.d.ts +12 -0
- package/build/src/background-jobs/json-socket.d.ts.map +1 -1
- package/build/src/background-jobs/json-socket.js +13 -1
- package/build/src/background-jobs/main.d.ts +22 -1
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +81 -3
- package/build/src/background-jobs/store.d.ts +53 -1
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +154 -8
- package/build/src/background-jobs/types.d.ts +14 -3
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +5 -3
- package/build/src/background-jobs/worker.d.ts +55 -7
- package/build/src/background-jobs/worker.d.ts.map +1 -1
- package/build/src/background-jobs/worker.js +108 -22
- package/build/src/configuration-types.d.ts +79 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +27 -1
- package/build/src/configuration.d.ts +4 -2
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +18 -3
- package/build/src/database/record/attachments/handle.d.ts +11 -0
- package/build/src/database/record/attachments/handle.d.ts.map +1 -1
- package/build/src/database/record/attachments/handle.js +17 -1
- package/build/src/database/record/attachments/store.d.ts +13 -0
- package/build/src/database/record/attachments/store.d.ts.map +1 -1
- package/build/src/database/record/attachments/store.js +40 -1
- package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +2 -2
- package/build/src/http-server/client/index.js +2 -2
- package/build/src/jobs/prune-terminal-background-jobs.d.ts +23 -0
- package/build/src/jobs/prune-terminal-background-jobs.d.ts.map +1 -0
- package/build/src/jobs/prune-terminal-background-jobs.js +61 -0
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/background-jobs/json-socket.js +12 -0
- package/src/background-jobs/main.js +88 -2
- package/src/background-jobs/store.js +177 -8
- package/src/background-jobs/types.js +4 -2
- package/src/background-jobs/worker.js +113 -23
- package/src/configuration-types.js +27 -0
- package/src/configuration.js +18 -3
- package/src/database/record/attachments/handle.js +18 -0
- package/src/database/record/attachments/store.js +45 -0
- package/src/environment-handlers/node/cli/commands/generate/base-models.js +1 -1
- package/src/http-server/client/index.js +1 -1
- package/src/jobs/prune-terminal-background-jobs.js +67 -0
package/README.md
CHANGED
|
@@ -440,7 +440,7 @@ This creates `src/frontend-models/user.js` (and one file per configured resource
|
|
|
440
440
|
- Attribute methods like `user.name()` and `user.setName(...)`
|
|
441
441
|
- Relationship helpers (when `relationships` are configured), for example `task.project()`, `await task.projectOrLoad()`, `await project.tasks().toArray()`, `await project.tasks().load()`, and `project.tasks().build({...})`
|
|
442
442
|
- Preload relationships onto records you already have with `await record.preload(Model.preload({...}).select({...}))` (or `Preloader.preload(records, ...)` for arrays), including `selectsExtra(...)` and a `{force: true}` reload option — see [docs/frontend-models.md](docs/frontend-models.md#preloading-onto-loaded-records)
|
|
443
|
-
- Attachment helpers (when `attachments` are configured), for example `await task.descriptionFile().attach(file)`, `await task.descriptionFile().download()`, and `await task.update({descriptionFile: file})`
|
|
443
|
+
- Attachment helpers (when `attachments` are configured), for example `await task.descriptionFile().attach(file)`, `await task.descriptionFile().download()`, `await task.files().purgeAll()`, and `await task.update({descriptionFile: file})`
|
|
444
444
|
|
|
445
445
|
React components can subscribe to lifecycle broadcasts without manual cleanup code:
|
|
446
446
|
|
|
@@ -501,6 +501,14 @@ await task.update({
|
|
|
501
501
|
})
|
|
502
502
|
```
|
|
503
503
|
|
|
504
|
+
Purge a record's attachments — both the stored files and their rows — for example before destroying the owner record:
|
|
505
|
+
|
|
506
|
+
```js
|
|
507
|
+
const purgedCount = await task.files().purgeAll()
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
`purgeAll()` deletes each attachment's backing storage and then its row, and removes only the attachments that existed when the purge started (a concurrent `attach()` for the same record/name is left intact). It throws without deleting anything if a storage driver has no `delete` operation, so a driver configured without deletion can never silently leak storage. It is a no-op for unpersisted records and returns the number of attachments purged.
|
|
511
|
+
|
|
504
512
|
Configure attachment storage drivers in `Configuration`:
|
|
505
513
|
|
|
506
514
|
```js
|
|
@@ -2123,6 +2131,40 @@ await MyJob.performLaterWithOptions({
|
|
|
2123
2131
|
|
|
2124
2132
|
If a handed-off job does not report back within 2 hours, it is marked orphaned and re-queued if retries remain.
|
|
2125
2133
|
|
|
2134
|
+
Workers also send periodic heartbeats (and use TCP keepalive) so the main can drop a wedged or half-open worker that never fires a socket `close` and release its leases, and job slots are freed independently of durable, background result reporting so a transient outage can't wedge a worker or lose a completion. See [docs/background-jobs.md](docs/background-jobs.md#worker-liveness).
|
|
2135
|
+
|
|
2136
|
+
## Queues
|
|
2137
|
+
|
|
2138
|
+
Give a job a queue with `static queue = "..."` (or a `{queue}` job option; the option wins) and cap how many of that queue's jobs run in flight across the whole cluster under `backgroundJobs.queues`:
|
|
2139
|
+
|
|
2140
|
+
```js
|
|
2141
|
+
backgroundJobs: {
|
|
2142
|
+
queues: {
|
|
2143
|
+
builds: {maxConcurrent: 100}, // I/O-bound: can run well above the core count
|
|
2144
|
+
default: {maxConcurrent: 8} // CPU-bound: keep near the core count
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
```
|
|
2148
|
+
|
|
2149
|
+
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), hold regardless of how many worker processes run, and are reconciled against the existing backlog on startup when you change them. Scheduled jobs honor a job's `static queue` too. See [docs/background-jobs.md](docs/background-jobs.md#queues-per-queue-concurrency-caps).
|
|
2150
|
+
|
|
2151
|
+
## Retention
|
|
2152
|
+
|
|
2153
|
+
Terminal `background_jobs` rows are not deleted automatically unless you configure retention, so a busy app otherwise grows the table indefinitely. Set `backgroundJobs.retention` to prune old terminal rows:
|
|
2154
|
+
|
|
2155
|
+
```js
|
|
2156
|
+
backgroundJobs: {
|
|
2157
|
+
retention: {
|
|
2158
|
+
completedTtlMs: 7 * 24 * 60 * 60 * 1000, // default: 7 days (null/0 disables)
|
|
2159
|
+
failedTtlMs: 30 * 24 * 60 * 60 * 1000, // default: 30 days for failed/orphaned (null/0 disables)
|
|
2160
|
+
batchSize: 1000, // default: 1000 rows per delete batch
|
|
2161
|
+
sweepIntervalMs: 60 * 60 * 1000 // default: 1 hour
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
```
|
|
2165
|
+
|
|
2166
|
+
`background-jobs-main` registers a built-in `velocious:prune-terminal-background-jobs` job on the scheduler when retention is enabled, so pruning runs as an ordinary scheduled/queued job (it needs a worker, appears in the job tables, and is bounded to one non-overlapping run). See [docs/background-jobs.md](docs/background-jobs.md#retention-pruning-old-job-rows).
|
|
2167
|
+
|
|
2126
2168
|
## Dashboard
|
|
2127
2169
|
|
|
2128
2170
|
Velocious ships a mountable read-only HTTP API for inspecting jobs (queued, running, completed, failed, orphaned and scheduled), similar in spirit to `sidekiq-web`. Mount it in your routes file the way `Sidekiq::Web` is mounted in Rails:
|
|
@@ -30,6 +30,18 @@ export default class JsonSocket extends EventEmitter {
|
|
|
30
30
|
* Narrows the runtime value to the documented type.
|
|
31
31
|
* @type {boolean} */
|
|
32
32
|
this.acceptsInlineJobs = true
|
|
33
|
+
/**
|
|
34
|
+
* Whether this worker advertised heartbeat support in its hello. Only
|
|
35
|
+
* heartbeat-capable workers are subject to the main's stale-liveness
|
|
36
|
+
* eviction; a legacy worker (e.g. mid rolling deploy) is exempt so its
|
|
37
|
+
* active leases are not released while it is still running them.
|
|
38
|
+
* @type {boolean} */
|
|
39
|
+
this.supportsHeartbeat = false
|
|
40
|
+
/**
|
|
41
|
+
* Last time (ms) the main saw any message from this worker socket; used by
|
|
42
|
+
* the main's liveness sweep to drop a wedged/silent worker.
|
|
43
|
+
* @type {number | undefined} */
|
|
44
|
+
this.lastSeenAt = undefined
|
|
33
45
|
this.buffer = ""
|
|
34
46
|
this.socket.setEncoding("utf8")
|
|
35
47
|
this.socket.on("data", (chunk) => this._onData(String(chunk)))
|
|
@@ -5,6 +5,7 @@ import JsonSocket from "./json-socket.js"
|
|
|
5
5
|
import BackgroundJobsScheduler from "./scheduler.js"
|
|
6
6
|
import BackgroundJobsStore from "./store.js"
|
|
7
7
|
import Logger from "../logger.js"
|
|
8
|
+
import PruneTerminalBackgroundJobsJob from "../jobs/prune-terminal-background-jobs.js"
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Channel used by `background-jobs-main` to coordinate dispatch wake-ups
|
|
@@ -21,6 +22,10 @@ const DISPATCH_CHANNEL = "velocious-background-jobs-dispatch"
|
|
|
21
22
|
* scheduled-job timer here and re-arm when it expires.
|
|
22
23
|
*/
|
|
23
24
|
const MAX_TIMER_MS = 2_147_483_647 // ~24.8 days
|
|
25
|
+
/** A worker silent (no heartbeat/ready/report) longer than this is dropped. */
|
|
26
|
+
const WORKER_STALE_TIMEOUT_MS = 60000
|
|
27
|
+
/** How often the main scans workers for staleness. */
|
|
28
|
+
const WORKER_LIVENESS_SWEEP_MS = 15000
|
|
24
29
|
/**
|
|
25
30
|
* WorkerExecutionModeCapability type.
|
|
26
31
|
* @typedef {object} WorkerExecutionModeCapability
|
|
@@ -46,14 +51,21 @@ export default class BackgroundJobsMain {
|
|
|
46
51
|
* @param {import("../configuration.js").default} args.configuration - Configuration.
|
|
47
52
|
* @param {string} [args.host] - Hostname.
|
|
48
53
|
* @param {number} [args.port] - Port.
|
|
54
|
+
* @param {number} [args.workerStaleTimeoutMs] - Override how long a silent worker may go before being dropped (default 60000ms).
|
|
55
|
+
* @param {number} [args.workerLivenessSweepMs] - Override how often stale workers are swept for (default 15000ms).
|
|
49
56
|
*/
|
|
50
|
-
constructor({configuration, host, port}) {
|
|
57
|
+
constructor({configuration, host, port, workerStaleTimeoutMs, workerLivenessSweepMs}) {
|
|
51
58
|
this.configuration = configuration
|
|
52
59
|
const config = configuration.getBackgroundJobsConfig()
|
|
53
60
|
this.host = host || config.host
|
|
54
61
|
this.port = typeof port === "number" ? port : config.port
|
|
55
62
|
this.dispatchStrategy = config.dispatchStrategy
|
|
56
63
|
this.pollIntervalMs = config.pollIntervalMs
|
|
64
|
+
this.retention = config.retention
|
|
65
|
+
// A worker that stops sending anything (heartbeat/ready/report) for this
|
|
66
|
+
// long is treated as wedged/dead: its leases are released and it is dropped.
|
|
67
|
+
this.workerStaleTimeoutMs = typeof workerStaleTimeoutMs === "number" && workerStaleTimeoutMs >= 1 ? workerStaleTimeoutMs : WORKER_STALE_TIMEOUT_MS
|
|
68
|
+
this.workerLivenessSweepMs = typeof workerLivenessSweepMs === "number" && workerLivenessSweepMs >= 1 ? workerLivenessSweepMs : WORKER_LIVENESS_SWEEP_MS
|
|
57
69
|
this.store = new BackgroundJobsStore({configuration, databaseIdentifier: config.databaseIdentifier})
|
|
58
70
|
this.logger = new Logger(this)
|
|
59
71
|
/**
|
|
@@ -88,6 +100,10 @@ export default class BackgroundJobsMain {
|
|
|
88
100
|
* Narrows the runtime value to the documented type.
|
|
89
101
|
* @type {ReturnType<typeof setTimeout> | undefined} */
|
|
90
102
|
this._orphanTimer = undefined
|
|
103
|
+
/**
|
|
104
|
+
* Narrows the runtime value to the documented type.
|
|
105
|
+
* @type {ReturnType<typeof setInterval> | undefined} */
|
|
106
|
+
this._workerStaleTimer = undefined
|
|
91
107
|
/**
|
|
92
108
|
* Narrows the runtime value to the documented type.
|
|
93
109
|
* @type {BackgroundJobsScheduler | undefined} */
|
|
@@ -139,13 +155,20 @@ export default class BackgroundJobsMain {
|
|
|
139
155
|
void this._sweepOrphans()
|
|
140
156
|
}, 60000)
|
|
141
157
|
|
|
158
|
+
this._workerStaleTimer = setInterval(() => {
|
|
159
|
+
void this._sweepStaleWorkers()
|
|
160
|
+
}, this.workerLivenessSweepMs)
|
|
161
|
+
|
|
142
162
|
this.scheduler = new BackgroundJobsScheduler({
|
|
143
163
|
configuration: this.configuration,
|
|
144
164
|
enqueueJob: async ({args, jobClass, options}) => {
|
|
145
165
|
await this.store.enqueue({
|
|
146
166
|
jobName: jobClass.jobName(),
|
|
147
167
|
args,
|
|
148
|
-
|
|
168
|
+
// Fold in the job class's static `queue` (as performLater* do) so a
|
|
169
|
+
// scheduled job with `static queue = "..."` lands on its queue and
|
|
170
|
+
// honors the configured cap without every schedule repeating it.
|
|
171
|
+
options: jobClass._withQueue(options)
|
|
149
172
|
})
|
|
150
173
|
this._notifyEnqueued()
|
|
151
174
|
await this._drain()
|
|
@@ -153,6 +176,16 @@ export default class BackgroundJobsMain {
|
|
|
153
176
|
})
|
|
154
177
|
await this.scheduler.start()
|
|
155
178
|
|
|
179
|
+
// Retention pruning runs as an ordinary scheduled job on the normal
|
|
180
|
+
// scheduler (so it is visible in the job tables and dispatched to a
|
|
181
|
+
// worker), rather than a hidden in-process timer. Skipped when retention
|
|
182
|
+
// is disabled. The scheduler owns the timer, so scheduler.stop() clears it.
|
|
183
|
+
const retentionSchedule = PruneTerminalBackgroundJobsJob.scheduleConfiguration(this.retention)
|
|
184
|
+
|
|
185
|
+
if (retentionSchedule) {
|
|
186
|
+
this.scheduler.scheduleJob({jobConfiguration: retentionSchedule, jobKey: "velociousPruneTerminalBackgroundJobs"})
|
|
187
|
+
}
|
|
188
|
+
|
|
156
189
|
// Startup catch-up: drain anything that was waiting before this
|
|
157
190
|
// process came up. In beacon mode this is also the safety net for
|
|
158
191
|
// races between attaching the connect listener and the initial
|
|
@@ -197,10 +230,12 @@ export default class BackgroundJobsMain {
|
|
|
197
230
|
if (this._scheduledTimer) clearTimeout(this._scheduledTimer)
|
|
198
231
|
if (this._errorRetryTimer) clearTimeout(this._errorRetryTimer)
|
|
199
232
|
if (this._orphanTimer) clearInterval(this._orphanTimer)
|
|
233
|
+
if (this._workerStaleTimer) clearInterval(this._workerStaleTimer)
|
|
200
234
|
this._pollTimer = undefined
|
|
201
235
|
this._scheduledTimer = undefined
|
|
202
236
|
this._errorRetryTimer = undefined
|
|
203
237
|
this._orphanTimer = undefined
|
|
238
|
+
this._workerStaleTimer = undefined
|
|
204
239
|
}
|
|
205
240
|
|
|
206
241
|
/**
|
|
@@ -383,6 +418,8 @@ export default class BackgroundJobsMain {
|
|
|
383
418
|
if (message.role === "worker") {
|
|
384
419
|
jsonSocket.workerId = message.workerId
|
|
385
420
|
jsonSocket.supportsHandoffIdReporting = message.supportsHandoffIdReporting === true
|
|
421
|
+
jsonSocket.supportsHeartbeat = message.supportsHeartbeat === true
|
|
422
|
+
jsonSocket.lastSeenAt = Date.now()
|
|
386
423
|
this.workers.add(jsonSocket)
|
|
387
424
|
this.workerHandoffs.set(jsonSocket, new Map())
|
|
388
425
|
}
|
|
@@ -411,6 +448,14 @@ export default class BackgroundJobsMain {
|
|
|
411
448
|
* @returns {void}
|
|
412
449
|
*/
|
|
413
450
|
_handleWorkerSocketMessage({jsonSocket, message}) {
|
|
451
|
+
// Any message from the worker proves it is alive; the liveness sweep uses
|
|
452
|
+
// this to detect a wedged/silent worker.
|
|
453
|
+
jsonSocket.lastSeenAt = Date.now()
|
|
454
|
+
|
|
455
|
+
if (message?.type === "heartbeat") {
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
414
459
|
if (message?.type === "ready") {
|
|
415
460
|
this._handleWorkerReady({jsonSocket, message})
|
|
416
461
|
return
|
|
@@ -1085,4 +1130,45 @@ export default class BackgroundJobsMain {
|
|
|
1085
1130
|
this.logger.error(() => ["Failed to mark orphaned jobs:", error])
|
|
1086
1131
|
}
|
|
1087
1132
|
}
|
|
1133
|
+
|
|
1134
|
+
/**
|
|
1135
|
+
* Drops workers that have gone silent past `workerStaleTimeoutMs` (no
|
|
1136
|
+
* heartbeat, ready, or report). A wedged worker keeps its socket open, so the
|
|
1137
|
+
* `close`-based cleanup never fires and its in-flight leases — and the whole
|
|
1138
|
+
* queue — stay stuck until a human notices. Releasing the lost worker's
|
|
1139
|
+
* leases lets its jobs run elsewhere and stops dispatch to it; the worker's
|
|
1140
|
+
* own process lifecycle is the supervisor's concern.
|
|
1141
|
+
* @returns {Promise<void>} - Resolves after the sweep.
|
|
1142
|
+
*/
|
|
1143
|
+
async _sweepStaleWorkers() {
|
|
1144
|
+
if (this._stopped) return
|
|
1145
|
+
|
|
1146
|
+
const cutoff = Date.now() - this.workerStaleTimeoutMs
|
|
1147
|
+
/** @type {JsonSocket[]} */
|
|
1148
|
+
const stale = []
|
|
1149
|
+
|
|
1150
|
+
for (const worker of this.workers) {
|
|
1151
|
+
// Only evict heartbeat-capable workers. A legacy worker (e.g. one from the
|
|
1152
|
+
// previous release during a rolling deploy) never heartbeats, so evicting
|
|
1153
|
+
// it on silence would wrongly release the leases of a job it is still
|
|
1154
|
+
// running. Its disconnect is still handled by the socket `close` path.
|
|
1155
|
+
if (!worker.supportsHeartbeat) continue
|
|
1156
|
+
|
|
1157
|
+
const lastSeenAt = typeof worker.lastSeenAt === "number" ? worker.lastSeenAt : 0
|
|
1158
|
+
|
|
1159
|
+
if (lastSeenAt <= cutoff) stale.push(worker)
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
for (const worker of stale) {
|
|
1163
|
+
this.logger.warn(() => ["Dropping stale background jobs worker", {workerId: worker.workerId, lastSeenAt: worker.lastSeenAt}])
|
|
1164
|
+
|
|
1165
|
+
try {
|
|
1166
|
+
worker.close()
|
|
1167
|
+
} catch {
|
|
1168
|
+
// Already closing; the lease release below is what matters.
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
await this._handleWorkerSocketClosed(worker)
|
|
1172
|
+
}
|
|
1173
|
+
}
|
|
1088
1174
|
}
|
|
@@ -51,6 +51,7 @@ export default class BackgroundJobsStore {
|
|
|
51
51
|
this.databaseIdentifier = databaseIdentifier
|
|
52
52
|
this.logger = new Logger(this)
|
|
53
53
|
this._readyPromise = null
|
|
54
|
+
this._queueConcurrencyReconciled = false
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
@@ -101,8 +102,26 @@ export default class BackgroundJobsStore {
|
|
|
101
102
|
const argsJson = JSON.stringify(args || [])
|
|
102
103
|
const queue = this._normalizeQueue(options)
|
|
103
104
|
const concurrency = this._resolveConcurrency(options, queue)
|
|
105
|
+
/** @type {string} */
|
|
106
|
+
let resultJobId = jobId
|
|
104
107
|
|
|
105
108
|
await this._withDb(async (db) => {
|
|
109
|
+
if (options?.deduplicateWhileQueued && concurrency?.concurrencyKey) {
|
|
110
|
+
const existing = await db
|
|
111
|
+
.newQuery()
|
|
112
|
+
.from(JOBS_TABLE)
|
|
113
|
+
.select("id")
|
|
114
|
+
.where({status: "queued", concurrency_key: concurrency.concurrencyKey})
|
|
115
|
+
.limit(1)
|
|
116
|
+
.results()
|
|
117
|
+
|
|
118
|
+
if (existing[0]) {
|
|
119
|
+
resultJobId = String(/** @type {Record<string, ?>} */ (existing[0]).id)
|
|
120
|
+
|
|
121
|
+
return
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
106
125
|
if (concurrency) {
|
|
107
126
|
if (concurrency.queueDerived) {
|
|
108
127
|
await this._ensureQueueConcurrencyKey(db, concurrency)
|
|
@@ -130,7 +149,7 @@ export default class BackgroundJobsStore {
|
|
|
130
149
|
})
|
|
131
150
|
})
|
|
132
151
|
|
|
133
|
-
return
|
|
152
|
+
return resultJobId
|
|
134
153
|
}
|
|
135
154
|
|
|
136
155
|
/**
|
|
@@ -476,11 +495,25 @@ export default class BackgroundJobsStore {
|
|
|
476
495
|
for (const row of rows) {
|
|
477
496
|
const job = this._normalizeJobRow(row)
|
|
478
497
|
|
|
498
|
+
// Fence the reclaim on the exact handoff this sweep selected, using its
|
|
499
|
+
// `handed_off_at_ms` rather than its `handoff_id`. Two reasons:
|
|
500
|
+
// 1. Null-safe. Some rows have a null `handoff_id` (handed off by an
|
|
501
|
+
// older velocious before handoff-id fencing). `{handoff_id: null}`
|
|
502
|
+
// renders as `handoff_id = NULL`, which matches nothing, so those
|
|
503
|
+
// rows would be stranded in `handed_off` forever.
|
|
504
|
+
// 2. Race-safe. If the row is returned to the queue and re-handed-off
|
|
505
|
+
// between the SELECT above and this update, it gets a fresh
|
|
506
|
+
// `handed_off_at_ms` (always "now"), so this stale cutoff-era
|
|
507
|
+
// timestamp no longer matches and we won't fail/orphan — or
|
|
508
|
+
// wrongly release the concurrency reservation of — that new lease.
|
|
509
|
+
// `handed_off_at_ms` is always set on a handed-off row (and the SELECT
|
|
510
|
+
// required it `<= cutoff`), so it is a reliable null-safe lease pin.
|
|
479
511
|
const orphanedJob = await this._applyFailure({
|
|
480
512
|
db,
|
|
481
513
|
job,
|
|
482
514
|
error: "Job orphaned after timeout",
|
|
483
|
-
markOrphaned: true
|
|
515
|
+
markOrphaned: true,
|
|
516
|
+
conditions: {id: job.id, status: "handed_off", handed_off_at_ms: job.handedOffAtMs}
|
|
484
517
|
})
|
|
485
518
|
|
|
486
519
|
if (orphanedJob) orphanedCount += 1
|
|
@@ -490,6 +523,78 @@ export default class BackgroundJobsStore {
|
|
|
490
523
|
})
|
|
491
524
|
}
|
|
492
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Deletes terminal job rows past their retention window so the jobs table
|
|
528
|
+
* does not grow unbounded (completed rows in particular accumulate forever
|
|
529
|
+
* otherwise). Batched by id — SELECT a page of ids, then
|
|
530
|
+
* `DELETE ... WHERE id IN (...)` — rather than `DELETE ... LIMIT`, which not
|
|
531
|
+
* every driver supports; each batch runs on its own connection so the sweep
|
|
532
|
+
* yields between batches instead of holding one long transaction.
|
|
533
|
+
* @param {object} [args] - Options.
|
|
534
|
+
* @param {number | null} [args.completedTtlMs] - Delete `completed` jobs whose `completed_at_ms` is older than this many ms. Falsy or `<= 0` disables completed pruning.
|
|
535
|
+
* @param {number | null} [args.failedTtlMs] - Delete terminal `failed`/`orphaned` jobs older than this many ms (by `failed_at_ms`/`orphaned_at_ms`). Falsy or `<= 0` disables.
|
|
536
|
+
* @param {number} [args.batchSize] - Max rows deleted per batch. Default `1000`.
|
|
537
|
+
* @returns {Promise<number>} - Total rows deleted.
|
|
538
|
+
*/
|
|
539
|
+
async pruneTerminalJobs({completedTtlMs = null, failedTtlMs = null, batchSize = 1000} = {}) {
|
|
540
|
+
await this.ensureReady()
|
|
541
|
+
|
|
542
|
+
const now = Date.now()
|
|
543
|
+
const size = batchSize > 0 ? batchSize : 1000
|
|
544
|
+
let deleted = 0
|
|
545
|
+
|
|
546
|
+
if (completedTtlMs && completedTtlMs > 0) {
|
|
547
|
+
deleted += await this._pruneStatusBatches({status: "completed", column: "completed_at_ms", cutoff: now - completedTtlMs, batchSize: size})
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
if (failedTtlMs && failedTtlMs > 0) {
|
|
551
|
+
deleted += await this._pruneStatusBatches({status: "failed", column: "failed_at_ms", cutoff: now - failedTtlMs, batchSize: size})
|
|
552
|
+
deleted += await this._pruneStatusBatches({status: "orphaned", column: "orphaned_at_ms", cutoff: now - failedTtlMs, batchSize: size})
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
return deleted
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* Deletes rows of one terminal status older than a cutoff, batch by batch,
|
|
560
|
+
* until a page returns fewer than `batchSize` rows.
|
|
561
|
+
* @param {object} args - Options.
|
|
562
|
+
* @param {string} args.status - Terminal status to prune.
|
|
563
|
+
* @param {string} args.column - Timestamp column compared against the cutoff.
|
|
564
|
+
* @param {number} args.cutoff - Delete rows whose column value is `<= cutoff`.
|
|
565
|
+
* @param {number} args.batchSize - Max rows per batch.
|
|
566
|
+
* @returns {Promise<number>} - Rows deleted for this status.
|
|
567
|
+
*/
|
|
568
|
+
async _pruneStatusBatches({status, column, cutoff, batchSize}) {
|
|
569
|
+
let deleted = 0
|
|
570
|
+
|
|
571
|
+
for (;;) {
|
|
572
|
+
const removed = await this._withDb(async (db) => {
|
|
573
|
+
const rows = await db
|
|
574
|
+
.newQuery()
|
|
575
|
+
.from(JOBS_TABLE)
|
|
576
|
+
.select("id")
|
|
577
|
+
.where({status})
|
|
578
|
+
.where(`${db.quoteColumn(column)} <= ${db.quote(cutoff)}`)
|
|
579
|
+
.limit(batchSize)
|
|
580
|
+
.results()
|
|
581
|
+
|
|
582
|
+
if (rows.length === 0) return 0
|
|
583
|
+
|
|
584
|
+
const ids = rows.map((/** @type {Record<string, ?>} */ row) => db.quote(String(row.id))).join(", ")
|
|
585
|
+
|
|
586
|
+
await db.query(`DELETE FROM ${db.quoteTable(JOBS_TABLE)} WHERE ${db.quoteColumn("id")} IN (${ids})`)
|
|
587
|
+
|
|
588
|
+
return rows.length
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
deleted += removed
|
|
592
|
+
if (removed < batchSize) break
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
return deleted
|
|
596
|
+
}
|
|
597
|
+
|
|
493
598
|
/**
|
|
494
599
|
* Runs clear all.
|
|
495
600
|
* @returns {Promise<void>} - Resolves when cleared.
|
|
@@ -562,6 +667,7 @@ export default class BackgroundJobsStore {
|
|
|
562
667
|
if (alreadyApplied && await db.tableExists(JOBS_TABLE)) {
|
|
563
668
|
await this._ensureJobsTableColumns(db)
|
|
564
669
|
await this._ensureConcurrencyTable(db)
|
|
670
|
+
await this._reconcileQueueConcurrency(db)
|
|
565
671
|
await this._reconcileConcurrency(db)
|
|
566
672
|
return
|
|
567
673
|
}
|
|
@@ -569,6 +675,7 @@ export default class BackgroundJobsStore {
|
|
|
569
675
|
await this._applyMigrations(db)
|
|
570
676
|
await this._ensureJobsTableColumns(db)
|
|
571
677
|
await this._ensureConcurrencyTable(db)
|
|
678
|
+
await this._reconcileQueueConcurrency(db)
|
|
572
679
|
await this._reconcileConcurrency(db)
|
|
573
680
|
|
|
574
681
|
if (alreadyApplied) return
|
|
@@ -748,16 +855,15 @@ export default class BackgroundJobsStore {
|
|
|
748
855
|
* @returns {Promise<void>} - Resolves when ensured.
|
|
749
856
|
*/
|
|
750
857
|
async _ensureQueueColumn(db) {
|
|
751
|
-
const table = await db.getTableByNameOrFail(JOBS_TABLE)
|
|
752
|
-
|
|
753
|
-
if (await table.getColumnByName("queue")) return
|
|
754
|
-
|
|
755
858
|
const lockName = `${MIGRATION_SCOPE}:queue_column`
|
|
756
859
|
const acquired = await db.acquireAdvisoryLock(lockName)
|
|
757
860
|
|
|
758
861
|
if (!acquired) throw new Error("Failed to acquire background jobs queue schema lock")
|
|
759
862
|
|
|
760
863
|
try {
|
|
864
|
+
// SQL Server schema reads can deadlock with a concurrent ALTER TABLE, so
|
|
865
|
+
// acquire the lock before inspecting the column rather than only
|
|
866
|
+
// protecting the mutation (mirrors the concurrency-column migration).
|
|
761
867
|
db.clearSchemaCache()
|
|
762
868
|
const lockedTable = await db.getTableByNameOrFail(JOBS_TABLE)
|
|
763
869
|
|
|
@@ -869,9 +975,10 @@ export default class BackgroundJobsStore {
|
|
|
869
975
|
* @param {import("./types.js").BackgroundJobRow} args.job - Job row.
|
|
870
976
|
* @param {?} args.error - Error.
|
|
871
977
|
* @param {boolean} args.markOrphaned - Whether marking orphaned.
|
|
978
|
+
* @param {Record<string, ?>} [args.conditions] - Update fencing conditions. Defaults to the active-handoff lease match; the time-based orphan sweep overrides this with an id/status match so it can reclaim rows whose `handoff_id` is null (e.g. handed off by an older velocious before handoff-id fencing existed).
|
|
872
979
|
* @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Updated job row when the lease transition won.
|
|
873
980
|
*/
|
|
874
|
-
async _applyFailure({db, job, error, markOrphaned}) {
|
|
981
|
+
async _applyFailure({db, job, error, markOrphaned, conditions}) {
|
|
875
982
|
const now = Date.now()
|
|
876
983
|
const nextAttempt = (job.attempts || 0) + 1
|
|
877
984
|
const maxRetries = this._normalizeMaxRetries(job.maxRetries)
|
|
@@ -890,7 +997,7 @@ export default class BackgroundJobsStore {
|
|
|
890
997
|
const affectedRows = await this._updateAffectedRows(db, {
|
|
891
998
|
tableName: JOBS_TABLE,
|
|
892
999
|
data: update,
|
|
893
|
-
conditions: this._activeHandoffConditions(job)
|
|
1000
|
+
conditions: conditions ?? this._activeHandoffConditions(job)
|
|
894
1001
|
})
|
|
895
1002
|
|
|
896
1003
|
if (affectedRows !== 1) return null
|
|
@@ -1014,6 +1121,9 @@ export default class BackgroundJobsStore {
|
|
|
1014
1121
|
if (typeof key !== "string" || key.length === 0 || !Number.isInteger(cap) || Number(cap) <= 0) {
|
|
1015
1122
|
throw new Error("background job concurrencyKey and maxConcurrency must be paired; concurrencyKey must be non-empty and maxConcurrency must be a positive integer")
|
|
1016
1123
|
}
|
|
1124
|
+
if (key.startsWith(QUEUE_CONCURRENCY_KEY_PREFIX)) {
|
|
1125
|
+
throw new Error(`background job concurrencyKey must not start with the reserved "${QUEUE_CONCURRENCY_KEY_PREFIX}" prefix, which is reserved for queue-derived concurrency caps`)
|
|
1126
|
+
}
|
|
1017
1127
|
return {concurrencyKey: key, maxConcurrency: Number(cap)}
|
|
1018
1128
|
}
|
|
1019
1129
|
|
|
@@ -1190,6 +1300,65 @@ export default class BackgroundJobsStore {
|
|
|
1190
1300
|
)
|
|
1191
1301
|
}
|
|
1192
1302
|
|
|
1303
|
+
/**
|
|
1304
|
+
* Reconciles queue-derived concurrency with the current configuration, once
|
|
1305
|
+
* per process. Enqueue only consults config for new jobs, so a cap added,
|
|
1306
|
+
* removed, or changed while a backlog exists otherwise leaves persisted rows
|
|
1307
|
+
* stale: pre-cap jobs keep a null key and bypass the cap, post-removal jobs
|
|
1308
|
+
* stay capped under a now-unconfigured key, and a changed numeric cap stays
|
|
1309
|
+
* stale until the next enqueue. Bring the durable state in line with config:
|
|
1310
|
+
* sync each configured queue's stored cap, adopt not-yet-keyed non-terminal
|
|
1311
|
+
* jobs onto their queue key, and release non-terminal jobs from queue keys
|
|
1312
|
+
* whose queue is no longer capped. Runs before {@link _reconcileConcurrency}
|
|
1313
|
+
* so the rebuilt active counts reflect the adopted/released keys.
|
|
1314
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
1315
|
+
* @returns {Promise<void>} - Resolves when reconciled.
|
|
1316
|
+
*/
|
|
1317
|
+
async _reconcileQueueConcurrency(db) {
|
|
1318
|
+
if (this._queueConcurrencyReconciled) return
|
|
1319
|
+
if (!(await db.tableExists(CONCURRENCY_TABLE))) return
|
|
1320
|
+
|
|
1321
|
+
const queuesConfig = this.configuration.getBackgroundJobsConfig().queues || {}
|
|
1322
|
+
const jobsTable = db.quoteTable(JOBS_TABLE)
|
|
1323
|
+
const keyColumn = db.quoteColumn("concurrency_key")
|
|
1324
|
+
const capColumn = db.quoteColumn("max_concurrency")
|
|
1325
|
+
const queueColumn = db.quoteColumn("queue")
|
|
1326
|
+
const nonTerminal = `${db.quoteColumn("status")} IN (${db.quote("queued")}, ${db.quote("handed_off")})`
|
|
1327
|
+
/** @type {Set<string>} */
|
|
1328
|
+
const cappedQueues = new Set()
|
|
1329
|
+
|
|
1330
|
+
for (const queue of Object.keys(queuesConfig)) {
|
|
1331
|
+
const cap = this._queueMaxConcurrency(queue)
|
|
1332
|
+
|
|
1333
|
+
if (cap === null) continue
|
|
1334
|
+
|
|
1335
|
+
cappedQueues.add(queue)
|
|
1336
|
+
const concurrencyKey = `${QUEUE_CONCURRENCY_KEY_PREFIX}${queue}`
|
|
1337
|
+
|
|
1338
|
+
await this._ensureQueueConcurrencyKey(db, {concurrencyKey, maxConcurrency: cap})
|
|
1339
|
+
await db.query(
|
|
1340
|
+
`UPDATE ${jobsTable} SET ${keyColumn} = ${db.quote(concurrencyKey)}, ${capColumn} = ${Number(cap)} ` +
|
|
1341
|
+
`WHERE ${queueColumn} = ${db.quote(queue)} AND ${keyColumn} IS NULL AND ${nonTerminal}`
|
|
1342
|
+
)
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
const concurrencyRows = await db.newQuery().from(CONCURRENCY_TABLE).select("concurrency_key").results()
|
|
1346
|
+
|
|
1347
|
+
for (const row of concurrencyRows) {
|
|
1348
|
+
const concurrencyKey = String(/** @type {Record<string, ?>} */ (row).concurrency_key)
|
|
1349
|
+
|
|
1350
|
+
if (!concurrencyKey.startsWith(QUEUE_CONCURRENCY_KEY_PREFIX)) continue
|
|
1351
|
+
if (cappedQueues.has(concurrencyKey.slice(QUEUE_CONCURRENCY_KEY_PREFIX.length))) continue
|
|
1352
|
+
|
|
1353
|
+
await db.query(
|
|
1354
|
+
`UPDATE ${jobsTable} SET ${keyColumn} = NULL, ${capColumn} = NULL ` +
|
|
1355
|
+
`WHERE ${keyColumn} = ${db.quote(concurrencyKey)} AND ${nonTerminal}`
|
|
1356
|
+
)
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
this._queueConcurrencyReconciled = true
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1193
1362
|
/**
|
|
1194
1363
|
* Runs normalize number.
|
|
1195
1364
|
* @param {?} value - Input value.
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* @property {string} [queue] - Queue name. Defaults to `"default"`. When the queue has a configured cap in `backgroundJobs.queues`, that cap is enforced cluster-wide.
|
|
17
17
|
* @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap. Overrides any queue-derived cap.
|
|
18
18
|
* @property {number} [maxConcurrency] - Positive integer cap; must be paired with `concurrencyKey`.
|
|
19
|
+
* @property {boolean} [deduplicateWhileQueued] - When true (requires `concurrencyKey`), skip the enqueue if a still-queued job with the same `concurrencyKey` already exists, returning that job's id. Keeps an interval-scheduled recurring job (e.g. retention pruning) from piling up redundant queued rows when it runs slower than its interval or no worker is free.
|
|
19
20
|
*/
|
|
20
21
|
/**
|
|
21
22
|
* @typedef {object} BackgroundJobPayload
|
|
@@ -65,9 +66,10 @@
|
|
|
65
66
|
* @typedef {"worker" | "client" | "reporter"} BackgroundJobSocketRole
|
|
66
67
|
*/
|
|
67
68
|
/**
|
|
68
|
-
* @typedef {{type: "hello", role: BackgroundJobSocketRole, supportsHandoffIdReporting?: boolean, workerId?: string}} BackgroundJobHelloMessage
|
|
69
|
+
* @typedef {{type: "hello", role: BackgroundJobSocketRole, supportsHandoffIdReporting?: boolean, supportsHeartbeat?: boolean, workerId?: string}} BackgroundJobHelloMessage
|
|
69
70
|
* @typedef {{type: "ready", acceptsForked?: boolean, acceptsInline?: boolean, acceptsSpawned?: boolean}} BackgroundJobReadyMessage
|
|
70
71
|
* @typedef {{type: "draining"}} BackgroundJobDrainingMessage
|
|
72
|
+
* @typedef {{type: "heartbeat", workerId?: string}} BackgroundJobHeartbeatMessage
|
|
71
73
|
* @typedef {{type: "enqueue", jobName: string, args?: Array<?>, options?: BackgroundJobOptions}} BackgroundJobEnqueueMessage
|
|
72
74
|
* @typedef {{type: "enqueued", jobId: string}} BackgroundJobEnqueuedMessage
|
|
73
75
|
* @typedef {{type: "enqueue-error", error?: string}} BackgroundJobEnqueueErrorMessage
|
|
@@ -78,7 +80,7 @@
|
|
|
78
80
|
* @typedef {{type: "job-update-error", jobId: string, error?: string}} BackgroundJobUpdateErrorMessage
|
|
79
81
|
*/
|
|
80
82
|
/**
|
|
81
|
-
* @typedef {BackgroundJobHelloMessage | BackgroundJobReadyMessage | BackgroundJobDrainingMessage | BackgroundJobEnqueueMessage | BackgroundJobEnqueuedMessage | BackgroundJobEnqueueErrorMessage | BackgroundJobJobMessage | BackgroundJobCompleteMessage | BackgroundJobFailedMessage | BackgroundJobUpdatedMessage | BackgroundJobUpdateErrorMessage} BackgroundJobSocketMessage
|
|
83
|
+
* @typedef {BackgroundJobHelloMessage | BackgroundJobReadyMessage | BackgroundJobDrainingMessage | BackgroundJobHeartbeatMessage | BackgroundJobEnqueueMessage | BackgroundJobEnqueuedMessage | BackgroundJobEnqueueErrorMessage | BackgroundJobJobMessage | BackgroundJobCompleteMessage | BackgroundJobFailedMessage | BackgroundJobUpdatedMessage | BackgroundJobUpdateErrorMessage} BackgroundJobSocketMessage
|
|
82
84
|
*/
|
|
83
85
|
|
|
84
86
|
export const nothing = {}
|