velocious 1.0.628 → 1.0.629

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2626,7 +2626,7 @@ Each job must define exactly one of `every` or `cron`. Cron times are evaluated
2626
2626
 
2627
2627
  ## Persistence and retries
2628
2628
 
2629
- Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. If a worker socket disconnects unexpectedly, only the leases handed to that exact socket are immediately returned to the queue; late reports are fenced by lease id so they cannot mutate a newer attempt. This recovery is at-least-once and may repeat application side effects if the disconnected attempt had already started them. Gracefully draining workers keep their leases while they finish. When the **main** itself restarts (every deploy), a reconnecting worker's still-active handoffs are adopted into its new socket on `hello` so a later disconnect releases them instead of leaving them stuck until the orphan sweep; the main never time-reclaims a disconnected worker's jobs, so a gracefully-draining old-release worker is not double-run. For rolling upgrades, upgrade workers before the main process: a lease-aware main keeps legacy workers connected for old reports but dispatches new jobs only to workers advertising lease-reporting support. See [docs/background-jobs.md](docs/background-jobs.md#worker-disconnect-recovery).
2629
+ Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. If a worker socket disconnects unexpectedly, only the leases handed to that exact socket are immediately returned to the queue; late reports are fenced by lease id so they cannot mutate a newer attempt. This recovery is at-least-once and may repeat application side effects if the disconnected attempt had already started them. Gracefully draining workers keep their leases while they finish. When the **main** itself restarts (every deploy), it snapshots exact pre-start leases and gives surviving workers 30 seconds to reconnect and adopt their active handoffs. At that deadline it waits up to one additional reconnect-grace interval for adoption queries that were already in flight, preventing a normal slow query from racing reclaim without letting a stuck query defer cleanup forever. A worker is excluded from startup reclaim only after its adoption query succeeds while that same socket is still connected; query failures and disconnects during adoption remain reclaimable. The main then orphans only unchanged startup snapshots belonging to worker ids that did not successfully adopt, using the normal retry/count/concurrency/event lifecycle and immediately waking queued work. A successfully adopted worker, a lease created after startup, and any newer re-handoff are fenced out. `BackgroundJobsMain` accepts `workerReconnectGraceMs` for tests and operational tuning; it must be an integer from 0 through 2,147,483,647 ms and invalid values throw. This startup cleanup does not lower the separate two-hour age sweep. For rolling upgrades, upgrade workers before the main process: a lease-aware main keeps legacy workers connected for old reports but dispatches new jobs only to workers advertising lease-reporting support. See [docs/background-jobs.md](docs/background-jobs.md#worker-disconnect-recovery).
2630
2630
 
2631
2631
  Failed jobs are re-queued with backoff and retried up to 10 times by default (10s, 1m, 10m, 1h, then +1h per retry). You can override the retry limit per job:
2632
2632
 
@@ -118,6 +118,22 @@ export default class BackgroundJobsAdapter {
118
118
  */
119
119
  async handedOffJobsForWorker(_args) { throw new Error("BackgroundJobsAdapter#handedOffJobsForWorker is not implemented") }
120
120
 
121
+ /**
122
+ * Snapshots exact active handoffs before a new main generation accepts worker
123
+ * reconnects. Adapters that do not persist worker leases may return none.
124
+ * @returns {Promise<import("./types.js").BackgroundJobHandoffSnapshot[]>} - Exact active handoffs.
125
+ */
126
+ async snapshotHandedOffJobs() { return [] }
127
+
128
+ /**
129
+ * Applies orphan failure semantics to unchanged exact handoff snapshots.
130
+ * Adapters that return startup snapshots must implement the matching fenced
131
+ * transition.
132
+ * @param {{handoffs: import("./types.js").BackgroundJobHandoffSnapshot[], error: ReturnType<typeof JSON.parse>}} _args - Exact leases and orphan reason.
133
+ * @returns {Promise<import("./types.js").BackgroundJobRow[]>} - Accepted transitions.
134
+ */
135
+ async markOrphanedHandoffs(_args) { return [] }
136
+
121
137
  /**
122
138
  * Marks a handed-off job failed or retryable.
123
139
  * @param {{jobId: string, error: ReturnType<typeof JSON.parse>, handoffId?: string, workerId?: string, handedOffAtMs?: number}} _args - Failure report.
@@ -34,6 +34,24 @@ const MAX_TIMER_MS = 2_147_483_647 // ~24.8 days
34
34
  const WORKER_STALE_TIMEOUT_MS = 60000
35
35
  /** How often the main scans workers for staleness. */
36
36
  const WORKER_LIVENESS_SWEEP_MS = 15000
37
+ /** Grace for workers from the previous main generation to reconnect and adopt leases. */
38
+ const WORKER_RECONNECT_GRACE_MS = 30000
39
+ const WORKER_RECONNECT_GRACE_VALIDATION_MESSAGE = `workerReconnectGraceMs must be an integer between 0 and ${MAX_TIMER_MS}`
40
+
41
+ /**
42
+ * Resolves a startup reconnect grace without allowing Node's timer overflow to
43
+ * turn an intentionally long grace into an immediate reclaim.
44
+ * @param {number | undefined} workerReconnectGraceMs - Requested reconnect grace.
45
+ * @returns {number} - Valid timer delay.
46
+ */
47
+ function normalizeWorkerReconnectGraceMs(workerReconnectGraceMs) {
48
+ if (workerReconnectGraceMs === undefined) return WORKER_RECONNECT_GRACE_MS
49
+ if (!Number.isInteger(workerReconnectGraceMs) || workerReconnectGraceMs < 0 || workerReconnectGraceMs > MAX_TIMER_MS) {
50
+ throw new TypeError(WORKER_RECONNECT_GRACE_VALIDATION_MESSAGE)
51
+ }
52
+
53
+ return workerReconnectGraceMs
54
+ }
37
55
  /**
38
56
  * Worker execution mode capabilities.
39
57
  * @type {WorkerExecutionModeCapability[]} */
@@ -61,10 +79,11 @@ export default class BackgroundJobsMain {
61
79
  * @param {number} [args.port] - Port.
62
80
  * @param {number} [args.workerStaleTimeoutMs] - Override how long a silent worker may go before being dropped (default 60000ms).
63
81
  * @param {number} [args.workerLivenessSweepMs] - Override how often stale workers are swept for (default 15000ms).
82
+ * @param {number} [args.workerReconnectGraceMs] - Integer from 0 through 2,147,483,647 overriding how long previous-generation workers may reconnect before exact startup leases are reclaimed (default 30000ms).
64
83
  * @param {boolean} [args.closeDatabaseConnectionsOnStop] - Whether stop owns closing the configuration's database pools (default true).
65
84
  * @param {() => void | Promise<void>} [args.onStopped] - Lifecycle hook invoked after the main process finishes stopping.
66
85
  */
67
- constructor({configuration, host, port, workerStaleTimeoutMs, workerLivenessSweepMs, closeDatabaseConnectionsOnStop = true, onStopped}) {
86
+ constructor({configuration, host, port, workerStaleTimeoutMs, workerLivenessSweepMs, workerReconnectGraceMs, closeDatabaseConnectionsOnStop = true, onStopped}) {
68
87
  this.configuration = configuration
69
88
  this.closeDatabaseConnectionsOnStop = closeDatabaseConnectionsOnStop
70
89
  this.onStopped = onStopped
@@ -78,6 +97,7 @@ export default class BackgroundJobsMain {
78
97
  // long is treated as wedged/dead: its leases are released and it is dropped.
79
98
  this.workerStaleTimeoutMs = typeof workerStaleTimeoutMs === "number" && workerStaleTimeoutMs >= 1 ? workerStaleTimeoutMs : WORKER_STALE_TIMEOUT_MS
80
99
  this.workerLivenessSweepMs = typeof workerLivenessSweepMs === "number" && workerLivenessSweepMs >= 1 ? workerLivenessSweepMs : WORKER_LIVENESS_SWEEP_MS
100
+ this.workerReconnectGraceMs = normalizeWorkerReconnectGraceMs(workerReconnectGraceMs)
81
101
  /** @type {import("./adapter.js").default | undefined} */
82
102
  this.adapter = undefined
83
103
  this.logger = new Logger(this)
@@ -104,6 +124,17 @@ export default class BackgroundJobsMain {
104
124
  * wait for these before closing the configuration's database pools.
105
125
  * @type {Set<Promise<void>>} */
106
126
  this.inflightWorkerHandoffAdoptions = new Set()
127
+ /**
128
+ * Worker ids whose handoffs were successfully adopted by a still-live
129
+ * connection in this main generation.
130
+ * @type {Set<string>}
131
+ */
132
+ this.reconnectedWorkerIds = new Set()
133
+ /** @type {import("./types.js").BackgroundJobHandoffSnapshot[]} */
134
+ this.startupHandoffSnapshot = []
135
+ /** @type {Promise<void>[]} */
136
+ this._startupHandoffAdoptionsAtDeadline = []
137
+ this._startupHandoffGraceElapsed = false
107
138
  /**
108
139
  * Narrows the runtime value to the documented type.
109
140
  * @type {net.Server | undefined} */
@@ -128,6 +159,10 @@ export default class BackgroundJobsMain {
128
159
  * Narrows the runtime value to the documented type.
129
160
  * @type {ReturnType<typeof setInterval> | undefined} */
130
161
  this._workerStaleTimer = undefined
162
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
163
+ this._startupHandoffReclaimTimer = undefined
164
+ /** @type {Promise<void> | undefined} */
165
+ this._startupHandoffReclaimPromise = undefined
131
166
  /**
132
167
  * Narrows the runtime value to the documented type.
133
168
  * @type {BackgroundJobsScheduler | undefined} */
@@ -178,6 +213,11 @@ export default class BackgroundJobsMain {
178
213
  async start() {
179
214
  this._stopped = false
180
215
  this.stopPromise = undefined
216
+ this.reconnectedWorkerIds.clear()
217
+ this.startupHandoffSnapshot = []
218
+ this._startupHandoffAdoptionsAtDeadline = []
219
+ this._startupHandoffGraceElapsed = false
220
+ this._startupHandoffReclaimPromise = undefined
181
221
  this.configuration.setCurrent()
182
222
 
183
223
  try {
@@ -194,6 +234,7 @@ export default class BackgroundJobsMain {
194
234
  // across processes with a database advisory lock, so concurrently started
195
235
  // mains cannot interleave them.
196
236
  await this.store.reconcileQueueConcurrency()
237
+ this.startupHandoffSnapshot = await this.store.snapshotHandedOffJobs()
197
238
  const server = net.createServer((socket) => this._handleConnection(socket))
198
239
  this.server = server
199
240
 
@@ -208,6 +249,7 @@ export default class BackgroundJobsMain {
208
249
  }
209
250
 
210
251
  this._setupDispatchTriggers()
252
+ this._setupStartupHandoffReclaim()
211
253
 
212
254
  this._orphanTimer = setInterval(() => {
213
255
  void this._sweepOrphans()
@@ -297,7 +339,11 @@ export default class BackgroundJobsMain {
297
339
  try {
298
340
  await this._drainWorkerHandoffAdoptions()
299
341
  } finally {
300
- await this._stopBeaconAndServer()
342
+ try {
343
+ await this._drainStartupHandoffReclaim()
344
+ } finally {
345
+ await this._stopBeaconAndServer()
346
+ }
301
347
  }
302
348
  }
303
349
  }
@@ -325,11 +371,13 @@ export default class BackgroundJobsMain {
325
371
  if (this._errorRetryTimer) clearTimeout(this._errorRetryTimer)
326
372
  if (this._orphanTimer) clearInterval(this._orphanTimer)
327
373
  if (this._workerStaleTimer) clearInterval(this._workerStaleTimer)
374
+ if (this._startupHandoffReclaimTimer) clearTimeout(this._startupHandoffReclaimTimer)
328
375
  this._pollTimer = undefined
329
376
  this._scheduledTimer = undefined
330
377
  this._errorRetryTimer = undefined
331
378
  this._orphanTimer = undefined
332
379
  this._workerStaleTimer = undefined
380
+ this._startupHandoffReclaimTimer = undefined
333
381
  }
334
382
 
335
383
  /**
@@ -431,6 +479,123 @@ export default class BackgroundJobsMain {
431
479
  beaconClient.on("connect", this._beaconConnectHandler)
432
480
  }
433
481
 
482
+ /**
483
+ * Arms the bounded adoption grace only when startup found exact persisted
484
+ * handoffs. The timer is unrefed so an otherwise-finished process is never
485
+ * retained solely to perform this cleanup.
486
+ * @returns {void}
487
+ */
488
+ _setupStartupHandoffReclaim() {
489
+ if (this.startupHandoffSnapshot.length === 0) return
490
+
491
+ this._startupHandoffReclaimTimer = setTimeout(() => {
492
+ this._startupHandoffReclaimTimer = undefined
493
+ this._startupHandoffAdoptionsAtDeadline = [...this.inflightWorkerHandoffAdoptions]
494
+ this._startupHandoffGraceElapsed = true
495
+ void this._startStartupHandoffReclaim()
496
+ }, this.workerReconnectGraceMs)
497
+ this._startupHandoffReclaimTimer.unref()
498
+ }
499
+
500
+ /**
501
+ * Starts one tracked startup-reclaim pass, coalescing lifecycle and retry
502
+ * callers so shutdown can wait for durable mutation before closing pools.
503
+ * @returns {Promise<void>} - Resolves after this pass settles.
504
+ */
505
+ _startStartupHandoffReclaim() {
506
+ if (this._startupHandoffReclaimPromise) return this._startupHandoffReclaimPromise
507
+
508
+ const reclaim = this._reclaimDisconnectedStartupHandoffs()
509
+
510
+ this._startupHandoffReclaimPromise = reclaim
511
+ const clearReclaim = () => {
512
+ if (this._startupHandoffReclaimPromise === reclaim) {
513
+ this._startupHandoffReclaimPromise = undefined
514
+ }
515
+ }
516
+ void reclaim.then(clearReclaim, clearReclaim)
517
+
518
+ return reclaim
519
+ }
520
+
521
+ /**
522
+ * Waits for an already-started startup reclaim before adapter shutdown.
523
+ * @returns {Promise<void>} - Resolves when no pass remains.
524
+ */
525
+ async _drainStartupHandoffReclaim() {
526
+ while (this._startupHandoffReclaimPromise) {
527
+ await this._startupHandoffReclaimPromise
528
+ }
529
+ }
530
+
531
+ /**
532
+ * Orphans only startup-snapshotted leases whose stable worker id has not been
533
+ * observed by this main generation. Store fencing rejects completed,
534
+ * returned, replaced, and re-handed-off rows.
535
+ * @returns {Promise<void>} - Resolves after reclaim or retained retry state.
536
+ */
537
+ async _reclaimDisconnectedStartupHandoffs() {
538
+ if (this._stopped || !this._startupHandoffGraceElapsed) return
539
+ if (this.startupHandoffSnapshot.length === 0) return
540
+
541
+ await this._waitForStartupHandoffAdoptionsAtDeadline()
542
+ if (this._stopped) return
543
+
544
+ const handoffs = this.startupHandoffSnapshot.filter(({workerId}) => !this.reconnectedWorkerIds.has(workerId))
545
+
546
+ if (handoffs.length === 0) {
547
+ this.startupHandoffSnapshot = []
548
+ return
549
+ }
550
+
551
+ let orphanedJobs
552
+
553
+ try {
554
+ orphanedJobs = await this.store.markOrphanedHandoffs({
555
+ error: "Job orphaned after its pre-restart worker did not reconnect",
556
+ handoffs
557
+ })
558
+ } catch (error) {
559
+ this._reportStartupHandoffReclaimError(error)
560
+ this._scheduleErrorRetry()
561
+ return
562
+ }
563
+
564
+ this.startupHandoffSnapshot = []
565
+ await this._handleOrphanedJobs({
566
+ jobs: orphanedJobs,
567
+ warning: "Reclaimed background jobs from workers absent after main restart grace"
568
+ })
569
+ }
570
+
571
+ /**
572
+ * Lets adoption queries already running at the reconnect deadline settle
573
+ * before worker ids are filtered. A second bounded grace prevents a stuck
574
+ * adapter query from deferring startup reclaim forever.
575
+ * @returns {Promise<void>} - Resolves when the deadline set settles or times out.
576
+ */
577
+ async _waitForStartupHandoffAdoptionsAtDeadline() {
578
+ const adoptions = this._startupHandoffAdoptionsAtDeadline
579
+
580
+ this._startupHandoffAdoptionsAtDeadline = []
581
+ if (adoptions.length === 0) return
582
+
583
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
584
+ let timer
585
+ const waitLimit = new Promise((resolve) => {
586
+ // This lifecycle deadline must not keep the main process alive; the
587
+ // generic timeout helper intentionally uses a referenced timer.
588
+ timer = setTimeout(resolve, this.workerReconnectGraceMs)
589
+ timer.unref()
590
+ })
591
+
592
+ try {
593
+ await Promise.race([Promise.all(adoptions), waitLimit])
594
+ } finally {
595
+ if (timer) clearTimeout(timer)
596
+ }
597
+ }
598
+
434
599
  /**
435
600
  * Publishes a dispatch wake-up on the Beacon channel. No-op in polling
436
601
  * mode or when Beacon is not connected; in those cases the direct
@@ -583,6 +748,7 @@ export default class BackgroundJobsMain {
583
748
  for (const {jobId, handoffId} of handoffs) {
584
749
  map.set(jobId, handoffId)
585
750
  }
751
+ this.reconnectedWorkerIds.add(workerId)
586
752
  } catch (error) {
587
753
  this._reportHandoffAdoptError(error)
588
754
  }
@@ -820,6 +986,22 @@ export default class BackgroundJobsMain {
820
986
  errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
821
987
  }
822
988
 
989
+ /**
990
+ * Reports an unexpected startup-snapshot reclaim failure while retaining the
991
+ * snapshot for the dispatcher's existing transient-error retry lifecycle.
992
+ * @param {ReturnType<typeof JSON.parse>} error - Reclaim failure.
993
+ * @returns {void}
994
+ */
995
+ _reportStartupHandoffReclaimError(error) {
996
+ const normalizedError = error instanceof Error ? error : new Error(String(error))
997
+ const payload = {context: {stage: "background-job-startup-handoff-reclaim"}, error: normalizedError}
998
+ const errorEvents = this.configuration.getErrorEvents()
999
+
1000
+ this.logger.error(() => ["Failed to reclaim disconnected startup handoffs:", normalizedError])
1001
+ errorEvents.emit("framework-error", payload)
1002
+ errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
1003
+ }
1004
+
823
1005
  /**
824
1006
  * Runs handle enqueue.
825
1007
  * @param {object} args - Options.
@@ -1237,6 +1419,7 @@ export default class BackgroundJobsMain {
1237
1419
  * @returns {void} */
1238
1420
  _clearErrorRetryTimer() {
1239
1421
  if (this.pendingHandoffRecoveries.size > 0) return
1422
+ if (this._startupHandoffGraceElapsed && this.startupHandoffSnapshot.length > 0) return
1240
1423
 
1241
1424
  for (const worker of this.workerHandoffs.keys()) {
1242
1425
  if (!this.workers.has(worker)) return
@@ -1311,6 +1494,11 @@ export default class BackgroundJobsMain {
1311
1494
  async _retryAfterError() {
1312
1495
  if (this._stopped) return
1313
1496
 
1497
+ if (this._startupHandoffGraceElapsed && this.startupHandoffSnapshot.length > 0) {
1498
+ await this._startStartupHandoffReclaim()
1499
+ if (this.startupHandoffSnapshot.length > 0) return
1500
+ }
1501
+
1314
1502
  try {
1315
1503
  await this._retryPendingHandoffRecoveries()
1316
1504
  } catch {
@@ -1650,31 +1838,40 @@ export default class BackgroundJobsMain {
1650
1838
  try {
1651
1839
  const orphanedJobs = await this.store.markOrphanedJobs()
1652
1840
 
1653
- if (orphanedJobs.length > 0) {
1654
- this.logger.warn(() => ["Marked orphaned background jobs", orphanedJobs.length])
1655
- // Reclaimed orphans become `queued` again — wake the dispatcher first so
1656
- // an application event handler that throws below cannot strand them
1657
- // queued until the next external enqueue/reconnect.
1658
- this._notifyEnqueued()
1659
- // Emit an event per orphaned job so applications can react to a dead
1660
- // worker's specific job (e.g. targeted recovery) instead of only polling
1661
- // for its aftermath. Emit before awaiting the drain so a blocked
1662
- // dispatcher cannot delay application recovery. Isolate each so one
1663
- // throwing handler can't suppress the events for the rest.
1664
- for (const job of orphanedJobs) {
1665
- try {
1666
- this._emitBackgroundJobOrphaned({job})
1667
- } catch (error) {
1668
- this.logger.error(() => ["A background-job-orphaned event handler threw:", error])
1669
- }
1670
- }
1671
- await this._drain()
1672
- }
1841
+ await this._handleOrphanedJobs({jobs: orphanedJobs, warning: "Marked orphaned background jobs"})
1673
1842
  } catch (error) {
1674
1843
  this.logger.error(() => ["Failed to mark orphaned jobs:", error])
1675
1844
  }
1676
1845
  }
1677
1846
 
1847
+ /**
1848
+ * Publishes the common post-orphan lifecycle: wake queued retries, emit one
1849
+ * isolated event per accepted transition, and drain so released concurrency
1850
+ * can immediately admit other work.
1851
+ * @param {object} args - Options.
1852
+ * @param {import("./types.js").BackgroundJobRow[]} args.jobs - Accepted orphan transitions.
1853
+ * @param {string} args.warning - Lifecycle log message.
1854
+ * @returns {Promise<void>} - Resolves after the resulting drain.
1855
+ */
1856
+ async _handleOrphanedJobs({jobs, warning}) {
1857
+ if (jobs.length === 0) return
1858
+
1859
+ this.logger.warn(() => [warning, jobs.length])
1860
+ // Reclaimed orphans can become `queued` again — wake the dispatcher first
1861
+ // so an application event handler that throws below cannot strand them.
1862
+ this._notifyEnqueued()
1863
+ // Emit before awaiting the drain so a blocked dispatcher cannot delay
1864
+ // application recovery. Isolate handlers so one cannot suppress the rest.
1865
+ for (const job of jobs) {
1866
+ try {
1867
+ this._emitBackgroundJobOrphaned({job})
1868
+ } catch (error) {
1869
+ this.logger.error(() => ["A background-job-orphaned event handler threw:", error])
1870
+ }
1871
+ }
1872
+ await this._drain()
1873
+ }
1874
+
1678
1875
  /**
1679
1876
  * Drops workers that have gone silent past `workerStaleTimeoutMs` (no
1680
1877
  * heartbeat, ready, or report). A wedged worker keeps its socket open, so the
@@ -43,6 +43,13 @@ import {
43
43
  * @property {number | null} timeoutMs - Per-job timeout override, or null when omitted.
44
44
  */
45
45
 
46
+ /**
47
+ * BackgroundJobOrphanSelection type.
48
+ * @typedef {object} BackgroundJobOrphanSelection
49
+ * @property {Record<string, ReturnType<typeof JSON.parse>>} conditions - Exact update fence.
50
+ * @property {import("./types.js").BackgroundJobRow} job - Selected active handoff.
51
+ */
52
+
46
53
  /**
47
54
  * BackgroundJobTransactionSerializationOptions type.
48
55
  * @typedef {object} BackgroundJobTransactionSerializationOptions
@@ -1096,6 +1103,81 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1096
1103
  return handoffs
1097
1104
  }
1098
1105
 
1106
+ /**
1107
+ * Snapshots exact, lease-aware active handoffs before a new main generation
1108
+ * starts accepting worker reconnects. Legacy rows without a complete worker,
1109
+ * lease, and timestamp identity stay owned by the age-based orphan sweep.
1110
+ * @returns {Promise<import("./types.js").BackgroundJobHandoffSnapshot[]>} - Exact startup handoffs.
1111
+ */
1112
+ async snapshotHandedOffJobs() {
1113
+ await this.ensureReady()
1114
+
1115
+ const rows = await this._withDb(async (db) => await db
1116
+ .newQuery()
1117
+ .from(JOBS_TABLE)
1118
+ .where({status: "handed_off"})
1119
+ .order("created_at_ms ASC")
1120
+ .order("id ASC")
1121
+ .results())
1122
+ /** @type {import("./types.js").BackgroundJobHandoffSnapshot[]} */
1123
+ const handoffs = []
1124
+
1125
+ for (const row of rows) {
1126
+ const job = this._normalizeJobRow(row)
1127
+
1128
+ if (!job.handoffId || !job.workerId || typeof job.handedOffAtMs !== "number") continue
1129
+
1130
+ handoffs.push({
1131
+ handedOffAtMs: job.handedOffAtMs,
1132
+ handoffId: job.handoffId,
1133
+ jobId: job.id,
1134
+ workerId: job.workerId
1135
+ })
1136
+ }
1137
+
1138
+ return handoffs
1139
+ }
1140
+
1141
+ /**
1142
+ * Reclaims only unchanged exact handoffs selected by a main-generation startup
1143
+ * snapshot. The ordinary orphan failure path owns retries, terminal status,
1144
+ * count transitions, schedule ownership, and concurrency release.
1145
+ * @param {object} args - Options.
1146
+ * @param {import("./types.js").BackgroundJobHandoffSnapshot[]} args.handoffs - Exact startup snapshots.
1147
+ * @param {ReturnType<typeof JSON.parse>} args.error - Orphan reason.
1148
+ * @returns {Promise<import("./types.js").BackgroundJobRow[]>} - Accepted transitions.
1149
+ */
1150
+ async markOrphanedHandoffs({handoffs, error}) {
1151
+ await this.ensureReady()
1152
+
1153
+ return await this._serializedCountMutation(async (db) => {
1154
+ /** @type {BackgroundJobOrphanSelection[]} */
1155
+ const selections = []
1156
+
1157
+ for (const handoff of handoffs) {
1158
+ const job = await this._getJobRowById(db, handoff.jobId)
1159
+
1160
+ if (!job || job.status !== "handed_off") continue
1161
+ if (job.handoffId !== handoff.handoffId) continue
1162
+ if (job.workerId !== handoff.workerId) continue
1163
+ if (job.handedOffAtMs !== handoff.handedOffAtMs) continue
1164
+
1165
+ selections.push({
1166
+ conditions: {
1167
+ handed_off_at_ms: handoff.handedOffAtMs,
1168
+ handoff_id: handoff.handoffId,
1169
+ id: handoff.jobId,
1170
+ status: "handed_off",
1171
+ worker_id: handoff.workerId
1172
+ },
1173
+ job
1174
+ })
1175
+ }
1176
+
1177
+ return await this._markOrphanSelections({db, error, selections})
1178
+ })
1179
+ }
1180
+
1099
1181
  /**
1100
1182
  * Runs mark failed.
1101
1183
  * @param {object} args - Options.
@@ -1141,8 +1223,8 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1141
1223
 
1142
1224
  const rows = await query.results()
1143
1225
 
1144
- /** @type {import("./types.js").BackgroundJobRow[]} */
1145
- const orphanedJobs = []
1226
+ /** @type {BackgroundJobOrphanSelection[]} */
1227
+ const selections = []
1146
1228
 
1147
1229
  for (const row of rows) {
1148
1230
  const job = this._normalizeJobRow(row)
@@ -1160,28 +1242,55 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1160
1242
  // wrongly release the concurrency reservation of — that new lease.
1161
1243
  // `handed_off_at_ms` is always set on a handed-off row (and the SELECT
1162
1244
  // required it `<= cutoff`), so it is a reliable null-safe lease pin.
1163
- const orphanedJob = await this._applyFailure({
1164
- db,
1165
- job,
1166
- error: "Job orphaned after timeout",
1167
- markOrphaned: true,
1168
- conditions: {id: job.id, status: "handed_off", handed_off_at_ms: job.handedOffAtMs}
1245
+ selections.push({
1246
+ conditions: {id: job.id, status: "handed_off", handed_off_at_ms: job.handedOffAtMs},
1247
+ job
1169
1248
  })
1170
-
1171
- if (orphanedJob) orphanedJobs.push(orphanedJob)
1172
1249
  }
1173
1250
 
1174
- const statusCounts = this._statusCounts(orphanedJobs)
1175
- const deltas = this._emptyCountBuckets()
1251
+ return await this._markOrphanSelections({
1252
+ db,
1253
+ error: "Job orphaned after timeout",
1254
+ selections
1255
+ })
1256
+ })
1257
+ }
1258
+
1259
+ /**
1260
+ * Applies the common fenced orphan transition and records one aggregate count
1261
+ * delta for the accepted rows.
1262
+ * @param {object} args - Options.
1263
+ * @param {import("../database/drivers/base.js").default} args.db - Transaction connection.
1264
+ * @param {ReturnType<typeof JSON.parse>} args.error - Orphan reason.
1265
+ * @param {BackgroundJobOrphanSelection[]} args.selections - Selected handoffs and exact fences.
1266
+ * @returns {Promise<import("./types.js").BackgroundJobRow[]>} - Accepted transitions.
1267
+ */
1268
+ async _markOrphanSelections({db, error, selections}) {
1269
+ /** @type {import("./types.js").BackgroundJobRow[]} */
1270
+ const orphanedJobs = []
1271
+
1272
+ for (const {conditions, job} of selections) {
1273
+ const orphanedJob = await this._applyFailure({
1274
+ conditions,
1275
+ db,
1276
+ error,
1277
+ job,
1278
+ markOrphaned: true
1279
+ })
1176
1280
 
1177
- for (const [status, count] of Object.entries(statusCounts)) {
1178
- deltas.handed_off -= count
1179
- deltas[status] += count
1180
- }
1181
- await this._recordCountDelta(db, deltas)
1281
+ if (orphanedJob) orphanedJobs.push(orphanedJob)
1282
+ }
1182
1283
 
1183
- return orphanedJobs
1184
- })
1284
+ const statusCounts = this._statusCounts(orphanedJobs)
1285
+ const deltas = this._emptyCountBuckets()
1286
+
1287
+ for (const [status, count] of Object.entries(statusCounts)) {
1288
+ deltas.handed_off -= count
1289
+ deltas[status] += count
1290
+ }
1291
+ await this._recordCountDelta(db, deltas)
1292
+
1293
+ return orphanedJobs
1185
1294
  }
1186
1295
 
1187
1296
  /**
@@ -43,6 +43,13 @@
43
43
  * @property {string} handoffId - Unique handoff lease id.
44
44
  * @property {number} handedOffAtMs - Time handed to a worker in ms.
45
45
  */
46
+ /**
47
+ * @typedef {object} BackgroundJobHandoffSnapshot
48
+ * @property {string} jobId - Job holding the lease.
49
+ * @property {string} handoffId - Exact durable lease id.
50
+ * @property {string} workerId - Stable worker id that received the lease.
51
+ * @property {number} handedOffAtMs - Time handed to the worker in ms.
52
+ */
46
53
  /**
47
54
  * @typedef {object} BackgroundJobHandoffRequest
48
55
  * @property {string} jobId - Job to claim.
@@ -130,6 +130,23 @@ export default class BackgroundJobsAdapter {
130
130
  jobId: string;
131
131
  handoffId: string;
132
132
  }>>;
133
+ /**
134
+ * Snapshots exact active handoffs before a new main generation accepts worker
135
+ * reconnects. Adapters that do not persist worker leases may return none.
136
+ * @returns {Promise<import("./types.js").BackgroundJobHandoffSnapshot[]>} - Exact active handoffs.
137
+ */
138
+ snapshotHandedOffJobs(): Promise<import("./types.js").BackgroundJobHandoffSnapshot[]>;
139
+ /**
140
+ * Applies orphan failure semantics to unchanged exact handoff snapshots.
141
+ * Adapters that return startup snapshots must implement the matching fenced
142
+ * transition.
143
+ * @param {{handoffs: import("./types.js").BackgroundJobHandoffSnapshot[], error: ReturnType<typeof JSON.parse>}} _args - Exact leases and orphan reason.
144
+ * @returns {Promise<import("./types.js").BackgroundJobRow[]>} - Accepted transitions.
145
+ */
146
+ markOrphanedHandoffs(_args: {
147
+ handoffs: import("./types.js").BackgroundJobHandoffSnapshot[];
148
+ error: ReturnType<typeof JSON.parse>;
149
+ }): Promise<import("./types.js").BackgroundJobRow[]>;
133
150
  /**
134
151
  * Marks a handed-off job failed or retryable.
135
152
  * @param {{jobId: string, error: ReturnType<typeof JSON.parse>, handoffId?: string, workerId?: string, handedOffAtMs?: number}} _args - Failure report.
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../../src/background-jobs/adapter.js"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"adapter.d.ts","sourceRoot":"","sources":["../../../src/background-jobs/adapter.js"],"names":[],"mappings":"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"}