velocious 1.0.524 → 1.0.526

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/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "velocious": "build/bin/velocious.js"
4
4
  },
5
5
  "name": "velocious",
6
- "version": "1.0.524",
6
+ "version": "1.0.526",
7
7
  "main": "build/index.js",
8
8
  "types": "build/index.d.ts",
9
9
  "files": [
@@ -784,6 +784,37 @@ export default class BackgroundJobsMain {
784
784
  errorEvents.emit("all-error", {...payload, errorType: "background-job-failed"})
785
785
  }
786
786
 
787
+ /**
788
+ * Emits `background-job-orphaned` (mirrored to `all-error`) for a job the time-based orphan sweep
789
+ * reclaimed after its worker died mid-run. Unlike `background-job-failed`, which fires on a
790
+ * worker's failure report, this fires from the main process's sweep, so applications can react to
791
+ * a dead worker's specific job — recover the work it left behind — without polling. `willRetry`
792
+ * reflects whether the reclaim returned the job to the queue for another attempt.
793
+ * @param {{job: import("./types.js").BackgroundJobRow}} args - The orphaned job.
794
+ * @returns {void}
795
+ */
796
+ _emitBackgroundJobOrphaned({job}) {
797
+ const normalizedError = this._normalizeFailureError(job.lastError ?? "Job orphaned after timeout")
798
+ const payload = {
799
+ context: {
800
+ attempts: job.attempts,
801
+ jobArgs: job.args,
802
+ jobId: job.id,
803
+ jobName: job.jobName,
804
+ maxRetries: job.maxRetries,
805
+ stage: "background-job-orphaned",
806
+ status: job.status,
807
+ terminal: job.status === "failed" || job.status === "orphaned",
808
+ willRetry: job.status === "queued"
809
+ },
810
+ error: normalizedError
811
+ }
812
+ const errorEvents = this.configuration.getErrorEvents()
813
+
814
+ errorEvents.emit("background-job-orphaned", payload)
815
+ errorEvents.emit("all-error", {...payload, errorType: "background-job-orphaned"})
816
+ }
817
+
787
818
  /**
788
819
  * Runs normalize failure error.
789
820
  * @param {?} error - Reported failure value.
@@ -1170,14 +1201,26 @@ export default class BackgroundJobsMain {
1170
1201
 
1171
1202
  async _sweepOrphans() {
1172
1203
  try {
1173
- const count = await this.store.markOrphanedJobs()
1204
+ const orphanedJobs = await this.store.markOrphanedJobs()
1174
1205
 
1175
- if (count > 0) {
1176
- this.logger.warn(() => ["Marked orphaned background jobs", count])
1177
- // Reclaimed orphans become `queued` again — wake the dispatcher
1178
- // so they aren't stranded until the next external signal.
1206
+ if (orphanedJobs.length > 0) {
1207
+ this.logger.warn(() => ["Marked orphaned background jobs", orphanedJobs.length])
1208
+ // Reclaimed orphans become `queued` again — wake the dispatcher first so
1209
+ // an application event handler that throws below cannot strand them
1210
+ // queued until the next external enqueue/reconnect.
1179
1211
  this._notifyEnqueued()
1180
1212
  await this._drain()
1213
+ // Emit an event per orphaned job so applications can react to a dead
1214
+ // worker's specific job (e.g. targeted recovery) instead of only polling
1215
+ // for its aftermath. Isolate each so one throwing handler can't suppress
1216
+ // the events for the rest.
1217
+ for (const job of orphanedJobs) {
1218
+ try {
1219
+ this._emitBackgroundJobOrphaned({job})
1220
+ } catch (error) {
1221
+ this.logger.error(() => ["A background-job-orphaned event handler threw:", error])
1222
+ }
1223
+ }
1181
1224
  }
1182
1225
  } catch (error) {
1183
1226
  this.logger.error(() => ["Failed to mark orphaned jobs:", error])
@@ -547,7 +547,7 @@ export default class BackgroundJobsStore {
547
547
  * Runs mark orphaned jobs.
548
548
  * @param {object} [args] - Options.
549
549
  * @param {number} [args.orphanedAfterMs] - Mark jobs orphaned after this duration.
550
- * @returns {Promise<number>} - Count of orphaned jobs.
550
+ * @returns {Promise<import("./types.js").BackgroundJobRow[]>} - The jobs this sweep marked orphaned.
551
551
  */
552
552
  async markOrphanedJobs({orphanedAfterMs = ORPHANED_AFTER_MS} = {}) {
553
553
  await this.ensureReady()
@@ -562,7 +562,8 @@ export default class BackgroundJobsStore {
562
562
 
563
563
  const rows = await query.results()
564
564
 
565
- let orphanedCount = 0
565
+ /** @type {import("./types.js").BackgroundJobRow[]} */
566
+ const orphanedJobs = []
566
567
 
567
568
  for (const row of rows) {
568
569
  const job = this._normalizeJobRow(row)
@@ -588,10 +589,10 @@ export default class BackgroundJobsStore {
588
589
  conditions: {id: job.id, status: "handed_off", handed_off_at_ms: job.handedOffAtMs}
589
590
  })
590
591
 
591
- if (orphanedJob) orphanedCount += 1
592
+ if (orphanedJob) orphanedJobs.push(orphanedJob)
592
593
  }
593
594
 
594
- return orphanedCount
595
+ return orphanedJobs
595
596
  })
596
597
  }
597
598
 
@@ -1075,10 +1076,30 @@ export default class BackgroundJobsStore {
1075
1076
  if (affectedRows !== 1) return null
1076
1077
  await this._releaseConcurrency(db, job.concurrencyKey)
1077
1078
 
1078
- const updatedJob = await this._getJobRowById(db, job.id)
1079
+ // Return a snapshot of the transition this update just applied rather than re-reading the row.
1080
+ // We won the conditional update (affectedRows === 1), so this state is authoritative; re-reading
1081
+ // could instead observe a newer state if another dispatcher reclaims a requeued job between the
1082
+ // update and the read (overlapping mains / polling dispatch), which would misreport the
1083
+ // status/terminal/willRetry of this transition to failure/orphan event listeners.
1084
+ const status = shouldRetry ? "queued" : (markOrphaned ? "orphaned" : "failed")
1085
+ /** @type {import("./types.js").BackgroundJobRow} */
1086
+ const transitionedJob = {
1087
+ ...job,
1088
+ attempts: nextAttempt,
1089
+ handedOffAtMs: null,
1090
+ lastError: failureMessage,
1091
+ status,
1092
+ workerId: null
1093
+ }
1094
+
1095
+ if (markOrphaned) transitionedJob.orphanedAtMs = now
1096
+ if (shouldRetry) {
1097
+ transitionedJob.scheduledAtMs = scheduledAt
1098
+ } else if (!markOrphaned) {
1099
+ transitionedJob.failedAtMs = now
1100
+ }
1079
1101
 
1080
- if (!updatedJob) return null
1081
- return updatedJob
1102
+ return transitionedJob
1082
1103
  }
1083
1104
 
1084
1105
  /**
@@ -11,6 +11,13 @@ import {fileURLToPath} from "node:url"
11
11
 
12
12
  /** Grace period after SIGTERM before a lingering process runner is SIGKILLed. */
13
13
  const FORKED_CHILD_SIGKILL_GRACE_MS = 5000
14
+ /**
15
+ * Largest delay Node's `setTimeout` accepts without overflowing to a 1ms delay
16
+ * (a 32-bit signed int of ms, ~24.8 days). A `jobTimeoutMs` above this — or a
17
+ * non-finite one like `Infinity` — is clamped/disabled rather than coerced to
18
+ * ~1ms, which would otherwise terminate every forked job almost immediately.
19
+ */
20
+ const MAX_FORKED_JOB_TIMEOUT_MS = 2_147_483_647
14
21
  const FORKED_RUNNER_ENTRY_PATH = fileURLToPath(new URL("./forked-runner-child.js", import.meta.url))
15
22
  /** How often the worker sends a liveness heartbeat to the main. */
16
23
  const HEARTBEAT_INTERVAL_MS = 15000
@@ -21,6 +28,15 @@ const SOCKET_KEEPALIVE_MS = 10000
21
28
  * @type {import("./types.js").BackgroundJobExecutionMode[]} */
22
29
  const EXECUTION_MODES = ["inline", "forked", "spawned"]
23
30
 
31
+ /**
32
+ * Per-forked-child timeout bookkeeping.
33
+ * @typedef {object} ForkedJobTimeoutState
34
+ * @property {boolean} timedOut - Whether the timeout fired and the child was terminated.
35
+ * @property {number | null} timeoutMs - The armed timeout in ms, or null when disabled.
36
+ * @property {ReturnType<typeof setTimeout> | null} timer - The pending timeout timer, cleared on exit.
37
+ * @property {ReturnType<typeof setTimeout> | null} sigkillTimer - The pending SIGKILL grace timer, cleared on exit.
38
+ */
39
+
24
40
  export default class BackgroundJobsWorker {
25
41
  /**
26
42
  * Runs constructor.
@@ -32,8 +48,9 @@ export default class BackgroundJobsWorker {
32
48
  * @param {number} [args.maxConcurrentInlineJobs] - Override the inline-job concurrency cap from `configuration.getBackgroundJobsConfig()`.
33
49
  * @param {number} [args.forkedChildSigkillGraceMs] - Override the grace period between SIGTERM and SIGKILL when reaping lingering process runners on stop.
34
50
  * @param {number} [args.heartbeatIntervalMs] - Override the liveness heartbeat interval (default 15000ms).
51
+ * @param {number} [args.jobTimeoutMs] - Override the forked-job wall-clock timeout from `configuration.getBackgroundJobsConfig()`. `0` disables it.
35
52
  */
36
- constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, forkedChildSigkillGraceMs, heartbeatIntervalMs} = {}) {
53
+ constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs} = {}) {
37
54
  /**
38
55
  * Narrows the runtime value to the documented type.
39
56
  * @type {Promise<import("../configuration.js").default>} */
@@ -77,6 +94,13 @@ export default class BackgroundJobsWorker {
77
94
  this.forkedChildSigkillGraceMs = typeof forkedChildSigkillGraceMs === "number" && forkedChildSigkillGraceMs >= 0
78
95
  ? forkedChildSigkillGraceMs
79
96
  : FORKED_CHILD_SIGKILL_GRACE_MS
97
+ /**
98
+ * Constructor override for the forked-job wall-clock timeout. When unset the
99
+ * timeout is read from `configuration.getBackgroundJobsConfig().jobTimeoutMs`
100
+ * at fork time (default: disabled).
101
+ * @type {number | undefined}
102
+ */
103
+ this.jobTimeoutMsOverride = typeof jobTimeoutMs === "number" ? jobTimeoutMs : undefined
80
104
  this.shouldStop = false
81
105
  this.workerId = randomUUID()
82
106
  this.heartbeatIntervalMs = typeof heartbeatIntervalMs === "number" && heartbeatIntervalMs >= 1
@@ -590,16 +614,109 @@ export default class BackgroundJobsWorker {
590
614
  * @returns {Promise<void>} - Resolves when the child exits.
591
615
  */
592
616
  _waitForForkedChild({child, payload}) {
617
+ const timeoutState = this._armForkedJobTimeout({child})
618
+
593
619
  return new Promise((resolve) => {
594
620
  child.once("exit", (code, signal) => {
595
- this._handleForkedChildExit({child, code, signal, payload, resolve})
621
+ this._clearForkedJobTimeout(timeoutState)
622
+ this._handleForkedChildExit({child, code, signal, payload, resolve, timeoutState})
596
623
  })
597
624
  child.once("error", (error) => {
625
+ this._clearForkedJobTimeout(timeoutState)
598
626
  this._handleForkedChildError({child, error, payload, resolve})
599
627
  })
600
628
  })
601
629
  }
602
630
 
631
+ /**
632
+ * Arms a wall-clock backstop for a forked job runner. A forked job still
633
+ * running after `jobTimeoutMs` is terminated (SIGTERM, then SIGKILL after the
634
+ * grace) so a single genuinely-hung runner can't pin a draining worker — and
635
+ * its full-app boot and database connections — indefinitely. Returns a state
636
+ * object the exit/error handlers use to cancel the timer and to report a
637
+ * timeout-specific failure. When no timeout is configured the timer is null
638
+ * and behavior is unchanged.
639
+ * @param {object} args - Options.
640
+ * @param {import("node:child_process").ChildProcess} args.child - Forked child process.
641
+ * @returns {ForkedJobTimeoutState} - Timeout state.
642
+ */
643
+ _armForkedJobTimeout({child}) {
644
+ const timeoutMs = this._resolveForkedJobTimeoutMs()
645
+ /** @type {ForkedJobTimeoutState} */
646
+ const state = {timedOut: false, timeoutMs, timer: null, sigkillTimer: null}
647
+
648
+ if (!(typeof timeoutMs === "number" && timeoutMs > 0)) return state
649
+
650
+ state.timer = setTimeout(() => this._onForkedJobTimeout({child, state}), timeoutMs)
651
+
652
+ return state
653
+ }
654
+
655
+ /**
656
+ * Resolves the effective forked-job timeout in ms, or null when disabled. The
657
+ * constructor override wins; otherwise the value comes from the background-jobs
658
+ * configuration. A non-positive value disables the backstop.
659
+ * @returns {number | null} - Timeout in ms, or null when disabled.
660
+ */
661
+ _resolveForkedJobTimeoutMs() {
662
+ const raw = typeof this.jobTimeoutMsOverride === "number"
663
+ ? this.jobTimeoutMsOverride
664
+ : (this.configuration ? this.configuration.getBackgroundJobsConfig().jobTimeoutMs : null)
665
+
666
+ // A non-finite (e.g. Infinity) or non-positive value disables the backstop;
667
+ // a finite value beyond Node's timer range is clamped to the max rather than
668
+ // silently coerced to ~1ms (which would kill every forked job immediately).
669
+ if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) return null
670
+
671
+ return Math.min(raw, MAX_FORKED_JOB_TIMEOUT_MS)
672
+ }
673
+
674
+ /**
675
+ * Fired when a forked runner overruns its timeout. Sends SIGTERM for a clean
676
+ * shutdown, then SIGKILL after the grace for a runner that ignores it. The
677
+ * resulting non-clean exit flows through `_handleForkedChildExit`, which frees
678
+ * the slot and reports the job failed.
679
+ * @param {object} args - Options.
680
+ * @param {import("node:child_process").ChildProcess} args.child - Forked child process.
681
+ * @param {ForkedJobTimeoutState} args.state - Timeout state.
682
+ * @returns {void}
683
+ */
684
+ _onForkedJobTimeout({child, state}) {
685
+ state.timedOut = true
686
+
687
+ try {
688
+ child.kill("SIGTERM")
689
+ } catch {
690
+ // Child already exited; nothing to do.
691
+ }
692
+
693
+ state.sigkillTimer = setTimeout(() => {
694
+ try {
695
+ child.kill("SIGKILL")
696
+ } catch {
697
+ // Child already exited; nothing to do.
698
+ }
699
+ }, this.forkedChildSigkillGraceMs)
700
+ }
701
+
702
+ /**
703
+ * Cancels any pending timeout/SIGKILL timers for a forked runner that has
704
+ * exited (or errored) so they never fire against a gone or reused child.
705
+ * @param {ForkedJobTimeoutState} state - Timeout state.
706
+ * @returns {void}
707
+ */
708
+ _clearForkedJobTimeout(state) {
709
+ if (state.timer) {
710
+ clearTimeout(state.timer)
711
+ state.timer = null
712
+ }
713
+
714
+ if (state.sigkillTimer) {
715
+ clearTimeout(state.sigkillTimer)
716
+ state.sigkillTimer = null
717
+ }
718
+ }
719
+
603
720
  /**
604
721
  * Runs handle forked child exit.
605
722
  * @param {object} args - Options.
@@ -608,9 +725,10 @@ export default class BackgroundJobsWorker {
608
725
  * @param {keyof typeof import("node:os").constants.signals | null} args.signal - Exit signal.
609
726
  * @param {import("./types.js").BackgroundJobPayload & {id: string}} args.payload - Payload.
610
727
  * @param {(value: void) => void} args.resolve - Promise resolver.
728
+ * @param {ForkedJobTimeoutState} [args.timeoutState] - Timeout state, when the runner had a wall-clock backstop.
611
729
  * @returns {void}
612
730
  */
613
- _handleForkedChildExit({child, code, signal, payload, resolve}) {
731
+ _handleForkedChildExit({child, code, signal, payload, resolve, timeoutState}) {
614
732
  this.inflightProcessChildren.delete(child)
615
733
 
616
734
  // Free the worker slot as soon as the child is gone — never gate it on the
@@ -620,10 +738,11 @@ export default class BackgroundJobsWorker {
620
738
 
621
739
  if (this._forkedChildExitedCleanly({code, signal})) return
622
740
 
623
- this._reportForkedChildFailure({
624
- payload,
625
- error: new Error(`Forked background job runner exited before reporting: code=${code} signal=${signal || "none"}`)
626
- })
741
+ const error = timeoutState?.timedOut
742
+ ? new Error(`Forked background job runner timed out after ${timeoutState.timeoutMs}ms and was terminated: code=${code} signal=${signal || "none"}`)
743
+ : new Error(`Forked background job runner exited before reporting: code=${code} signal=${signal || "none"}`)
744
+
745
+ this._reportForkedChildFailure({payload, error})
627
746
  }
628
747
 
629
748
  /**
@@ -192,6 +192,15 @@
192
192
  * to restore the legacy fixed-interval poll.
193
193
  * @property {number} [pollIntervalMs] - Poll interval in milliseconds. Only used
194
194
  * when `dispatchStrategy === "polling"`. Default: `1000`.
195
+ * @property {number | null} [jobTimeoutMs] - Wall-clock backstop, in ms, for a
196
+ * `"forked"` job runner. A forked job still running after this is terminated
197
+ * (SIGTERM, then SIGKILL after the reaping grace) and reported failed, so a
198
+ * single genuinely-hung runner can't pin a draining worker — and its full-app
199
+ * boot and DB connections — indefinitely (e.g. across a deploy where a retired
200
+ * worker drains in-flight jobs). This is a coarse safety net, not per-job
201
+ * tuning: set it well above the longest legitimate forked job (build runners,
202
+ * long imports) so only genuinely-stuck runners are killed. Omit, `null`, or
203
+ * `<= 0` to disable (default), which preserves the prior unbounded behavior.
195
204
  * @property {BackgroundJobsRetentionConfiguration} [retention] - Retention/pruning
196
205
  * of terminal job rows. Without pruning the jobs table grows unbounded
197
206
  * (completed rows accumulate forever), which bloats storage and indexes and
@@ -1270,10 +1270,12 @@ export default class VelociousConfiguration {
1270
1270
  const envMaxConcurrentRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_MAX_CONCURRENT_INLINE_JOBS
1271
1271
  const envDispatchStrategy = process.env.VELOCIOUS_BACKGROUND_JOBS_DISPATCH_STRATEGY
1272
1272
  const envPollIntervalRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_POLL_INTERVAL_MS
1273
+ const envJobTimeoutRaw = process.env.VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS
1273
1274
  const envPort = envPortRaw ? Number(envPortRaw) : undefined
1274
1275
  const envMaxConcurrentForked = envMaxConcurrentForkedRaw ? Number(envMaxConcurrentForkedRaw) : undefined
1275
1276
  const envMaxConcurrent = envMaxConcurrentRaw ? Number(envMaxConcurrentRaw) : undefined
1276
1277
  const envPollInterval = envPollIntervalRaw ? Number(envPollIntervalRaw) : undefined
1278
+ const envJobTimeout = envJobTimeoutRaw ? Number(envJobTimeoutRaw) : undefined
1277
1279
  const configured = this._backgroundJobs || {}
1278
1280
  const host = configured.host || envHost || "127.0.0.1"
1279
1281
  const port = typeof configured.port === "number"
@@ -1292,6 +1294,12 @@ export default class VelociousConfiguration {
1292
1294
  ? configured.pollIntervalMs
1293
1295
  : (typeof envPollInterval === "number" && Number.isFinite(envPollInterval) && envPollInterval >= 1 ? envPollInterval : 1000)
1294
1296
  const queues = configured.queues && typeof configured.queues === "object" ? configured.queues : {}
1297
+ // An explicit config value wins over the env var — including `null`/`0`,
1298
+ // which disable the backstop even when the environment sets a default.
1299
+ // Only fall through to the env var when config omits `jobTimeoutMs` entirely.
1300
+ const jobTimeoutMs = "jobTimeoutMs" in configured
1301
+ ? (typeof configured.jobTimeoutMs === "number" && configured.jobTimeoutMs > 0 ? configured.jobTimeoutMs : null)
1302
+ : (typeof envJobTimeout === "number" && Number.isFinite(envJobTimeout) && envJobTimeout > 0 ? envJobTimeout : null)
1295
1303
  const configuredRetention = configured.retention && typeof configured.retention === "object" ? configured.retention : {}
1296
1304
  const retention = {
1297
1305
  completedTtlMs: typeof configuredRetention.completedTtlMs === "number" || configuredRetention.completedTtlMs === null
@@ -1308,7 +1316,7 @@ export default class VelociousConfiguration {
1308
1316
  : 60 * 60 * 1000
1309
1317
  }
1310
1318
 
1311
- return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, dispatchStrategy, pollIntervalMs, queues, retention}
1319
+ return {host, port, databaseIdentifier, maxConcurrentForkedJobs, maxConcurrentInlineJobs, dispatchStrategy, pollIntervalMs, queues, jobTimeoutMs, retention}
1312
1320
  }
1313
1321
 
1314
1322
  /**