velocious 1.0.519 → 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.
Files changed (46) hide show
  1. package/README.md +34 -0
  2. package/build/background-jobs/json-socket.js +12 -0
  3. package/build/background-jobs/main.js +78 -23
  4. package/build/background-jobs/store.js +87 -5
  5. package/build/background-jobs/types.js +4 -2
  6. package/build/background-jobs/worker.js +113 -23
  7. package/build/configuration-types.js +11 -2
  8. package/build/configuration.js +1 -1
  9. package/build/environment-handlers/node/cli/commands/generate/base-models.js +1 -1
  10. package/build/jobs/prune-terminal-background-jobs.js +67 -0
  11. package/build/src/background-jobs/json-socket.d.ts +12 -0
  12. package/build/src/background-jobs/json-socket.d.ts.map +1 -1
  13. package/build/src/background-jobs/json-socket.js +13 -1
  14. package/build/src/background-jobs/main.d.ts +18 -9
  15. package/build/src/background-jobs/main.d.ts.map +1 -1
  16. package/build/src/background-jobs/main.js +73 -26
  17. package/build/src/background-jobs/store.d.ts +16 -0
  18. package/build/src/background-jobs/store.d.ts.map +1 -1
  19. package/build/src/background-jobs/store.js +75 -5
  20. package/build/src/background-jobs/types.d.ts +14 -3
  21. package/build/src/background-jobs/types.d.ts.map +1 -1
  22. package/build/src/background-jobs/types.js +5 -3
  23. package/build/src/background-jobs/worker.d.ts +55 -7
  24. package/build/src/background-jobs/worker.d.ts.map +1 -1
  25. package/build/src/background-jobs/worker.js +108 -22
  26. package/build/src/configuration-types.d.ts +35 -4
  27. package/build/src/configuration-types.d.ts.map +1 -1
  28. package/build/src/configuration-types.js +12 -3
  29. package/build/src/configuration.d.ts +4 -2
  30. package/build/src/configuration.d.ts.map +1 -1
  31. package/build/src/configuration.js +2 -2
  32. package/build/src/environment-handlers/node/cli/commands/generate/base-models.js +2 -2
  33. package/build/src/jobs/prune-terminal-background-jobs.d.ts +23 -0
  34. package/build/src/jobs/prune-terminal-background-jobs.d.ts.map +1 -0
  35. package/build/src/jobs/prune-terminal-background-jobs.js +61 -0
  36. package/build/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +1 -1
  38. package/src/background-jobs/json-socket.js +12 -0
  39. package/src/background-jobs/main.js +78 -23
  40. package/src/background-jobs/store.js +87 -5
  41. package/src/background-jobs/types.js +4 -2
  42. package/src/background-jobs/worker.js +113 -23
  43. package/src/configuration-types.js +11 -2
  44. package/src/configuration.js +1 -1
  45. package/src/environment-handlers/node/cli/commands/generate/base-models.js +1 -1
  46. package/src/jobs/prune-terminal-background-jobs.js +67 -0
@@ -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,8 +51,10 @@ 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
@@ -55,6 +62,10 @@ export default class BackgroundJobsMain {
55
62
  this.dispatchStrategy = config.dispatchStrategy
56
63
  this.pollIntervalMs = config.pollIntervalMs
57
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
58
69
  this.store = new BackgroundJobsStore({configuration, databaseIdentifier: config.databaseIdentifier})
59
70
  this.logger = new Logger(this)
60
71
  /**
@@ -91,8 +102,8 @@ export default class BackgroundJobsMain {
91
102
  this._orphanTimer = undefined
92
103
  /**
93
104
  * Narrows the runtime value to the documented type.
94
- * @type {ReturnType<typeof setTimeout> | undefined} */
95
- this._retentionTimer = undefined
105
+ * @type {ReturnType<typeof setInterval> | undefined} */
106
+ this._workerStaleTimer = undefined
96
107
  /**
97
108
  * Narrows the runtime value to the documented type.
98
109
  * @type {BackgroundJobsScheduler | undefined} */
@@ -144,9 +155,9 @@ export default class BackgroundJobsMain {
144
155
  void this._sweepOrphans()
145
156
  }, 60000)
146
157
 
147
- this._retentionTimer = setInterval(() => {
148
- void this._sweepRetention()
149
- }, this.retention.sweepIntervalMs)
158
+ this._workerStaleTimer = setInterval(() => {
159
+ void this._sweepStaleWorkers()
160
+ }, this.workerLivenessSweepMs)
150
161
 
151
162
  this.scheduler = new BackgroundJobsScheduler({
152
163
  configuration: this.configuration,
@@ -154,7 +165,10 @@ export default class BackgroundJobsMain {
154
165
  await this.store.enqueue({
155
166
  jobName: jobClass.jobName(),
156
167
  args,
157
- options
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)
158
172
  })
159
173
  this._notifyEnqueued()
160
174
  await this._drain()
@@ -162,6 +176,16 @@ export default class BackgroundJobsMain {
162
176
  })
163
177
  await this.scheduler.start()
164
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
+
165
189
  // Startup catch-up: drain anything that was waiting before this
166
190
  // process came up. In beacon mode this is also the safety net for
167
191
  // races between attaching the connect listener and the initial
@@ -206,12 +230,12 @@ export default class BackgroundJobsMain {
206
230
  if (this._scheduledTimer) clearTimeout(this._scheduledTimer)
207
231
  if (this._errorRetryTimer) clearTimeout(this._errorRetryTimer)
208
232
  if (this._orphanTimer) clearInterval(this._orphanTimer)
209
- if (this._retentionTimer) clearInterval(this._retentionTimer)
233
+ if (this._workerStaleTimer) clearInterval(this._workerStaleTimer)
210
234
  this._pollTimer = undefined
211
235
  this._scheduledTimer = undefined
212
236
  this._errorRetryTimer = undefined
213
237
  this._orphanTimer = undefined
214
- this._retentionTimer = undefined
238
+ this._workerStaleTimer = undefined
215
239
  }
216
240
 
217
241
  /**
@@ -394,6 +418,8 @@ export default class BackgroundJobsMain {
394
418
  if (message.role === "worker") {
395
419
  jsonSocket.workerId = message.workerId
396
420
  jsonSocket.supportsHandoffIdReporting = message.supportsHandoffIdReporting === true
421
+ jsonSocket.supportsHeartbeat = message.supportsHeartbeat === true
422
+ jsonSocket.lastSeenAt = Date.now()
397
423
  this.workers.add(jsonSocket)
398
424
  this.workerHandoffs.set(jsonSocket, new Map())
399
425
  }
@@ -422,6 +448,14 @@ export default class BackgroundJobsMain {
422
448
  * @returns {void}
423
449
  */
424
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
+
425
459
  if (message?.type === "ready") {
426
460
  this._handleWorkerReady({jsonSocket, message})
427
461
  return
@@ -1098,22 +1132,43 @@ export default class BackgroundJobsMain {
1098
1132
  }
1099
1133
 
1100
1134
  /**
1101
- * Deletes terminal job rows past their retention window so the jobs table
1102
- * does not grow unbounded. Runs on its own interval; failures are logged and
1103
- * retried on the next tick.
1104
- * @returns {Promise<void>} - Resolves after one retention sweep.
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.
1105
1142
  */
1106
- async _sweepRetention() {
1107
- try {
1108
- const deleted = await this.store.pruneTerminalJobs({
1109
- completedTtlMs: this.retention.completedTtlMs,
1110
- failedTtlMs: this.retention.failedTtlMs,
1111
- batchSize: this.retention.batchSize
1112
- })
1143
+ async _sweepStaleWorkers() {
1144
+ if (this._stopped) return
1113
1145
 
1114
- if (deleted > 0) this.logger.warn(() => ["Pruned terminal background jobs", deleted])
1115
- } catch (error) {
1116
- this.logger.error(() => ["Failed to prune terminal jobs:", error])
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)
1117
1172
  }
1118
1173
  }
1119
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 jobId
152
+ return resultJobId
134
153
  }
135
154
 
136
155
  /**
@@ -648,6 +667,7 @@ export default class BackgroundJobsStore {
648
667
  if (alreadyApplied && await db.tableExists(JOBS_TABLE)) {
649
668
  await this._ensureJobsTableColumns(db)
650
669
  await this._ensureConcurrencyTable(db)
670
+ await this._reconcileQueueConcurrency(db)
651
671
  await this._reconcileConcurrency(db)
652
672
  return
653
673
  }
@@ -655,6 +675,7 @@ export default class BackgroundJobsStore {
655
675
  await this._applyMigrations(db)
656
676
  await this._ensureJobsTableColumns(db)
657
677
  await this._ensureConcurrencyTable(db)
678
+ await this._reconcileQueueConcurrency(db)
658
679
  await this._reconcileConcurrency(db)
659
680
 
660
681
  if (alreadyApplied) return
@@ -834,16 +855,15 @@ export default class BackgroundJobsStore {
834
855
  * @returns {Promise<void>} - Resolves when ensured.
835
856
  */
836
857
  async _ensureQueueColumn(db) {
837
- const table = await db.getTableByNameOrFail(JOBS_TABLE)
838
-
839
- if (await table.getColumnByName("queue")) return
840
-
841
858
  const lockName = `${MIGRATION_SCOPE}:queue_column`
842
859
  const acquired = await db.acquireAdvisoryLock(lockName)
843
860
 
844
861
  if (!acquired) throw new Error("Failed to acquire background jobs queue schema lock")
845
862
 
846
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).
847
867
  db.clearSchemaCache()
848
868
  const lockedTable = await db.getTableByNameOrFail(JOBS_TABLE)
849
869
 
@@ -1101,6 +1121,9 @@ export default class BackgroundJobsStore {
1101
1121
  if (typeof key !== "string" || key.length === 0 || !Number.isInteger(cap) || Number(cap) <= 0) {
1102
1122
  throw new Error("background job concurrencyKey and maxConcurrency must be paired; concurrencyKey must be non-empty and maxConcurrency must be a positive integer")
1103
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
+ }
1104
1127
  return {concurrencyKey: key, maxConcurrency: Number(cap)}
1105
1128
  }
1106
1129
 
@@ -1277,6 +1300,65 @@ export default class BackgroundJobsStore {
1277
1300
  )
1278
1301
  }
1279
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
+
1280
1362
  /**
1281
1363
  * Runs normalize number.
1282
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 = {}
@@ -12,6 +12,10 @@ import {fileURLToPath} from "node:url"
12
12
  /** Grace period after SIGTERM before a lingering process runner is SIGKILLed. */
13
13
  const FORKED_CHILD_SIGKILL_GRACE_MS = 5000
14
14
  const FORKED_RUNNER_ENTRY_PATH = fileURLToPath(new URL("./forked-runner-child.js", import.meta.url))
15
+ /** How often the worker sends a liveness heartbeat to the main. */
16
+ const HEARTBEAT_INTERVAL_MS = 15000
17
+ /** TCP keepalive so a half-open connection to the main surfaces as a close. */
18
+ const SOCKET_KEEPALIVE_MS = 10000
15
19
  /**
16
20
  * Execution modes.
17
21
  * @type {import("./types.js").BackgroundJobExecutionMode[]} */
@@ -27,8 +31,9 @@ export default class BackgroundJobsWorker {
27
31
  * @param {number} [args.maxConcurrentForkedJobs] - Override the process runner concurrency cap from `configuration.getBackgroundJobsConfig()`.
28
32
  * @param {number} [args.maxConcurrentInlineJobs] - Override the inline-job concurrency cap from `configuration.getBackgroundJobsConfig()`.
29
33
  * @param {number} [args.forkedChildSigkillGraceMs] - Override the grace period between SIGTERM and SIGKILL when reaping lingering process runners on stop.
34
+ * @param {number} [args.heartbeatIntervalMs] - Override the liveness heartbeat interval (default 15000ms).
30
35
  */
31
- constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, forkedChildSigkillGraceMs} = {}) {
36
+ constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, forkedChildSigkillGraceMs, heartbeatIntervalMs} = {}) {
32
37
  /**
33
38
  * Narrows the runtime value to the documented type.
34
39
  * @type {Promise<import("../configuration.js").default>} */
@@ -74,6 +79,21 @@ export default class BackgroundJobsWorker {
74
79
  : FORKED_CHILD_SIGKILL_GRACE_MS
75
80
  this.shouldStop = false
76
81
  this.workerId = randomUUID()
82
+ this.heartbeatIntervalMs = typeof heartbeatIntervalMs === "number" && heartbeatIntervalMs >= 1
83
+ ? heartbeatIntervalMs
84
+ : HEARTBEAT_INTERVAL_MS
85
+ /**
86
+ * Narrows the runtime value to the documented type.
87
+ * @type {ReturnType<typeof setInterval> | undefined} */
88
+ this._heartbeatTimer = undefined
89
+ /**
90
+ * In-flight job-result reports to the main. Reporting is decoupled from the
91
+ * job/child slot (freeing the slot never waits on a report) and retried
92
+ * durably, so a transient main/DB outage cannot leak slots or lose a
93
+ * terminal report. Tracked so a graceful `stop()` can drain them.
94
+ * @type {Set<Promise<void>>}
95
+ */
96
+ this.inflightReports = new Set()
77
97
  /**
78
98
  * Narrows the runtime value to the documented type.
79
99
  * @type {JsonSocket | undefined} */
@@ -154,6 +174,7 @@ export default class BackgroundJobsWorker {
154
174
  async stop({timeoutMs} = {}) {
155
175
  if (this.shouldStop) return
156
176
  this.shouldStop = true
177
+ this._stopHeartbeat()
157
178
 
158
179
  // Announce drain so main stops dispatching but keeps the connection
159
180
  // open until we close it ourselves below.
@@ -168,6 +189,9 @@ export default class BackgroundJobsWorker {
168
189
  await this._drainInflight(this.inflightInlineJobs, timeoutMs)
169
190
  await this._drainInflight(this.inflightProcessJobs, timeoutMs)
170
191
  await this._terminateProcessChildren()
192
+ // Give in-flight result reports (now decoupled from job slots) a bounded
193
+ // chance to land before the socket closes.
194
+ await this._drainInflight(this.inflightReports, timeoutMs)
171
195
 
172
196
  if (this.jsonSocket) this.jsonSocket.close()
173
197
  if (this.configuration) {
@@ -238,6 +262,7 @@ export default class BackgroundJobsWorker {
238
262
  const host = this.host || config.host
239
263
  const port = typeof this.port === "number" ? this.port : config.port
240
264
  const socket = net.createConnection({host, port})
265
+ socket.setKeepAlive(true, SOCKET_KEEPALIVE_MS)
241
266
  const jsonSocket = new JsonSocket(socket)
242
267
  this.jsonSocket = jsonSocket
243
268
 
@@ -256,16 +281,51 @@ export default class BackgroundJobsWorker {
256
281
  })
257
282
 
258
283
  jsonSocket.on("close", () => {
284
+ this._stopHeartbeat()
259
285
  if (this.shouldStop) return
260
286
  setTimeout(() => { void this._connect() }, 1000)
261
287
  })
262
288
 
263
289
  socket.on("connect", () => {
264
- jsonSocket.send({type: "hello", role: "worker", supportsHandoffIdReporting: true, workerId: this.workerId})
290
+ jsonSocket.send({type: "hello", role: "worker", supportsHandoffIdReporting: true, supportsHeartbeat: true, workerId: this.workerId})
265
291
  this._sendReadyIfRunning()
292
+ this._startHeartbeat()
266
293
  })
267
294
  }
268
295
 
296
+ /**
297
+ * Sends periodic liveness heartbeats to the main so a wedged or silent worker
298
+ * can be detected and dropped there (its leases released) instead of freezing
299
+ * the queue until a human notices.
300
+ * @returns {void}
301
+ */
302
+ _startHeartbeat() {
303
+ this._stopHeartbeat()
304
+
305
+ this._heartbeatTimer = setInterval(() => {
306
+ if (this.shouldStop || !this.jsonSocket) return
307
+
308
+ try {
309
+ this.jsonSocket.send({type: "heartbeat", workerId: this.workerId})
310
+ } catch {
311
+ // Socket is closing/closed; the close handler drives reconnect.
312
+ }
313
+ }, this.heartbeatIntervalMs)
314
+
315
+ if (typeof this._heartbeatTimer.unref === "function") this._heartbeatTimer.unref()
316
+ }
317
+
318
+ /**
319
+ * Stops the liveness heartbeat timer.
320
+ * @returns {void}
321
+ */
322
+ _stopHeartbeat() {
323
+ if (this._heartbeatTimer) {
324
+ clearInterval(this._heartbeatTimer)
325
+ this._heartbeatTimer = undefined
326
+ }
327
+ }
328
+
269
329
  /**
270
330
  * Runs handle job.
271
331
  * @param {import("./types.js").BackgroundJobPayload} payload - Payload.
@@ -394,9 +454,12 @@ export default class BackgroundJobsWorker {
394
454
  * @returns {Promise<void>} - Resolves when complete (success or failure reported).
395
455
  */
396
456
  async _runInlineJobAndReport(payload) {
457
+ // Report in the background so freeing this inline slot never waits on the
458
+ // report. Reporting is durable (retried until it lands), so a transient
459
+ // main/DB outage neither wedges the slot nor loses the terminal result.
397
460
  try {
398
461
  await this._runJobInline(payload)
399
- await this._reportJobResult({
462
+ this._reportJobResultInBackground({
400
463
  jobId: payload.id,
401
464
  status: "completed",
402
465
  handoffId: payload.handoffId,
@@ -404,7 +467,7 @@ export default class BackgroundJobsWorker {
404
467
  workerId: payload.workerId || this.workerId
405
468
  })
406
469
  } catch (error) {
407
- await this._reportJobResult({
470
+ this._reportJobResultInBackground({
408
471
  jobId: payload.id,
409
472
  status: "failed",
410
473
  error,
@@ -522,10 +585,10 @@ export default class BackgroundJobsWorker {
522
585
  _waitForForkedChild({child, payload}) {
523
586
  return new Promise((resolve) => {
524
587
  child.once("exit", (code, signal) => {
525
- void this._handleForkedChildExit({child, code, signal, payload, resolve})
588
+ this._handleForkedChildExit({child, code, signal, payload, resolve})
526
589
  })
527
590
  child.once("error", (error) => {
528
- void this._handleForkedChildError({child, error, payload, resolve})
591
+ this._handleForkedChildError({child, error, payload, resolve})
529
592
  })
530
593
  })
531
594
  }
@@ -538,22 +601,22 @@ export default class BackgroundJobsWorker {
538
601
  * @param {keyof typeof import("node:os").constants.signals | null} args.signal - Exit signal.
539
602
  * @param {import("./types.js").BackgroundJobPayload & {id: string}} args.payload - Payload.
540
603
  * @param {(value: void) => void} args.resolve - Promise resolver.
541
- * @returns {Promise<void>} - Resolves after failure is reported.
604
+ * @returns {void}
542
605
  */
543
- async _handleForkedChildExit({child, code, signal, payload, resolve}) {
606
+ _handleForkedChildExit({child, code, signal, payload, resolve}) {
544
607
  this.inflightProcessChildren.delete(child)
545
608
 
546
- if (this._forkedChildExitedCleanly({code, signal})) {
547
- resolve(undefined)
548
- return
549
- }
609
+ // Free the worker slot as soon as the child is gone — never gate it on the
610
+ // failure report. A hung/slow report must not leak the slot; enough leaked
611
+ // slots drive `acceptsForked` to false and silently wedge the worker.
612
+ resolve(undefined)
613
+
614
+ if (this._forkedChildExitedCleanly({code, signal})) return
550
615
 
551
- await this._reportForkedChildFailure({
616
+ this._reportForkedChildFailure({
552
617
  payload,
553
618
  error: new Error(`Forked background job runner exited before reporting: code=${code} signal=${signal || "none"}`)
554
619
  })
555
-
556
- resolve(undefined)
557
620
  }
558
621
 
559
622
  /**
@@ -574,13 +637,14 @@ export default class BackgroundJobsWorker {
574
637
  * @param {Error} args.error - Child process error.
575
638
  * @param {import("./types.js").BackgroundJobPayload & {id: string}} args.payload - Payload.
576
639
  * @param {(value: void) => void} args.resolve - Promise resolver.
577
- * @returns {Promise<void>} - Resolves after failure is reported.
640
+ * @returns {void}
578
641
  */
579
- async _handleForkedChildError({child, error, payload, resolve}) {
642
+ _handleForkedChildError({child, error, payload, resolve}) {
580
643
  this.inflightProcessChildren.delete(child)
581
- console.error("Background jobs forked runner error:", error)
582
- await this._reportForkedChildFailure({payload, error})
644
+ // Free the slot first (see _handleForkedChildExit) reporting is best-effort.
583
645
  resolve(undefined)
646
+ console.error("Background jobs forked runner error:", error)
647
+ this._reportForkedChildFailure({payload, error})
584
648
  }
585
649
 
586
650
  /**
@@ -595,7 +659,7 @@ export default class BackgroundJobsWorker {
595
659
  child.send({type: "job", payload})
596
660
  } catch (error) {
597
661
  child.kill("SIGTERM")
598
- void this._reportForkedChildFailure({payload, error})
662
+ this._reportForkedChildFailure({payload, error})
599
663
  }
600
664
  }
601
665
 
@@ -604,10 +668,10 @@ export default class BackgroundJobsWorker {
604
668
  * @param {object} args - Options.
605
669
  * @param {import("./types.js").BackgroundJobPayload & {id: string}} args.payload - Payload.
606
670
  * @param {?} args.error - Error.
607
- * @returns {Promise<void>} - Resolves after failure is reported.
671
+ * @returns {void}
608
672
  */
609
- async _reportForkedChildFailure({payload, error}) {
610
- await this._reportJobResult({
673
+ _reportForkedChildFailure({payload, error}) {
674
+ this._reportJobResultInBackground({
611
675
  jobId: payload.id,
612
676
  status: "failed",
613
677
  error,
@@ -682,4 +746,30 @@ export default class BackgroundJobsWorker {
682
746
  console.error("Background job status reporting failed:", reportError)
683
747
  }
684
748
  }
749
+
750
+ /**
751
+ * Fires a durable job-result report without blocking the caller (so freeing a
752
+ * job/child slot never waits on the report). The report is tracked so a
753
+ * graceful `stop()` can drain in-flight reports before closing the socket.
754
+ * @param {object} args - Options.
755
+ * @param {string} args.jobId - Job id.
756
+ * @param {"completed" | "failed"} args.status - Status.
757
+ * @param {?} [args.error] - Error.
758
+ * @param {string} [args.handoffId] - Handoff lease id.
759
+ * @param {number} [args.handedOffAtMs] - Handed off timestamp.
760
+ * @param {string} [args.workerId] - Worker id.
761
+ * @returns {void}
762
+ */
763
+ _reportJobResultInBackground({jobId, status, error, handoffId, handedOffAtMs, workerId}) {
764
+ /**
765
+ * Defines report.
766
+ * @type {Promise<void>} */
767
+ let report
768
+
769
+ report = this._reportJobResult({jobId, status, error, handoffId, handedOffAtMs, workerId}).finally(() => {
770
+ this.inflightReports.delete(report)
771
+ })
772
+
773
+ this.inflightReports.add(report)
774
+ }
685
775
  }
@@ -194,8 +194,17 @@
194
194
  * jobs older than this many ms. `null` or `<= 0` disables (keeps them for
195
195
  * debugging). Default: `2592000000` (30 days).
196
196
  * @property {number} [batchSize] - Rows deleted per batch. Default: `1000`.
197
- * @property {number} [sweepIntervalMs] - How often the main process runs the
198
- * retention sweep. Default: `3600000` (1 hour).
197
+ * @property {number} [sweepIntervalMs] - How often the retention sweep runs.
198
+ * Default: `3600000` (1 hour).
199
+ */
200
+ /**
201
+ * Fully-resolved retention config as returned by `getBackgroundJobsConfig()`
202
+ * (every field defaulted), as opposed to the partial user-provided input.
203
+ * @typedef {object} ResolvedBackgroundJobsRetentionConfiguration
204
+ * @property {number | null} completedTtlMs - Resolved completed-job TTL in ms (`null` disables).
205
+ * @property {number | null} failedTtlMs - Resolved failed/orphaned TTL in ms (`null` disables).
206
+ * @property {number} batchSize - Resolved delete batch size.
207
+ * @property {number} sweepIntervalMs - Resolved sweep interval in ms.
199
208
  */
200
209
 
201
210
  /**
@@ -1260,7 +1260,7 @@ export default class VelociousConfiguration {
1260
1260
 
1261
1261
  /**
1262
1262
  * Runs get background jobs config.
1263
- * @returns {Required<import("./configuration-types.js").BackgroundJobsConfiguration>} - Background jobs configuration.
1263
+ * @returns {Required<import("./configuration-types.js").BackgroundJobsConfiguration> & {retention: import("./configuration-types.js").ResolvedBackgroundJobsRetentionConfiguration}} - Background jobs configuration.
1264
1264
  */
1265
1265
  getBackgroundJobsConfig() {
1266
1266
  const envHost = process.env.VELOCIOUS_BACKGROUND_JOBS_HOST
@@ -24,7 +24,7 @@ const jsDocTypeByColumnType = {
24
24
  float: "number",
25
25
  int: "number",
26
26
  integer: "number",
27
- json: "Record<string, ?>",
27
+ json: "Record<string, unknown>",
28
28
  longtext: "string",
29
29
  mediumtext: "string",
30
30
  numeric: "number",