velocious 1.0.516 → 1.0.518

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 (72) hide show
  1. package/README.md +2 -0
  2. package/build/background-jobs/job.js +27 -2
  3. package/build/background-jobs/store.js +356 -39
  4. package/build/background-jobs/types.js +6 -0
  5. package/build/configuration-types.js +12 -1
  6. package/build/configuration.js +2 -1
  7. package/build/database/drivers/base.js +26 -0
  8. package/build/database/drivers/mssql/index.js +13 -0
  9. package/build/database/drivers/mysql/index.js +18 -0
  10. package/build/database/drivers/pgsql/index.js +12 -0
  11. package/build/database/drivers/sqlite/connection-sql-js.js +11 -0
  12. package/build/database/drivers/sqlite/index.js +14 -4
  13. package/build/database/drivers/sqlite/index.native.js +13 -0
  14. package/build/database/drivers/sqlite/index.web.js +11 -1
  15. package/build/database/drivers/sqlite/query.js +1 -2
  16. package/build/http-server/client/request-buffer/index.js +151 -89
  17. package/build/src/background-jobs/job.d.ts +15 -0
  18. package/build/src/background-jobs/job.d.ts.map +1 -1
  19. package/build/src/background-jobs/job.js +24 -3
  20. package/build/src/background-jobs/store.d.ts +111 -9
  21. package/build/src/background-jobs/store.d.ts.map +1 -1
  22. package/build/src/background-jobs/store.js +339 -40
  23. package/build/src/background-jobs/types.d.ts +30 -0
  24. package/build/src/background-jobs/types.d.ts.map +1 -1
  25. package/build/src/background-jobs/types.js +7 -1
  26. package/build/src/configuration-types.d.ts +29 -2
  27. package/build/src/configuration-types.d.ts.map +1 -1
  28. package/build/src/configuration-types.js +13 -2
  29. package/build/src/configuration.d.ts.map +1 -1
  30. package/build/src/configuration.js +3 -2
  31. package/build/src/database/drivers/base.d.ts +13 -0
  32. package/build/src/database/drivers/base.d.ts.map +1 -1
  33. package/build/src/database/drivers/base.js +25 -1
  34. package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
  35. package/build/src/database/drivers/mssql/index.js +13 -1
  36. package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
  37. package/build/src/database/drivers/mysql/index.js +21 -1
  38. package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
  39. package/build/src/database/drivers/pgsql/index.js +14 -1
  40. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +6 -0
  41. package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
  42. package/build/src/database/drivers/sqlite/connection-sql-js.js +11 -1
  43. package/build/src/database/drivers/sqlite/index.d.ts +2 -2
  44. package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
  45. package/build/src/database/drivers/sqlite/index.js +15 -5
  46. package/build/src/database/drivers/sqlite/index.native.d.ts.map +1 -1
  47. package/build/src/database/drivers/sqlite/index.native.js +14 -1
  48. package/build/src/database/drivers/sqlite/index.web.d.ts +2 -1
  49. package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
  50. package/build/src/database/drivers/sqlite/index.web.js +11 -2
  51. package/build/src/database/drivers/sqlite/query.d.ts +2 -2
  52. package/build/src/database/drivers/sqlite/query.d.ts.map +1 -1
  53. package/build/src/database/drivers/sqlite/query.js +2 -3
  54. package/build/src/http-server/client/request-buffer/index.d.ts +23 -2
  55. package/build/src/http-server/client/request-buffer/index.d.ts.map +1 -1
  56. package/build/src/http-server/client/request-buffer/index.js +141 -86
  57. package/package.json +2 -2
  58. package/src/background-jobs/job.js +27 -2
  59. package/src/background-jobs/store.js +356 -39
  60. package/src/background-jobs/types.js +6 -0
  61. package/src/configuration-types.js +12 -1
  62. package/src/configuration.js +2 -1
  63. package/src/database/drivers/base.js +26 -0
  64. package/src/database/drivers/mssql/index.js +13 -0
  65. package/src/database/drivers/mysql/index.js +18 -0
  66. package/src/database/drivers/pgsql/index.js +12 -0
  67. package/src/database/drivers/sqlite/connection-sql-js.js +11 -0
  68. package/src/database/drivers/sqlite/index.js +14 -4
  69. package/src/database/drivers/sqlite/index.native.js +13 -0
  70. package/src/database/drivers/sqlite/index.web.js +11 -1
  71. package/src/database/drivers/sqlite/query.js +1 -2
  72. package/src/http-server/client/request-buffer/index.js +151 -89
package/README.md CHANGED
@@ -1930,6 +1930,8 @@ Create the file `src/routes/testing/another-action.ejs` and so something like th
1930
1930
 
1931
1931
  Velocious includes a simple background jobs system inspired by Sidekiq.
1932
1932
 
1933
+ Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
1934
+
1933
1935
  Production apps can listen for `background-job-failed` or its `all-error` mirror to report accepted failed attempts, including retry and terminal state metadata. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).
1934
1936
 
1935
1937
  ## Setup
@@ -13,6 +13,15 @@ import BackgroundJobsClient from "./client.js"
13
13
  * @template {Array<?>} [TArgs=[]]
14
14
  */
15
15
  export default class VelociousJob {
16
+ /**
17
+ * Queue this job class runs on. Subclasses set e.g. `static queue = "builds"`
18
+ * to route onto a queue with its own cluster-wide concurrency cap (configured
19
+ * via `backgroundJobs.queues`). The `{queue}` enqueue option overrides it.
20
+ * Left undefined, jobs run on the `"default"` queue.
21
+ * @type {string | undefined}
22
+ */
23
+ static queue = undefined
24
+
16
25
  /**
17
26
  * Runs job name.
18
27
  * @returns {string} - Job name.
@@ -21,6 +30,22 @@ export default class VelociousJob {
21
30
  return this.name
22
31
  }
23
32
 
33
+ /**
34
+ * Folds this job class's static `queue` into the enqueue options unless the
35
+ * caller already specified one.
36
+ * @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
37
+ * @returns {import("./types.js").BackgroundJobOptions} - Options including the resolved queue.
38
+ */
39
+ static _withQueue(options) {
40
+ const merged = options ? {...options} : {}
41
+
42
+ if (merged.queue === undefined && typeof this.queue === "string" && this.queue.length > 0) {
43
+ merged.queue = this.queue
44
+ }
45
+
46
+ return merged
47
+ }
48
+
24
49
  /**
25
50
  * Runs perform later.
26
51
  * @param {...?} args - Job args.
@@ -33,7 +58,7 @@ export default class VelociousJob {
33
58
  return await client.enqueue({
34
59
  jobName: this.jobName(),
35
60
  args: jobArgs,
36
- options: jobOptions
61
+ options: this._withQueue(jobOptions)
37
62
  })
38
63
  }
39
64
 
@@ -50,7 +75,7 @@ export default class VelociousJob {
50
75
  return await client.enqueue({
51
76
  jobName: this.jobName(),
52
77
  args,
53
- options
78
+ options: this._withQueue(options)
54
79
  })
55
80
  }
56
81
 
@@ -11,6 +11,7 @@ const MIGRATION_SCOPE = "background_jobs"
11
11
  const MIGRATION_VERSION = "20250215000000"
12
12
  const EXECUTION_MODE_BACKFILL_MIGRATION_VERSION = "20260607131010"
13
13
  const JOBS_TABLE = "background_jobs"
14
+ const CONCURRENCY_TABLE = "background_job_concurrency"
14
15
  const DEFAULT_MAX_RETRIES = 10
15
16
  const ORPHANED_AFTER_MS = 2 * 60 * 60 * 1000
16
17
  /**
@@ -18,6 +19,10 @@ const ORPHANED_AFTER_MS = 2 * 60 * 60 * 1000
18
19
  * @type {import("./types.js").BackgroundJobExecutionMode[]} */
19
20
  const EXECUTION_MODES = ["inline", "forked", "spawned"]
20
21
  const DEFAULT_EXECUTION_MODE = "forked"
22
+ const DEFAULT_QUEUE = "default"
23
+ // Queue-derived durable concurrency keys are namespaced so they can't collide
24
+ // with explicit caller-supplied concurrencyKeys.
25
+ const QUEUE_CONCURRENCY_KEY_PREFIX = "queue:"
21
26
 
22
27
  /**
23
28
  * Columns the dashboard is allowed to sort job listings by, mapped to their
@@ -94,8 +99,17 @@ export default class BackgroundJobsStore {
94
99
  const executionMode = this._normalizeExecutionMode(options)
95
100
  const maxRetries = this._normalizeMaxRetries(options?.maxRetries)
96
101
  const argsJson = JSON.stringify(args || [])
102
+ const queue = this._normalizeQueue(options)
103
+ const concurrency = this._resolveConcurrency(options, queue)
97
104
 
98
105
  await this._withDb(async (db) => {
106
+ if (concurrency) {
107
+ if (concurrency.queueDerived) {
108
+ await this._ensureQueueConcurrencyKey(db, concurrency)
109
+ } else {
110
+ await this._ensureConcurrencyKey(db, concurrency)
111
+ }
112
+ }
99
113
  await db.insert({
100
114
  tableName: JOBS_TABLE,
101
115
  data: {
@@ -104,11 +118,14 @@ export default class BackgroundJobsStore {
104
118
  args_json: argsJson,
105
119
  forked: executionMode !== "inline",
106
120
  execution_mode: executionMode,
121
+ queue,
107
122
  max_retries: maxRetries,
108
123
  attempts: 0,
109
124
  status: "queued",
110
125
  scheduled_at_ms: now,
111
- created_at_ms: now
126
+ created_at_ms: now,
127
+ concurrency_key: concurrency?.concurrencyKey || null,
128
+ max_concurrency: concurrency?.maxConcurrency || null
112
129
  }
113
130
  })
114
131
  })
@@ -169,6 +186,17 @@ export default class BackgroundJobsStore {
169
186
  .where({status: "queued"})
170
187
  .where(`scheduled_at_ms ${scheduledAtOperator} ${db.quote(now)}`)
171
188
 
189
+ if (scheduledAtOperator === "<=") {
190
+ const jobsTable = db.quoteTable(JOBS_TABLE)
191
+ const concurrencyTable = db.quoteTable(CONCURRENCY_TABLE)
192
+ query = query.where(
193
+ `(${jobsTable}.${db.quoteColumn("concurrency_key")} IS NULL OR EXISTS (` +
194
+ `SELECT 1 FROM ${concurrencyTable} WHERE ` +
195
+ `${concurrencyTable}.${db.quoteColumn("concurrency_key")} = ${jobsTable}.${db.quoteColumn("concurrency_key")} AND ` +
196
+ `${concurrencyTable}.${db.quoteColumn("active_count")} < ${concurrencyTable}.${db.quoteColumn("max_concurrency")}))`
197
+ )
198
+ }
199
+
172
200
  if (typeof forked === "boolean") {
173
201
  query = query.where({forked})
174
202
  }
@@ -314,8 +342,12 @@ export default class BackgroundJobsStore {
314
342
  const handedOffAtMs = Date.now()
315
343
  const handoffId = randomUUID()
316
344
 
317
- return await this._withDb(async (db) => {
318
- await db.update({
345
+ return await this._withDb(async (db) => await this._transactionResult(db, async () => {
346
+ const queuedJob = await this._getJobRowById(db, jobId)
347
+ if (!queuedJob || queuedJob.status !== "queued") return null
348
+ if (queuedJob.concurrencyKey && !(await this._reserveConcurrency(db, queuedJob.concurrencyKey))) return null
349
+
350
+ const affectedRows = await this._updateAffectedRows(db, {
319
351
  tableName: JOBS_TABLE,
320
352
  data: {
321
353
  status: "handed_off",
@@ -326,12 +358,13 @@ export default class BackgroundJobsStore {
326
358
  conditions: {id: jobId, status: "queued"}
327
359
  })
328
360
 
329
- const job = await this._getJobRowById(db, jobId)
330
-
331
- if (job?.status !== "handed_off" || job.handoffId !== handoffId) return null
361
+ if (affectedRows !== 1) {
362
+ await this._releaseConcurrency(db, queuedJob.concurrencyKey)
363
+ return null
364
+ }
332
365
 
333
366
  return {handedOffAtMs, handoffId}
334
- })
367
+ }))
335
368
  }
336
369
 
337
370
  /**
@@ -346,13 +379,13 @@ export default class BackgroundJobsStore {
346
379
  async markCompleted({jobId, handoffId, workerId, handedOffAtMs}) {
347
380
  await this.ensureReady()
348
381
 
349
- return await this._withDb(async (db) => await this._withHandoffLock({db, jobId}, async () => {
382
+ return await this._withDb(async (db) => await this._transactionResult(db, async () => {
350
383
  const job = await this._getJobRowById(db, jobId)
351
384
 
352
385
  if (!job) return false
353
386
  if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return false
354
387
 
355
- await db.update({
388
+ const affectedRows = await this._updateAffectedRows(db, {
356
389
  tableName: JOBS_TABLE,
357
390
  data: {
358
391
  status: "completed",
@@ -361,9 +394,9 @@ export default class BackgroundJobsStore {
361
394
  conditions: this._activeHandoffConditions(job)
362
395
  })
363
396
 
364
- const updatedJob = await this._getJobRowById(db, jobId)
365
-
366
- return updatedJob?.status === "completed" && updatedJob.handoffId === job.handoffId
397
+ if (affectedRows !== 1) return false
398
+ await this._releaseConcurrency(db, job.concurrencyKey)
399
+ return true
367
400
  }))
368
401
  }
369
402
 
@@ -377,8 +410,10 @@ export default class BackgroundJobsStore {
377
410
  async markReturnedToQueue({jobId, handoffId}) {
378
411
  await this.ensureReady()
379
412
 
380
- await this._withDb(async (db) => {
381
- await db.update({
413
+ await this._withDb(async (db) => await db.transaction(async () => {
414
+ const job = await this._getJobRowById(db, jobId)
415
+ if (!job || job.handoffId !== handoffId || job.status !== "handed_off") return
416
+ const affectedRows = await this._updateAffectedRows(db, {
382
417
  tableName: JOBS_TABLE,
383
418
  data: {
384
419
  status: "queued",
@@ -389,7 +424,8 @@ export default class BackgroundJobsStore {
389
424
  },
390
425
  conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"}
391
426
  })
392
- })
427
+ if (affectedRows === 1) await this._releaseConcurrency(db, job.concurrencyKey)
428
+ }))
393
429
  }
394
430
 
395
431
  /**
@@ -405,13 +441,14 @@ export default class BackgroundJobsStore {
405
441
  async markFailed({jobId, error, handoffId, workerId, handedOffAtMs}) {
406
442
  await this.ensureReady()
407
443
 
408
- return await this._withDb(async (db) => await this._withHandoffLock({db, jobId}, async () => {
444
+ return await this._withDb(async (db) => await this._transactionResult(db, async () => {
409
445
  const job = await this._getJobRowById(db, jobId)
410
446
 
411
447
  if (!job) return null
412
448
  if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return null
413
449
 
414
- return await this._applyFailure({db, job, error, markOrphaned: false})
450
+ const updatedJob = await this._applyFailure({db, job, error, markOrphaned: false})
451
+ return updatedJob
415
452
  }))
416
453
  }
417
454
 
@@ -462,9 +499,27 @@ export default class BackgroundJobsStore {
462
499
 
463
500
  await this._withDb(async (db) => {
464
501
  await db.query(`DELETE FROM ${db.quoteTable(JOBS_TABLE)}`)
502
+ if (await db.tableExists(CONCURRENCY_TABLE)) await db.query(`DELETE FROM ${db.quoteTable(CONCURRENCY_TABLE)}`)
465
503
  })
466
504
  }
467
505
 
506
+ /**
507
+ * Cancels a queued or handed-off job and releases any durable concurrency reservation.
508
+ * @param {string} jobId - Job id.
509
+ * @returns {Promise<boolean>} - Whether the job was cancelled.
510
+ */
511
+ async cancel(jobId) {
512
+ await this.ensureReady()
513
+ return await this._withDb(async (db) => await this._transactionResult(db, async () => {
514
+ const job = await this._getJobRowById(db, jobId)
515
+ if (!job || (job.status !== "queued" && job.status !== "handed_off")) return false
516
+ const affectedRows = await this._updateAffectedRows(db, {tableName: JOBS_TABLE, data: {status: "cancelled"}, conditions: {id: job.id, status: job.status}})
517
+ if (affectedRows !== 1) return false
518
+ if (job.status === "handed_off") await this._releaseConcurrency(db, job.concurrencyKey)
519
+ return true
520
+ }))
521
+ }
522
+
468
523
  /**
469
524
  * Runs get retry delay ms.
470
525
  * @param {number} retryCount - Retry attempt count (1-based).
@@ -506,11 +561,15 @@ export default class BackgroundJobsStore {
506
561
  // row alone, otherwise later callers fail with "no such table".
507
562
  if (alreadyApplied && await db.tableExists(JOBS_TABLE)) {
508
563
  await this._ensureJobsTableColumns(db)
564
+ await this._ensureConcurrencyTable(db)
565
+ await this._reconcileConcurrency(db)
509
566
  return
510
567
  }
511
568
 
512
569
  await this._applyMigrations(db)
513
570
  await this._ensureJobsTableColumns(db)
571
+ await this._ensureConcurrencyTable(db)
572
+ await this._reconcileConcurrency(db)
514
573
 
515
574
  if (alreadyApplied) return
516
575
 
@@ -574,6 +633,7 @@ export default class BackgroundJobsStore {
574
633
  table.text("args_json", {null: false})
575
634
  table.boolean("forked", {null: false})
576
635
  table.string("execution_mode", {null: false})
636
+ table.string("queue", {null: true, index: true})
577
637
  table.integer("max_retries", {null: false})
578
638
  table.integer("attempts", {null: false})
579
639
  table.string("status", {null: false, index: true})
@@ -586,6 +646,8 @@ export default class BackgroundJobsStore {
586
646
  table.bigint("orphaned_at_ms", {null: true, index: true})
587
647
  table.string("worker_id", {null: true})
588
648
  table.text("last_error", {null: true})
649
+ table.string("concurrency_key", {null: true, index: true})
650
+ table.integer("max_concurrency", {null: true})
589
651
 
590
652
  await db.createTable(table)
591
653
  }
@@ -643,6 +705,74 @@ export default class BackgroundJobsStore {
643
705
  }
644
706
 
645
707
  await this._backfillExecutionModesOnce(db)
708
+
709
+ const lockName = `${MIGRATION_SCOPE}:concurrency_columns`
710
+ const acquired = await db.acquireAdvisoryLock(lockName)
711
+
712
+ if (!acquired) throw new Error("Failed to acquire background jobs concurrency schema lock")
713
+
714
+ try {
715
+ // SQL Server schema reads can deadlock with a concurrent ALTER TABLE, so
716
+ // acquire the lock before inspecting either column rather than only
717
+ // protecting the mutation.
718
+ db.clearSchemaCache()
719
+ const lockedTable = await db.getTableByNameOrFail(JOBS_TABLE)
720
+ const concurrencyColumnNames = ["concurrency_key", "max_concurrency"]
721
+
722
+ for (const concurrencyColumnName of concurrencyColumnNames) {
723
+ if (await lockedTable.getColumnByName(concurrencyColumnName)) continue
724
+
725
+ const tableData = new TableData(JOBS_TABLE)
726
+ if (concurrencyColumnName == "concurrency_key") {
727
+ tableData.string("concurrency_key", {null: true, index: true})
728
+ } else {
729
+ tableData.integer("max_concurrency", {null: true})
730
+ }
731
+
732
+ for (const sql of await db.alterTableSQLs(tableData)) await db.query(sql)
733
+ }
734
+
735
+ db.clearSchemaCache()
736
+ } finally {
737
+ await db.releaseAdvisoryLock(lockName)
738
+ }
739
+
740
+ await this._ensureQueueColumn(db)
741
+ }
742
+
743
+ /**
744
+ * Idempotently adds the `queue` column to an existing jobs table. Existing
745
+ * rows read back as the default queue (see {@link _normalizeJobRow}), so no
746
+ * data backfill is required.
747
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
748
+ * @returns {Promise<void>} - Resolves when ensured.
749
+ */
750
+ async _ensureQueueColumn(db) {
751
+ const table = await db.getTableByNameOrFail(JOBS_TABLE)
752
+
753
+ if (await table.getColumnByName("queue")) return
754
+
755
+ const lockName = `${MIGRATION_SCOPE}:queue_column`
756
+ const acquired = await db.acquireAdvisoryLock(lockName)
757
+
758
+ if (!acquired) throw new Error("Failed to acquire background jobs queue schema lock")
759
+
760
+ try {
761
+ db.clearSchemaCache()
762
+ const lockedTable = await db.getTableByNameOrFail(JOBS_TABLE)
763
+
764
+ if (!(await lockedTable.getColumnByName("queue"))) {
765
+ const tableData = new TableData(JOBS_TABLE)
766
+
767
+ tableData.string("queue", {null: true, index: true})
768
+
769
+ for (const sql of await db.alterTableSQLs(tableData)) await db.query(sql)
770
+
771
+ db.clearSchemaCache()
772
+ }
773
+ } finally {
774
+ await db.releaseAdvisoryLock(lockName)
775
+ }
646
776
  }
647
777
 
648
778
  /**
@@ -757,19 +887,18 @@ export default class BackgroundJobsStore {
757
887
  shouldRetry
758
888
  })
759
889
 
760
- await db.update({
890
+ const affectedRows = await this._updateAffectedRows(db, {
761
891
  tableName: JOBS_TABLE,
762
892
  data: update,
763
893
  conditions: this._activeHandoffConditions(job)
764
894
  })
765
895
 
896
+ if (affectedRows !== 1) return null
897
+ await this._releaseConcurrency(db, job.concurrencyKey)
898
+
766
899
  const updatedJob = await this._getJobRowById(db, job.id)
767
900
 
768
901
  if (!updatedJob) return null
769
- if (updatedJob.handoffId !== job.handoffId) return null
770
- if (updatedJob.attempts !== nextAttempt) return null
771
- if (updatedJob.status !== update.status) return null
772
-
773
902
  return updatedJob
774
903
  }
775
904
 
@@ -855,6 +984,7 @@ export default class BackgroundJobsStore {
855
984
  args: this._parseArgs(row.args_json),
856
985
  executionMode,
857
986
  forked: executionMode !== "inline",
987
+ queue: row.queue ? String(row.queue) : DEFAULT_QUEUE,
858
988
  status: row.status ? String(row.status) : "queued",
859
989
  attempts: this._normalizeNumber(row.attempts),
860
990
  maxRetries: this._normalizeNumber(row.max_retries),
@@ -866,8 +996,198 @@ export default class BackgroundJobsStore {
866
996
  failedAtMs: this._normalizeNumber(row.failed_at_ms),
867
997
  orphanedAtMs: this._normalizeNumber(row.orphaned_at_ms),
868
998
  workerId: row.worker_id ? String(row.worker_id) : null,
869
- lastError: row.last_error ? String(row.last_error) : null
999
+ lastError: row.last_error ? String(row.last_error) : null,
1000
+ concurrencyKey: row.concurrency_key ? String(row.concurrency_key) : null,
1001
+ maxConcurrency: this._normalizeNumber(row.max_concurrency)
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Validates concurrency options.
1007
+ * @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
1008
+ * @returns {{concurrencyKey: string, maxConcurrency: number} | null} - Normalized configuration.
1009
+ */
1010
+ _normalizeConcurrencyOptions(options) {
1011
+ const key = options?.concurrencyKey
1012
+ const cap = options?.maxConcurrency
1013
+ if (key === undefined && cap === undefined) return null
1014
+ if (typeof key !== "string" || key.length === 0 || !Number.isInteger(cap) || Number(cap) <= 0) {
1015
+ throw new Error("background job concurrencyKey and maxConcurrency must be paired; concurrencyKey must be non-empty and maxConcurrency must be a positive integer")
1016
+ }
1017
+ return {concurrencyKey: key, maxConcurrency: Number(cap)}
1018
+ }
1019
+
1020
+ /**
1021
+ * Normalizes a job's queue name, defaulting to "default".
1022
+ * @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
1023
+ * @returns {string} - Queue name.
1024
+ */
1025
+ _normalizeQueue(options) {
1026
+ const queue = options?.queue
1027
+
1028
+ if (typeof queue === "string" && queue.trim().length > 0) return queue.trim()
1029
+
1030
+ return DEFAULT_QUEUE
1031
+ }
1032
+
1033
+ /**
1034
+ * Resolves a job's durable concurrency. An explicit concurrencyKey/maxConcurrency
1035
+ * pair always wins. Otherwise, when the job's queue has a configured cap
1036
+ * (`backgroundJobs.queues[queue].maxConcurrent`), derive a queue-scoped
1037
+ * concurrency key so the queue cap is enforced cluster-wide through the
1038
+ * existing durable concurrency mechanism.
1039
+ * @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
1040
+ * @param {string} queue - Normalized queue name.
1041
+ * @returns {{concurrencyKey: string, maxConcurrency: number, queueDerived: boolean} | null} - Resolved concurrency.
1042
+ */
1043
+ _resolveConcurrency(options, queue) {
1044
+ const explicit = this._normalizeConcurrencyOptions(options)
1045
+
1046
+ if (explicit) return {...explicit, queueDerived: false}
1047
+
1048
+ const cap = this._queueMaxConcurrency(queue)
1049
+
1050
+ if (cap === null) return null
1051
+
1052
+ return {concurrencyKey: `${QUEUE_CONCURRENCY_KEY_PREFIX}${queue}`, maxConcurrency: cap, queueDerived: true}
1053
+ }
1054
+
1055
+ /**
1056
+ * Reads the configured max concurrency for a queue from the background-jobs config.
1057
+ * @param {string} queue - Queue name.
1058
+ * @returns {number | null} - Positive integer cap, or null when the queue has no configured cap.
1059
+ */
1060
+ _queueMaxConcurrency(queue) {
1061
+ const queues = this.configuration.getBackgroundJobsConfig().queues
1062
+ const cap = queues?.[queue]?.maxConcurrent
1063
+
1064
+ if (Number.isInteger(cap) && Number(cap) > 0) return Number(cap)
1065
+
1066
+ return null
1067
+ }
1068
+
1069
+ /**
1070
+ * Like {@link _ensureConcurrencyKey}, but for queue-derived keys the configured
1071
+ * queue cap is the source of truth: if it changed, update the stored cap
1072
+ * instead of throwing on conflict (config-driven caps must be tunable).
1073
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1074
+ * @param {{concurrencyKey: string, maxConcurrency: number}} concurrency - Concurrency configuration.
1075
+ * @returns {Promise<void>} - Resolves when ensured.
1076
+ */
1077
+ async _ensureQueueConcurrencyKey(db, {concurrencyKey, maxConcurrency}) {
1078
+ const rows = await db.newQuery().from(CONCURRENCY_TABLE).where({concurrency_key: concurrencyKey}).limit(1).results()
1079
+
1080
+ if (!rows[0]) {
1081
+ try {
1082
+ await db.insert({tableName: CONCURRENCY_TABLE, data: {active_count: 0, concurrency_key: concurrencyKey, max_concurrency: maxConcurrency}})
1083
+
1084
+ return
1085
+ } catch (error) {
1086
+ const racedRows = await db.newQuery().from(CONCURRENCY_TABLE).where({concurrency_key: concurrencyKey}).limit(1).results()
1087
+
1088
+ if (!racedRows[0]) throw error
1089
+
1090
+ rows[0] = racedRows[0]
1091
+ }
870
1092
  }
1093
+
1094
+ const configured = /** @type {{max_concurrency?: number | string}} */ (rows[0])
1095
+
1096
+ if (this._normalizeNumber(configured.max_concurrency) !== maxConcurrency) {
1097
+ const table = db.quoteTable(CONCURRENCY_TABLE)
1098
+
1099
+ await db.query(`UPDATE ${table} SET ${db.quoteColumn("max_concurrency")} = ${Number(maxConcurrency)} WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)}`)
1100
+ }
1101
+ }
1102
+
1103
+ /**
1104
+ * Ensures the concurrency state table exists.
1105
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1106
+ * @returns {Promise<void>} - Resolves when ready.
1107
+ */
1108
+ async _ensureConcurrencyTable(db) {
1109
+ if (await db.tableExists(CONCURRENCY_TABLE)) return
1110
+ const table = new TableData(CONCURRENCY_TABLE, {ifNotExists: true})
1111
+ table.string("concurrency_key", {primaryKey: true})
1112
+ table.integer("max_concurrency", {null: false})
1113
+ table.integer("active_count", {null: false})
1114
+ await db.createTable(table)
1115
+ }
1116
+
1117
+ /**
1118
+ * Registers or verifies a stable key configuration.
1119
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1120
+ * @param {object} concurrency - Concurrency configuration.
1121
+ * @param {string} concurrency.concurrencyKey - Concurrency key.
1122
+ * @param {number} concurrency.maxConcurrency - Stable cap.
1123
+ * @returns {Promise<void>} - Resolves when verified.
1124
+ */
1125
+ async _ensureConcurrencyKey(db, {concurrencyKey, maxConcurrency}) {
1126
+ const rows = await db.newQuery().from(CONCURRENCY_TABLE).where({concurrency_key: concurrencyKey}).limit(1).results()
1127
+ if (!rows[0]) {
1128
+ try {
1129
+ await db.insert({tableName: CONCURRENCY_TABLE, data: {active_count: 0, concurrency_key: concurrencyKey, max_concurrency: maxConcurrency}})
1130
+ return
1131
+ } catch (error) {
1132
+ const racedRows = await db.newQuery().from(CONCURRENCY_TABLE).where({concurrency_key: concurrencyKey}).limit(1).results()
1133
+ if (!racedRows[0]) throw error
1134
+ rows[0] = racedRows[0]
1135
+ }
1136
+ }
1137
+ const configured = /** @type {{max_concurrency?: number | string}} */ (rows[0])
1138
+ if (this._normalizeNumber(configured.max_concurrency) !== maxConcurrency) throw new Error(`Conflicting maxConcurrency for background job concurrencyKey: ${concurrencyKey}`)
1139
+ }
1140
+
1141
+ /**
1142
+ * Atomically reserves capacity for a key.
1143
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1144
+ * @param {string} concurrencyKey - Concurrency key.
1145
+ * @returns {Promise<boolean>} - Whether capacity was reserved.
1146
+ */
1147
+ async _reserveConcurrency(db, concurrencyKey) {
1148
+ const table = db.quoteTable(CONCURRENCY_TABLE)
1149
+ const count = db.quoteColumn("active_count")
1150
+ const affectedRows = await db.affectedRows(`UPDATE ${table} SET ${count} = ${count} + 1 WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)} AND ${count} < ${db.quoteColumn("max_concurrency")}`)
1151
+ return affectedRows === 1
1152
+ }
1153
+
1154
+ /**
1155
+ * Runs a portable update and returns its affected-row count.
1156
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1157
+ * @param {import("../database/drivers/base.js").UpdateSqlArgsType} args - Update options.
1158
+ * @returns {Promise<number>} - Affected row count.
1159
+ */
1160
+ async _updateAffectedRows(db, args) {
1161
+ return await db.affectedRows(db.updateSql(args))
1162
+ }
1163
+
1164
+ /**
1165
+ * Releases capacity for a key.
1166
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1167
+ * @param {string | null} concurrencyKey - Concurrency key.
1168
+ * @returns {Promise<void>} - Resolves when released.
1169
+ */
1170
+ async _releaseConcurrency(db, concurrencyKey) {
1171
+ if (!concurrencyKey) return
1172
+ const table = db.quoteTable(CONCURRENCY_TABLE)
1173
+ const count = db.quoteColumn("active_count")
1174
+ await db.query(`UPDATE ${table} SET ${count} = ${count} - 1 WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)} AND ${count} > 0`)
1175
+ }
1176
+
1177
+ /**
1178
+ * Rebuilds durable counts from active handoffs after startup.
1179
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1180
+ * @returns {Promise<void>} - Resolves when reconciled.
1181
+ */
1182
+ async _reconcileConcurrency(db) {
1183
+ if (!(await db.tableExists(CONCURRENCY_TABLE))) return
1184
+ const concurrencyTable = db.quoteTable(CONCURRENCY_TABLE)
1185
+ const jobsTable = db.quoteTable(JOBS_TABLE)
1186
+ await db.query(
1187
+ `UPDATE ${concurrencyTable} SET ${db.quoteColumn("active_count")} = (` +
1188
+ `SELECT COUNT(*) FROM ${jobsTable} WHERE ${jobsTable}.${db.quoteColumn("status")} = ${db.quote("handed_off")} AND ` +
1189
+ `${jobsTable}.${db.quoteColumn("concurrency_key")} = ${concurrencyTable}.${db.quoteColumn("concurrency_key")})`
1190
+ )
871
1191
  }
872
1192
 
873
1193
  /**
@@ -973,25 +1293,22 @@ export default class BackgroundJobsStore {
973
1293
  }
974
1294
 
975
1295
  /**
976
- * Serializes reports for one job so duplicate reports cannot both appear accepted.
1296
+ * Runs a value-returning callback inside the driver's void-typed transaction API.
977
1297
  * @template T
978
- * @param {object} args - Options.
979
- * @param {import("../database/drivers/base.js").default} args.db - Database connection.
980
- * @param {string} args.jobId - Job id.
981
- * @param {() => Promise<T>} callback - Locked callback.
1298
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1299
+ * @param {() => Promise<T>} callback - Transaction callback.
982
1300
  * @returns {Promise<T>} - Callback result.
983
1301
  */
984
- async _withHandoffLock({db, jobId}, callback) {
985
- const lockName = `background-job:${jobId}`
986
- const acquired = await db.acquireAdvisoryLock(lockName)
987
-
988
- if (!acquired) throw new Error(`Failed to acquire background job handoff lock: ${jobId}`)
989
-
990
- try {
991
- return await callback()
992
- } finally {
993
- await db.releaseAdvisoryLock(lockName)
994
- }
1302
+ async _transactionResult(db, callback) {
1303
+ let completed = false
1304
+ /** @type {T | undefined} */
1305
+ let result
1306
+ await db.transaction(async () => {
1307
+ result = await callback()
1308
+ completed = true
1309
+ })
1310
+ if (!completed) throw new Error("Background jobs transaction callback was not invoked")
1311
+ return /** @type {T} */ (result)
995
1312
  }
996
1313
 
997
1314
  /**
@@ -13,6 +13,9 @@
13
13
  * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Defaults to `"forked"`.
14
14
  * @property {boolean} [forked] - Compatibility alias: `false` maps to `"inline"` and `true` maps to `"forked"`.
15
15
  * @property {number} [maxRetries] - Max retries for a failed job before it is marked failed.
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
+ * @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap. Overrides any queue-derived cap.
18
+ * @property {number} [maxConcurrency] - Positive integer cap; must be paired with `concurrencyKey`.
16
19
  */
17
20
  /**
18
21
  * @typedef {object} BackgroundJobPayload
@@ -31,6 +34,7 @@
31
34
  * @property {Array<?>} args - Serialized job arguments.
32
35
  * @property {BackgroundJobExecutionMode} executionMode - How the job should run.
33
36
  * @property {boolean} forked - Compatibility flag; true for non-inline execution.
37
+ * @property {string} queue - Queue name (defaults to `"default"`).
34
38
  * @property {string} status - Current job status.
35
39
  * @property {number | null} attempts - Failure attempts count.
36
40
  * @property {number | null} maxRetries - Max retry attempts.
@@ -43,6 +47,8 @@
43
47
  * @property {number | null} orphanedAtMs - Orphaned time in ms.
44
48
  * @property {string | null} workerId - Worker id handling the job.
45
49
  * @property {string | null} lastError - Last failure message.
50
+ * @property {string | null} concurrencyKey - Durable concurrency key.
51
+ * @property {number | null} maxConcurrency - Durable per-key cap.
46
52
  */
47
53
  /**
48
54
  * @typedef {object} BackgroundJobFailureEvent