velocious 1.0.516 → 1.0.517
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/store.js +222 -39
- package/build/background-jobs/types.js +4 -0
- 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/src/background-jobs/store.d.ts +64 -9
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +224 -40
- package/build/src/background-jobs/types.d.ts +20 -0
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +5 -1
- 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/package.json +1 -1
- package/src/background-jobs/store.js +222 -39
- package/src/background-jobs/types.js +4 -0
- 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/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
|
|
@@ -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
|
/**
|
|
@@ -94,8 +95,10 @@ export default class BackgroundJobsStore {
|
|
|
94
95
|
const executionMode = this._normalizeExecutionMode(options)
|
|
95
96
|
const maxRetries = this._normalizeMaxRetries(options?.maxRetries)
|
|
96
97
|
const argsJson = JSON.stringify(args || [])
|
|
98
|
+
const concurrency = this._normalizeConcurrencyOptions(options)
|
|
97
99
|
|
|
98
100
|
await this._withDb(async (db) => {
|
|
101
|
+
if (concurrency) await this._ensureConcurrencyKey(db, concurrency)
|
|
99
102
|
await db.insert({
|
|
100
103
|
tableName: JOBS_TABLE,
|
|
101
104
|
data: {
|
|
@@ -108,7 +111,9 @@ export default class BackgroundJobsStore {
|
|
|
108
111
|
attempts: 0,
|
|
109
112
|
status: "queued",
|
|
110
113
|
scheduled_at_ms: now,
|
|
111
|
-
created_at_ms: now
|
|
114
|
+
created_at_ms: now,
|
|
115
|
+
concurrency_key: concurrency?.concurrencyKey || null,
|
|
116
|
+
max_concurrency: concurrency?.maxConcurrency || null
|
|
112
117
|
}
|
|
113
118
|
})
|
|
114
119
|
})
|
|
@@ -169,6 +174,17 @@ export default class BackgroundJobsStore {
|
|
|
169
174
|
.where({status: "queued"})
|
|
170
175
|
.where(`scheduled_at_ms ${scheduledAtOperator} ${db.quote(now)}`)
|
|
171
176
|
|
|
177
|
+
if (scheduledAtOperator === "<=") {
|
|
178
|
+
const jobsTable = db.quoteTable(JOBS_TABLE)
|
|
179
|
+
const concurrencyTable = db.quoteTable(CONCURRENCY_TABLE)
|
|
180
|
+
query = query.where(
|
|
181
|
+
`(${jobsTable}.${db.quoteColumn("concurrency_key")} IS NULL OR EXISTS (` +
|
|
182
|
+
`SELECT 1 FROM ${concurrencyTable} WHERE ` +
|
|
183
|
+
`${concurrencyTable}.${db.quoteColumn("concurrency_key")} = ${jobsTable}.${db.quoteColumn("concurrency_key")} AND ` +
|
|
184
|
+
`${concurrencyTable}.${db.quoteColumn("active_count")} < ${concurrencyTable}.${db.quoteColumn("max_concurrency")}))`
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
|
|
172
188
|
if (typeof forked === "boolean") {
|
|
173
189
|
query = query.where({forked})
|
|
174
190
|
}
|
|
@@ -314,8 +330,12 @@ export default class BackgroundJobsStore {
|
|
|
314
330
|
const handedOffAtMs = Date.now()
|
|
315
331
|
const handoffId = randomUUID()
|
|
316
332
|
|
|
317
|
-
return await this._withDb(async (db) => {
|
|
318
|
-
await
|
|
333
|
+
return await this._withDb(async (db) => await this._transactionResult(db, async () => {
|
|
334
|
+
const queuedJob = await this._getJobRowById(db, jobId)
|
|
335
|
+
if (!queuedJob || queuedJob.status !== "queued") return null
|
|
336
|
+
if (queuedJob.concurrencyKey && !(await this._reserveConcurrency(db, queuedJob.concurrencyKey))) return null
|
|
337
|
+
|
|
338
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
319
339
|
tableName: JOBS_TABLE,
|
|
320
340
|
data: {
|
|
321
341
|
status: "handed_off",
|
|
@@ -326,12 +346,13 @@ export default class BackgroundJobsStore {
|
|
|
326
346
|
conditions: {id: jobId, status: "queued"}
|
|
327
347
|
})
|
|
328
348
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
349
|
+
if (affectedRows !== 1) {
|
|
350
|
+
await this._releaseConcurrency(db, queuedJob.concurrencyKey)
|
|
351
|
+
return null
|
|
352
|
+
}
|
|
332
353
|
|
|
333
354
|
return {handedOffAtMs, handoffId}
|
|
334
|
-
})
|
|
355
|
+
}))
|
|
335
356
|
}
|
|
336
357
|
|
|
337
358
|
/**
|
|
@@ -346,13 +367,13 @@ export default class BackgroundJobsStore {
|
|
|
346
367
|
async markCompleted({jobId, handoffId, workerId, handedOffAtMs}) {
|
|
347
368
|
await this.ensureReady()
|
|
348
369
|
|
|
349
|
-
return await this._withDb(async (db) => await this.
|
|
370
|
+
return await this._withDb(async (db) => await this._transactionResult(db, async () => {
|
|
350
371
|
const job = await this._getJobRowById(db, jobId)
|
|
351
372
|
|
|
352
373
|
if (!job) return false
|
|
353
374
|
if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return false
|
|
354
375
|
|
|
355
|
-
await
|
|
376
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
356
377
|
tableName: JOBS_TABLE,
|
|
357
378
|
data: {
|
|
358
379
|
status: "completed",
|
|
@@ -361,9 +382,9 @@ export default class BackgroundJobsStore {
|
|
|
361
382
|
conditions: this._activeHandoffConditions(job)
|
|
362
383
|
})
|
|
363
384
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
return
|
|
385
|
+
if (affectedRows !== 1) return false
|
|
386
|
+
await this._releaseConcurrency(db, job.concurrencyKey)
|
|
387
|
+
return true
|
|
367
388
|
}))
|
|
368
389
|
}
|
|
369
390
|
|
|
@@ -377,8 +398,10 @@ export default class BackgroundJobsStore {
|
|
|
377
398
|
async markReturnedToQueue({jobId, handoffId}) {
|
|
378
399
|
await this.ensureReady()
|
|
379
400
|
|
|
380
|
-
await this._withDb(async (db) => {
|
|
381
|
-
await
|
|
401
|
+
await this._withDb(async (db) => await db.transaction(async () => {
|
|
402
|
+
const job = await this._getJobRowById(db, jobId)
|
|
403
|
+
if (!job || job.handoffId !== handoffId || job.status !== "handed_off") return
|
|
404
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
382
405
|
tableName: JOBS_TABLE,
|
|
383
406
|
data: {
|
|
384
407
|
status: "queued",
|
|
@@ -389,7 +412,8 @@ export default class BackgroundJobsStore {
|
|
|
389
412
|
},
|
|
390
413
|
conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"}
|
|
391
414
|
})
|
|
392
|
-
|
|
415
|
+
if (affectedRows === 1) await this._releaseConcurrency(db, job.concurrencyKey)
|
|
416
|
+
}))
|
|
393
417
|
}
|
|
394
418
|
|
|
395
419
|
/**
|
|
@@ -405,13 +429,14 @@ export default class BackgroundJobsStore {
|
|
|
405
429
|
async markFailed({jobId, error, handoffId, workerId, handedOffAtMs}) {
|
|
406
430
|
await this.ensureReady()
|
|
407
431
|
|
|
408
|
-
return await this._withDb(async (db) => await this.
|
|
432
|
+
return await this._withDb(async (db) => await this._transactionResult(db, async () => {
|
|
409
433
|
const job = await this._getJobRowById(db, jobId)
|
|
410
434
|
|
|
411
435
|
if (!job) return null
|
|
412
436
|
if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return null
|
|
413
437
|
|
|
414
|
-
|
|
438
|
+
const updatedJob = await this._applyFailure({db, job, error, markOrphaned: false})
|
|
439
|
+
return updatedJob
|
|
415
440
|
}))
|
|
416
441
|
}
|
|
417
442
|
|
|
@@ -462,9 +487,27 @@ export default class BackgroundJobsStore {
|
|
|
462
487
|
|
|
463
488
|
await this._withDb(async (db) => {
|
|
464
489
|
await db.query(`DELETE FROM ${db.quoteTable(JOBS_TABLE)}`)
|
|
490
|
+
if (await db.tableExists(CONCURRENCY_TABLE)) await db.query(`DELETE FROM ${db.quoteTable(CONCURRENCY_TABLE)}`)
|
|
465
491
|
})
|
|
466
492
|
}
|
|
467
493
|
|
|
494
|
+
/**
|
|
495
|
+
* Cancels a queued or handed-off job and releases any durable concurrency reservation.
|
|
496
|
+
* @param {string} jobId - Job id.
|
|
497
|
+
* @returns {Promise<boolean>} - Whether the job was cancelled.
|
|
498
|
+
*/
|
|
499
|
+
async cancel(jobId) {
|
|
500
|
+
await this.ensureReady()
|
|
501
|
+
return await this._withDb(async (db) => await this._transactionResult(db, async () => {
|
|
502
|
+
const job = await this._getJobRowById(db, jobId)
|
|
503
|
+
if (!job || (job.status !== "queued" && job.status !== "handed_off")) return false
|
|
504
|
+
const affectedRows = await this._updateAffectedRows(db, {tableName: JOBS_TABLE, data: {status: "cancelled"}, conditions: {id: job.id, status: job.status}})
|
|
505
|
+
if (affectedRows !== 1) return false
|
|
506
|
+
if (job.status === "handed_off") await this._releaseConcurrency(db, job.concurrencyKey)
|
|
507
|
+
return true
|
|
508
|
+
}))
|
|
509
|
+
}
|
|
510
|
+
|
|
468
511
|
/**
|
|
469
512
|
* Runs get retry delay ms.
|
|
470
513
|
* @param {number} retryCount - Retry attempt count (1-based).
|
|
@@ -506,11 +549,15 @@ export default class BackgroundJobsStore {
|
|
|
506
549
|
// row alone, otherwise later callers fail with "no such table".
|
|
507
550
|
if (alreadyApplied && await db.tableExists(JOBS_TABLE)) {
|
|
508
551
|
await this._ensureJobsTableColumns(db)
|
|
552
|
+
await this._ensureConcurrencyTable(db)
|
|
553
|
+
await this._reconcileConcurrency(db)
|
|
509
554
|
return
|
|
510
555
|
}
|
|
511
556
|
|
|
512
557
|
await this._applyMigrations(db)
|
|
513
558
|
await this._ensureJobsTableColumns(db)
|
|
559
|
+
await this._ensureConcurrencyTable(db)
|
|
560
|
+
await this._reconcileConcurrency(db)
|
|
514
561
|
|
|
515
562
|
if (alreadyApplied) return
|
|
516
563
|
|
|
@@ -586,6 +633,8 @@ export default class BackgroundJobsStore {
|
|
|
586
633
|
table.bigint("orphaned_at_ms", {null: true, index: true})
|
|
587
634
|
table.string("worker_id", {null: true})
|
|
588
635
|
table.text("last_error", {null: true})
|
|
636
|
+
table.string("concurrency_key", {null: true, index: true})
|
|
637
|
+
table.integer("max_concurrency", {null: true})
|
|
589
638
|
|
|
590
639
|
await db.createTable(table)
|
|
591
640
|
}
|
|
@@ -643,6 +692,37 @@ export default class BackgroundJobsStore {
|
|
|
643
692
|
}
|
|
644
693
|
|
|
645
694
|
await this._backfillExecutionModesOnce(db)
|
|
695
|
+
|
|
696
|
+
const lockName = `${MIGRATION_SCOPE}:concurrency_columns`
|
|
697
|
+
const acquired = await db.acquireAdvisoryLock(lockName)
|
|
698
|
+
|
|
699
|
+
if (!acquired) throw new Error("Failed to acquire background jobs concurrency schema lock")
|
|
700
|
+
|
|
701
|
+
try {
|
|
702
|
+
// SQL Server schema reads can deadlock with a concurrent ALTER TABLE, so
|
|
703
|
+
// acquire the lock before inspecting either column rather than only
|
|
704
|
+
// protecting the mutation.
|
|
705
|
+
db.clearSchemaCache()
|
|
706
|
+
const lockedTable = await db.getTableByNameOrFail(JOBS_TABLE)
|
|
707
|
+
const concurrencyColumnNames = ["concurrency_key", "max_concurrency"]
|
|
708
|
+
|
|
709
|
+
for (const concurrencyColumnName of concurrencyColumnNames) {
|
|
710
|
+
if (await lockedTable.getColumnByName(concurrencyColumnName)) continue
|
|
711
|
+
|
|
712
|
+
const tableData = new TableData(JOBS_TABLE)
|
|
713
|
+
if (concurrencyColumnName == "concurrency_key") {
|
|
714
|
+
tableData.string("concurrency_key", {null: true, index: true})
|
|
715
|
+
} else {
|
|
716
|
+
tableData.integer("max_concurrency", {null: true})
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
for (const sql of await db.alterTableSQLs(tableData)) await db.query(sql)
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
db.clearSchemaCache()
|
|
723
|
+
} finally {
|
|
724
|
+
await db.releaseAdvisoryLock(lockName)
|
|
725
|
+
}
|
|
646
726
|
}
|
|
647
727
|
|
|
648
728
|
/**
|
|
@@ -757,19 +837,18 @@ export default class BackgroundJobsStore {
|
|
|
757
837
|
shouldRetry
|
|
758
838
|
})
|
|
759
839
|
|
|
760
|
-
await
|
|
840
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
761
841
|
tableName: JOBS_TABLE,
|
|
762
842
|
data: update,
|
|
763
843
|
conditions: this._activeHandoffConditions(job)
|
|
764
844
|
})
|
|
765
845
|
|
|
846
|
+
if (affectedRows !== 1) return null
|
|
847
|
+
await this._releaseConcurrency(db, job.concurrencyKey)
|
|
848
|
+
|
|
766
849
|
const updatedJob = await this._getJobRowById(db, job.id)
|
|
767
850
|
|
|
768
851
|
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
852
|
return updatedJob
|
|
774
853
|
}
|
|
775
854
|
|
|
@@ -866,8 +945,115 @@ export default class BackgroundJobsStore {
|
|
|
866
945
|
failedAtMs: this._normalizeNumber(row.failed_at_ms),
|
|
867
946
|
orphanedAtMs: this._normalizeNumber(row.orphaned_at_ms),
|
|
868
947
|
workerId: row.worker_id ? String(row.worker_id) : null,
|
|
869
|
-
lastError: row.last_error ? String(row.last_error) : null
|
|
948
|
+
lastError: row.last_error ? String(row.last_error) : null,
|
|
949
|
+
concurrencyKey: row.concurrency_key ? String(row.concurrency_key) : null,
|
|
950
|
+
maxConcurrency: this._normalizeNumber(row.max_concurrency)
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* Validates concurrency options.
|
|
956
|
+
* @param {import("./types.js").BackgroundJobOptions | undefined} options - Job options.
|
|
957
|
+
* @returns {{concurrencyKey: string, maxConcurrency: number} | null} - Normalized configuration.
|
|
958
|
+
*/
|
|
959
|
+
_normalizeConcurrencyOptions(options) {
|
|
960
|
+
const key = options?.concurrencyKey
|
|
961
|
+
const cap = options?.maxConcurrency
|
|
962
|
+
if (key === undefined && cap === undefined) return null
|
|
963
|
+
if (typeof key !== "string" || key.length === 0 || !Number.isInteger(cap) || Number(cap) <= 0) {
|
|
964
|
+
throw new Error("background job concurrencyKey and maxConcurrency must be paired; concurrencyKey must be non-empty and maxConcurrency must be a positive integer")
|
|
965
|
+
}
|
|
966
|
+
return {concurrencyKey: key, maxConcurrency: Number(cap)}
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Ensures the concurrency state table exists.
|
|
971
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
972
|
+
* @returns {Promise<void>} - Resolves when ready.
|
|
973
|
+
*/
|
|
974
|
+
async _ensureConcurrencyTable(db) {
|
|
975
|
+
if (await db.tableExists(CONCURRENCY_TABLE)) return
|
|
976
|
+
const table = new TableData(CONCURRENCY_TABLE, {ifNotExists: true})
|
|
977
|
+
table.string("concurrency_key", {primaryKey: true})
|
|
978
|
+
table.integer("max_concurrency", {null: false})
|
|
979
|
+
table.integer("active_count", {null: false})
|
|
980
|
+
await db.createTable(table)
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Registers or verifies a stable key configuration.
|
|
985
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
986
|
+
* @param {object} concurrency - Concurrency configuration.
|
|
987
|
+
* @param {string} concurrency.concurrencyKey - Concurrency key.
|
|
988
|
+
* @param {number} concurrency.maxConcurrency - Stable cap.
|
|
989
|
+
* @returns {Promise<void>} - Resolves when verified.
|
|
990
|
+
*/
|
|
991
|
+
async _ensureConcurrencyKey(db, {concurrencyKey, maxConcurrency}) {
|
|
992
|
+
const rows = await db.newQuery().from(CONCURRENCY_TABLE).where({concurrency_key: concurrencyKey}).limit(1).results()
|
|
993
|
+
if (!rows[0]) {
|
|
994
|
+
try {
|
|
995
|
+
await db.insert({tableName: CONCURRENCY_TABLE, data: {active_count: 0, concurrency_key: concurrencyKey, max_concurrency: maxConcurrency}})
|
|
996
|
+
return
|
|
997
|
+
} catch (error) {
|
|
998
|
+
const racedRows = await db.newQuery().from(CONCURRENCY_TABLE).where({concurrency_key: concurrencyKey}).limit(1).results()
|
|
999
|
+
if (!racedRows[0]) throw error
|
|
1000
|
+
rows[0] = racedRows[0]
|
|
1001
|
+
}
|
|
870
1002
|
}
|
|
1003
|
+
const configured = /** @type {{max_concurrency?: number | string}} */ (rows[0])
|
|
1004
|
+
if (this._normalizeNumber(configured.max_concurrency) !== maxConcurrency) throw new Error(`Conflicting maxConcurrency for background job concurrencyKey: ${concurrencyKey}`)
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* Atomically reserves capacity for a key.
|
|
1009
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
1010
|
+
* @param {string} concurrencyKey - Concurrency key.
|
|
1011
|
+
* @returns {Promise<boolean>} - Whether capacity was reserved.
|
|
1012
|
+
*/
|
|
1013
|
+
async _reserveConcurrency(db, concurrencyKey) {
|
|
1014
|
+
const table = db.quoteTable(CONCURRENCY_TABLE)
|
|
1015
|
+
const count = db.quoteColumn("active_count")
|
|
1016
|
+
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")}`)
|
|
1017
|
+
return affectedRows === 1
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
/**
|
|
1021
|
+
* Runs a portable update and returns its affected-row count.
|
|
1022
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
1023
|
+
* @param {import("../database/drivers/base.js").UpdateSqlArgsType} args - Update options.
|
|
1024
|
+
* @returns {Promise<number>} - Affected row count.
|
|
1025
|
+
*/
|
|
1026
|
+
async _updateAffectedRows(db, args) {
|
|
1027
|
+
return await db.affectedRows(db.updateSql(args))
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* Releases capacity for a key.
|
|
1032
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
1033
|
+
* @param {string | null} concurrencyKey - Concurrency key.
|
|
1034
|
+
* @returns {Promise<void>} - Resolves when released.
|
|
1035
|
+
*/
|
|
1036
|
+
async _releaseConcurrency(db, concurrencyKey) {
|
|
1037
|
+
if (!concurrencyKey) return
|
|
1038
|
+
const table = db.quoteTable(CONCURRENCY_TABLE)
|
|
1039
|
+
const count = db.quoteColumn("active_count")
|
|
1040
|
+
await db.query(`UPDATE ${table} SET ${count} = ${count} - 1 WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)} AND ${count} > 0`)
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
/**
|
|
1044
|
+
* Rebuilds durable counts from active handoffs after startup.
|
|
1045
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
1046
|
+
* @returns {Promise<void>} - Resolves when reconciled.
|
|
1047
|
+
*/
|
|
1048
|
+
async _reconcileConcurrency(db) {
|
|
1049
|
+
if (!(await db.tableExists(CONCURRENCY_TABLE))) return
|
|
1050
|
+
const concurrencyTable = db.quoteTable(CONCURRENCY_TABLE)
|
|
1051
|
+
const jobsTable = db.quoteTable(JOBS_TABLE)
|
|
1052
|
+
await db.query(
|
|
1053
|
+
`UPDATE ${concurrencyTable} SET ${db.quoteColumn("active_count")} = (` +
|
|
1054
|
+
`SELECT COUNT(*) FROM ${jobsTable} WHERE ${jobsTable}.${db.quoteColumn("status")} = ${db.quote("handed_off")} AND ` +
|
|
1055
|
+
`${jobsTable}.${db.quoteColumn("concurrency_key")} = ${concurrencyTable}.${db.quoteColumn("concurrency_key")})`
|
|
1056
|
+
)
|
|
871
1057
|
}
|
|
872
1058
|
|
|
873
1059
|
/**
|
|
@@ -973,25 +1159,22 @@ export default class BackgroundJobsStore {
|
|
|
973
1159
|
}
|
|
974
1160
|
|
|
975
1161
|
/**
|
|
976
|
-
*
|
|
1162
|
+
* Runs a value-returning callback inside the driver's void-typed transaction API.
|
|
977
1163
|
* @template T
|
|
978
|
-
* @param {
|
|
979
|
-
* @param {
|
|
980
|
-
* @param {string} args.jobId - Job id.
|
|
981
|
-
* @param {() => Promise<T>} callback - Locked callback.
|
|
1164
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
1165
|
+
* @param {() => Promise<T>} callback - Transaction callback.
|
|
982
1166
|
* @returns {Promise<T>} - Callback result.
|
|
983
1167
|
*/
|
|
984
|
-
async
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
}
|
|
1168
|
+
async _transactionResult(db, callback) {
|
|
1169
|
+
let completed = false
|
|
1170
|
+
/** @type {T | undefined} */
|
|
1171
|
+
let result
|
|
1172
|
+
await db.transaction(async () => {
|
|
1173
|
+
result = await callback()
|
|
1174
|
+
completed = true
|
|
1175
|
+
})
|
|
1176
|
+
if (!completed) throw new Error("Background jobs transaction callback was not invoked")
|
|
1177
|
+
return /** @type {T} */ (result)
|
|
995
1178
|
}
|
|
996
1179
|
|
|
997
1180
|
/**
|
|
@@ -13,6 +13,8 @@
|
|
|
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} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap.
|
|
17
|
+
* @property {number} [maxConcurrency] - Positive integer cap; must be paired with `concurrencyKey`.
|
|
16
18
|
*/
|
|
17
19
|
/**
|
|
18
20
|
* @typedef {object} BackgroundJobPayload
|
|
@@ -43,6 +45,8 @@
|
|
|
43
45
|
* @property {number | null} orphanedAtMs - Orphaned time in ms.
|
|
44
46
|
* @property {string | null} workerId - Worker id handling the job.
|
|
45
47
|
* @property {string | null} lastError - Last failure message.
|
|
48
|
+
* @property {string | null} concurrencyKey - Durable concurrency key.
|
|
49
|
+
* @property {number | null} maxConcurrency - Durable per-key cap.
|
|
46
50
|
*/
|
|
47
51
|
/**
|
|
48
52
|
* @typedef {object} BackgroundJobFailureEvent
|
|
@@ -1050,6 +1050,22 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1050
1050
|
throw new Error("'query' unexpected came here")
|
|
1051
1051
|
}
|
|
1052
1052
|
|
|
1053
|
+
/**
|
|
1054
|
+
* Executes a mutation and returns the number of rows changed by that statement.
|
|
1055
|
+
* @param {string} sql - Mutation SQL string.
|
|
1056
|
+
* @returns {Promise<number>} - Affected row count.
|
|
1057
|
+
*/
|
|
1058
|
+
async affectedRows(sql) {
|
|
1059
|
+
this._assertWritableQuery(sql)
|
|
1060
|
+
await this.beforeQuery(sql, {})
|
|
1061
|
+
|
|
1062
|
+
try {
|
|
1063
|
+
return await this._affectedRowsActual(sql)
|
|
1064
|
+
} finally {
|
|
1065
|
+
await this.afterQuery(sql, {})
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1053
1069
|
/**
|
|
1054
1070
|
* Runs query actual with logging.
|
|
1055
1071
|
* @param {object} args - Options object.
|
|
@@ -1308,6 +1324,16 @@ export default class VelociousDatabaseDriversBase {
|
|
|
1308
1324
|
throw new Error(`queryActual not implemented`)
|
|
1309
1325
|
}
|
|
1310
1326
|
|
|
1327
|
+
/**
|
|
1328
|
+
* Executes a mutation and returns its affected row count.
|
|
1329
|
+
* @abstract
|
|
1330
|
+
* @param {string} sql - Mutation SQL string.
|
|
1331
|
+
* @returns {Promise<number>} - Affected row count.
|
|
1332
|
+
*/
|
|
1333
|
+
_affectedRowsActual(sql) { // eslint-disable-line no-unused-vars
|
|
1334
|
+
throw new Error(`affectedRowsActual not implemented`)
|
|
1335
|
+
}
|
|
1336
|
+
|
|
1311
1337
|
/**
|
|
1312
1338
|
* Runs query to sql.
|
|
1313
1339
|
* @abstract
|
|
@@ -328,6 +328,19 @@ export default class VelociousDatabaseDriversMssql extends Base{
|
|
|
328
328
|
return Array.isArray(result.recordsets) ? result.recordsets[0] || [] : []
|
|
329
329
|
}
|
|
330
330
|
|
|
331
|
+
/**
|
|
332
|
+
* Executes a mutation with affected-row metadata.
|
|
333
|
+
* @param {string} sql - Mutation SQL.
|
|
334
|
+
* @returns {Promise<number>} - Affected row count.
|
|
335
|
+
*/
|
|
336
|
+
async _affectedRowsActual(sql) {
|
|
337
|
+
const request = this._currentTransaction
|
|
338
|
+
? new mssql.Request(this._currentTransaction)
|
|
339
|
+
: new mssql.Request(this.connection)
|
|
340
|
+
const result = await request.query(sql)
|
|
341
|
+
return result.rowsAffected.reduce((total, count) => total + count, 0)
|
|
342
|
+
}
|
|
343
|
+
|
|
331
344
|
/**
|
|
332
345
|
* Runs query to sql.
|
|
333
346
|
* @param {import("../../query/index.js").default} query - Query instance.
|
|
@@ -344,6 +344,24 @@ export default class VelociousDatabaseDriversMysql extends Base{
|
|
|
344
344
|
}
|
|
345
345
|
}
|
|
346
346
|
|
|
347
|
+
/**
|
|
348
|
+
* Executes a mutation with affected-row metadata.
|
|
349
|
+
* @param {string} sql - Mutation SQL.
|
|
350
|
+
* @returns {Promise<number>} - Affected row count.
|
|
351
|
+
*/
|
|
352
|
+
async _affectedRowsActual(sql) {
|
|
353
|
+
if (!this.pool) await this.connect()
|
|
354
|
+
if (!this.pool) throw new Error("MySQL pool failed to initialize")
|
|
355
|
+
const pool = this.pool
|
|
356
|
+
|
|
357
|
+
return await new Promise((resolve, reject) => {
|
|
358
|
+
pool.query(sql, (error, result) => {
|
|
359
|
+
if (error) reject(error)
|
|
360
|
+
else resolve("affectedRows" in result ? result.affectedRows : 0)
|
|
361
|
+
})
|
|
362
|
+
})
|
|
363
|
+
}
|
|
364
|
+
|
|
347
365
|
/**
|
|
348
366
|
* Runs query to sql.
|
|
349
367
|
* @param {import("../../query/index.js").default} query - Query instance.
|
|
@@ -227,6 +227,18 @@ export default class VelociousDatabaseDriversPgsql extends Base{
|
|
|
227
227
|
return response.rows
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
+
/**
|
|
231
|
+
* Executes a mutation with affected-row metadata.
|
|
232
|
+
* @param {string} sql - Mutation SQL.
|
|
233
|
+
* @returns {Promise<number>} - Affected row count.
|
|
234
|
+
*/
|
|
235
|
+
async _affectedRowsActual(sql) {
|
|
236
|
+
if (!this.connection) await this.connect()
|
|
237
|
+
if (!this.connection) throw new Error("PostgreSQL connection failed to initialize")
|
|
238
|
+
const response = await this.connection.query(sql)
|
|
239
|
+
return response.rowCount || 0
|
|
240
|
+
}
|
|
241
|
+
|
|
230
242
|
/**
|
|
231
243
|
* Runs query to sql.
|
|
232
244
|
* @param {import("../../query/index.js").default} query - Query instance.
|
|
@@ -47,6 +47,17 @@ export default class VelociousDatabaseDriversSqliteConnectionSqlJs {
|
|
|
47
47
|
return result
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Executes a mutation with affected-row metadata.
|
|
52
|
+
* @param {string} sql - Mutation SQL.
|
|
53
|
+
* @returns {Promise<number>} - Affected row count.
|
|
54
|
+
*/
|
|
55
|
+
async affectedRows(sql) {
|
|
56
|
+
await this.query(sql)
|
|
57
|
+
const connection = /** @type {import("sql.js").Database & {getRowsModified: () => number}} */ (this.connection)
|
|
58
|
+
return connection.getRowsModified()
|
|
59
|
+
}
|
|
60
|
+
|
|
50
61
|
saveDatabase = async () => {
|
|
51
62
|
const databaseContent = this.connection.export()
|
|
52
63
|
|
|
@@ -15,7 +15,7 @@ import fileExists from "../../../utils/file-exists.js"
|
|
|
15
15
|
export default class VelociousDatabaseDriversSqliteNode extends Base {
|
|
16
16
|
/**
|
|
17
17
|
* Connection.
|
|
18
|
-
* @type {import("
|
|
18
|
+
* @type {import("sqlite").Database | undefined} */
|
|
19
19
|
connection = undefined
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -39,11 +39,10 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
|
|
|
39
39
|
this._advisoryLockDirectory = path.join(databaseDir, `${this.localStorageName()}.velocious-advisory-locks`)
|
|
40
40
|
|
|
41
41
|
try {
|
|
42
|
-
|
|
43
|
-
this.connection = /** @type {import("sqlite3").Database} */ (await open({
|
|
42
|
+
this.connection = await open({
|
|
44
43
|
filename: databasePath,
|
|
45
44
|
driver: sqlite3.Database
|
|
46
|
-
})
|
|
45
|
+
})
|
|
47
46
|
} catch (error) {
|
|
48
47
|
if (error instanceof Error) {
|
|
49
48
|
throw new Error(`Couldn't open database ${databasePath} because of ${error.constructor.name}: ${error.message}`, {cause: error})
|
|
@@ -79,6 +78,17 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
|
|
|
79
78
|
return await query(this.connection, sql)
|
|
80
79
|
}
|
|
81
80
|
|
|
81
|
+
/**
|
|
82
|
+
* Executes a mutation with affected-row metadata.
|
|
83
|
+
* @param {string} sql - Mutation SQL.
|
|
84
|
+
* @returns {Promise<number>} - Affected row count.
|
|
85
|
+
*/
|
|
86
|
+
async _affectedRowsActual(sql) {
|
|
87
|
+
if (!this.connection) throw new Error("No connection")
|
|
88
|
+
const result = await this.connection.run(sql)
|
|
89
|
+
return result.changes || 0
|
|
90
|
+
}
|
|
91
|
+
|
|
82
92
|
/**
|
|
83
93
|
* Layers a filesystem lock directory on top of the in-process waiter
|
|
84
94
|
* queue so SQLite deployments with multiple Node processes writing to
|
|
@@ -81,4 +81,17 @@ export default class VelociousDatabaseDriversSqliteNative extends Base {
|
|
|
81
81
|
return query(this.connection, sql)
|
|
82
82
|
})
|
|
83
83
|
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Executes a mutation with affected-row metadata.
|
|
87
|
+
* @param {string} sql - Mutation SQL.
|
|
88
|
+
* @returns {Promise<number>} - Affected row count.
|
|
89
|
+
*/
|
|
90
|
+
async _affectedRowsActual(sql) {
|
|
91
|
+
return await this._queryMutex.sync(async () => {
|
|
92
|
+
if (!this.connection) throw new Error("Not connected yet")
|
|
93
|
+
const result = await this.connection.runAsync(sql)
|
|
94
|
+
return result.changes
|
|
95
|
+
})
|
|
96
|
+
}
|
|
84
97
|
}
|
|
@@ -8,7 +8,7 @@ import Base from "./base.js"
|
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
10
|
* VelociousDatabaseDriversSqliteWeb class.
|
|
11
|
-
* @typedef {{query: (sql: string) => Promise<Record<string, ?>[]>, close: () => Promise<void>}} SqliteWebConnection
|
|
11
|
+
* @typedef {{query: (sql: string) => Promise<Record<string, ?>[]>, affectedRows: (sql: string) => Promise<number>, close: () => Promise<void>}} SqliteWebConnection
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
export default class VelociousDatabaseDriversSqliteWeb extends Base {
|
|
@@ -110,4 +110,14 @@ export default class VelociousDatabaseDriversSqliteWeb extends Base {
|
|
|
110
110
|
|
|
111
111
|
return result
|
|
112
112
|
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Executes a mutation with affected-row metadata.
|
|
116
|
+
* @param {string} sql - Mutation SQL.
|
|
117
|
+
* @returns {Promise<number>} - Affected row count.
|
|
118
|
+
*/
|
|
119
|
+
async _affectedRowsActual(sql) {
|
|
120
|
+
const connection = this.getConnection()
|
|
121
|
+
return await connection.affectedRows(sql)
|
|
122
|
+
}
|
|
113
123
|
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Runs query.
|
|
5
|
-
* @param {import("
|
|
5
|
+
* @param {import("sqlite").Database} connection - Connection.
|
|
6
6
|
* @param {string} sql - SQL string.
|
|
7
7
|
* @returns {Promise<Record<string, ?>[]>} - Resolves with string value.
|
|
8
8
|
*/
|
|
@@ -13,7 +13,6 @@ export default async function query(connection, sql) {
|
|
|
13
13
|
* @type {Record<string, ?>[]} */
|
|
14
14
|
let result
|
|
15
15
|
|
|
16
|
-
// @ts-expect-error
|
|
17
16
|
result = await connection.all(sql)
|
|
18
17
|
|
|
19
18
|
return result
|