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.
- package/README.md +2 -0
- package/build/background-jobs/job.js +27 -2
- package/build/background-jobs/store.js +356 -39
- package/build/background-jobs/types.js +6 -0
- package/build/configuration-types.js +12 -1
- package/build/configuration.js +2 -1
- package/build/database/drivers/base.js +26 -0
- package/build/database/drivers/mssql/index.js +13 -0
- package/build/database/drivers/mysql/index.js +18 -0
- package/build/database/drivers/pgsql/index.js +12 -0
- package/build/database/drivers/sqlite/connection-sql-js.js +11 -0
- package/build/database/drivers/sqlite/index.js +14 -4
- package/build/database/drivers/sqlite/index.native.js +13 -0
- package/build/database/drivers/sqlite/index.web.js +11 -1
- package/build/database/drivers/sqlite/query.js +1 -2
- package/build/http-server/client/request-buffer/index.js +151 -89
- package/build/src/background-jobs/job.d.ts +15 -0
- package/build/src/background-jobs/job.d.ts.map +1 -1
- package/build/src/background-jobs/job.js +24 -3
- package/build/src/background-jobs/store.d.ts +111 -9
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +339 -40
- package/build/src/background-jobs/types.d.ts +30 -0
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +7 -1
- package/build/src/configuration-types.d.ts +29 -2
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +13 -2
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +3 -2
- package/build/src/database/drivers/base.d.ts +13 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +25 -1
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +13 -1
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +21 -1
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +14 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.d.ts +6 -0
- package/build/src/database/drivers/sqlite/connection-sql-js.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/connection-sql-js.js +11 -1
- package/build/src/database/drivers/sqlite/index.d.ts +2 -2
- package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.js +15 -5
- package/build/src/database/drivers/sqlite/index.native.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.native.js +14 -1
- package/build/src/database/drivers/sqlite/index.web.d.ts +2 -1
- package/build/src/database/drivers/sqlite/index.web.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.web.js +11 -2
- package/build/src/database/drivers/sqlite/query.d.ts +2 -2
- package/build/src/database/drivers/sqlite/query.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/query.js +2 -3
- package/build/src/http-server/client/request-buffer/index.d.ts +23 -2
- package/build/src/http-server/client/request-buffer/index.d.ts.map +1 -1
- package/build/src/http-server/client/request-buffer/index.js +141 -86
- package/package.json +2 -2
- package/src/background-jobs/job.js +27 -2
- package/src/background-jobs/store.js +356 -39
- package/src/background-jobs/types.js +6 -0
- package/src/configuration-types.js +12 -1
- package/src/configuration.js +2 -1
- package/src/database/drivers/base.js +26 -0
- package/src/database/drivers/mssql/index.js +13 -0
- package/src/database/drivers/mysql/index.js +18 -0
- package/src/database/drivers/pgsql/index.js +12 -0
- package/src/database/drivers/sqlite/connection-sql-js.js +11 -0
- package/src/database/drivers/sqlite/index.js +14 -4
- package/src/database/drivers/sqlite/index.native.js +13 -0
- package/src/database/drivers/sqlite/index.web.js +11 -1
- package/src/database/drivers/sqlite/query.js +1 -2
- 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
|
|
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
|
-
|
|
330
|
-
|
|
331
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
return
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
1296
|
+
* Runs a value-returning callback inside the driver's void-typed transaction API.
|
|
977
1297
|
* @template T
|
|
978
|
-
* @param {
|
|
979
|
-
* @param {
|
|
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
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
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
|