velocious 1.0.538 → 1.0.540
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/build/background-jobs/scheduler.js +6 -1
- package/build/background-jobs/store.js +34 -2
- package/build/background-jobs/types.js +1 -1
- package/build/configuration-types.js +3 -0
- package/build/database/drivers/base.js +29 -3
- package/build/src/background-jobs/scheduler.d.ts.map +1 -1
- package/build/src/background-jobs/scheduler.js +7 -2
- package/build/src/background-jobs/store.d.ts +16 -0
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +36 -3
- package/build/src/background-jobs/types.d.ts +2 -2
- package/build/src/background-jobs/types.js +2 -2
- package/build/src/configuration-types.d.ts +15 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +4 -1
- package/build/src/database/drivers/base.d.ts +7 -0
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +28 -4
- package/package.json +1 -1
- package/src/background-jobs/scheduler.js +6 -1
- package/src/background-jobs/store.js +34 -2
- package/src/background-jobs/types.js +1 -1
- package/src/configuration-types.js +3 -0
- package/src/database/drivers/base.js +29 -3
package/package.json
CHANGED
|
@@ -261,11 +261,16 @@ export default class BackgroundJobsScheduler {
|
|
|
261
261
|
*/
|
|
262
262
|
async enqueueScheduledJob({jobConfiguration, jobKey}) {
|
|
263
263
|
try {
|
|
264
|
+
// De-duplicate scheduled enqueues by default: a periodic job still pending from an earlier
|
|
265
|
+
// tick is not enqueued again, which is what let the background_jobs table fill with thousands
|
|
266
|
+
// of identical scheduled jobs when the queue backed up. Dedup is by job identity (see the
|
|
267
|
+
// store), so the job keeps its queue-derived concurrency cap. A schedule can opt out with
|
|
268
|
+
// `deduplicateWhileQueued: false`.
|
|
264
269
|
await this.enqueueJob({
|
|
265
270
|
args: Array.isArray(jobConfiguration.args) ? jobConfiguration.args : [],
|
|
266
271
|
jobClass: jobConfiguration.class,
|
|
267
272
|
jobKey,
|
|
268
|
-
options: jobConfiguration.options || {}
|
|
273
|
+
options: {deduplicateWhileQueued: true, ...(jobConfiguration.options || {})}
|
|
269
274
|
})
|
|
270
275
|
} catch (error) {
|
|
271
276
|
await this.logger.error(() => ["Failed to enqueue scheduled background job", {jobKey, jobName: jobConfiguration.class.jobName()}, error])
|
|
@@ -144,12 +144,16 @@ export default class BackgroundJobsStore {
|
|
|
144
144
|
let resultJobId = jobId
|
|
145
145
|
|
|
146
146
|
await this._withDb(async (db) => {
|
|
147
|
-
if (options?.deduplicateWhileQueued
|
|
147
|
+
if (options?.deduplicateWhileQueued) {
|
|
148
|
+
// Dedupe on the job's identity (name + args + queue), NOT its concurrency key, so a job
|
|
149
|
+
// keeps whatever concurrency it resolves to — in particular a scheduled job that relies on
|
|
150
|
+
// its queue-derived `queue:<name>` cap still participates in that cluster-wide cap instead
|
|
151
|
+
// of being pulled onto a private per-job key.
|
|
148
152
|
const existing = await db
|
|
149
153
|
.newQuery()
|
|
150
154
|
.from(JOBS_TABLE)
|
|
151
155
|
.select("id")
|
|
152
|
-
.where({status: "queued",
|
|
156
|
+
.where({status: "queued", job_name: jobName, args_json: argsJson, queue})
|
|
153
157
|
.limit(1)
|
|
154
158
|
.results()
|
|
155
159
|
|
|
@@ -472,6 +476,7 @@ export default class BackgroundJobsStore {
|
|
|
472
476
|
if (!job) return false
|
|
473
477
|
if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return false
|
|
474
478
|
|
|
479
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
475
480
|
const affectedRows = await this._updateAffectedRows(db, {
|
|
476
481
|
tableName: JOBS_TABLE,
|
|
477
482
|
data: {
|
|
@@ -500,6 +505,7 @@ export default class BackgroundJobsStore {
|
|
|
500
505
|
await this._withDb(async (db) => await db.transaction(async () => {
|
|
501
506
|
const job = await this._getJobRowById(db, jobId)
|
|
502
507
|
if (!job || job.handoffId !== handoffId || job.status !== "handed_off") return
|
|
508
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
503
509
|
const affectedRows = await this._updateAffectedRows(db, {
|
|
504
510
|
tableName: JOBS_TABLE,
|
|
505
511
|
data: {
|
|
@@ -719,6 +725,9 @@ export default class BackgroundJobsStore {
|
|
|
719
725
|
return await this._withDb(async (db) => await this._transactionResult(db, async () => {
|
|
720
726
|
const job = await this._getJobRowById(db, jobId)
|
|
721
727
|
if (!job || (job.status !== "queued" && job.status !== "handed_off")) return false
|
|
728
|
+
// Only a handed_off job holds a concurrency reservation, so only that case touches the
|
|
729
|
+
// shared counter row and needs the concurrency-then-job lock ordering.
|
|
730
|
+
if (job.status === "handed_off") await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
722
731
|
const affectedRows = await this._updateAffectedRows(db, {tableName: JOBS_TABLE, data: {status: "cancelled"}, conditions: {id: job.id, status: job.status}})
|
|
723
732
|
if (affectedRows !== 1) return false
|
|
724
733
|
if (job.status === "handed_off") await this._releaseConcurrency(db, job.concurrencyKey)
|
|
@@ -1194,6 +1203,7 @@ export default class BackgroundJobsStore {
|
|
|
1194
1203
|
shouldRetry
|
|
1195
1204
|
})
|
|
1196
1205
|
|
|
1206
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
1197
1207
|
const affectedRows = await this._updateAffectedRows(db, {
|
|
1198
1208
|
tableName: JOBS_TABLE,
|
|
1199
1209
|
data: update,
|
|
@@ -1469,6 +1479,28 @@ export default class BackgroundJobsStore {
|
|
|
1469
1479
|
if (this._normalizeNumber(configured.max_concurrency) !== maxConcurrency) throw new Error(`Conflicting maxConcurrency for background job concurrencyKey: ${concurrencyKey}`)
|
|
1470
1480
|
}
|
|
1471
1481
|
|
|
1482
|
+
/**
|
|
1483
|
+
* Locks the concurrency counter row so a job-release transaction acquires it *before* the job
|
|
1484
|
+
* row. {@link markHandedOff} reserves capacity (locking the counter row) before it updates the
|
|
1485
|
+
* job, so it locks concurrency-then-job; the release paths update the job before releasing
|
|
1486
|
+
* capacity, which is job-then-concurrency. Those opposite orders on the same shared counter row
|
|
1487
|
+
* are what deadlock (AB-BA) under a draining worker. Taking this lock first gives every
|
|
1488
|
+
* transaction a single concurrency-then-job order and removes the cycle.
|
|
1489
|
+
*
|
|
1490
|
+
* Uses a value-preserving `UPDATE` rather than `SELECT ... FOR UPDATE` so it stays portable
|
|
1491
|
+
* across drivers without row-level locking reads (e.g. SQLite); on row-locking engines the
|
|
1492
|
+
* matched row is write-locked for the rest of the transaction even though its value is unchanged.
|
|
1493
|
+
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
1494
|
+
* @param {string | null} concurrencyKey - Concurrency key.
|
|
1495
|
+
* @returns {Promise<void>} - Resolves when the counter row is locked.
|
|
1496
|
+
*/
|
|
1497
|
+
async _lockConcurrencyRow(db, concurrencyKey) {
|
|
1498
|
+
if (!concurrencyKey) return
|
|
1499
|
+
const table = db.quoteTable(CONCURRENCY_TABLE)
|
|
1500
|
+
const count = db.quoteColumn("active_count")
|
|
1501
|
+
await db.query(`UPDATE ${table} SET ${count} = ${count} WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)}`)
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1472
1504
|
/**
|
|
1473
1505
|
* Atomically reserves capacity for a key.
|
|
1474
1506
|
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* @property {string} [queue] - Queue name. Defaults to `"default"`. When the queue has a configured cap in `backgroundJobs.queues`, that cap is enforced cluster-wide.
|
|
16
16
|
* @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap. Overrides any queue-derived cap.
|
|
17
17
|
* @property {number} [maxConcurrency] - Positive integer cap; must be paired with `concurrencyKey`.
|
|
18
|
-
* @property {boolean} [deduplicateWhileQueued] - When true
|
|
18
|
+
* @property {boolean} [deduplicateWhileQueued] - When true, skip the enqueue if an identical still-queued job (same job name, args and queue) already exists, returning that job's id. Deduplication is by job identity and independent of `concurrencyKey`, so the job keeps its normal (e.g. queue-derived) concurrency cap. Keeps an interval-scheduled recurring job (e.g. retention pruning) from piling up redundant queued rows when it runs slower than its interval or no worker is free.
|
|
19
19
|
* @property {number} [scheduledAtMs] - Epoch timestamp in milliseconds when the job becomes eligible for dispatch. Defaults to enqueue time.
|
|
20
20
|
*/
|
|
21
21
|
/**
|
|
@@ -64,6 +64,9 @@
|
|
|
64
64
|
* @property {string} [databaseCharset] - Default character set applied by `db:create` via mysql/mariadb `CREATE DATABASE ... CHARACTER SET`. Distinct from `charset`, which is the client connection charset forwarded to the mysql2 driver.
|
|
65
65
|
* @property {string} [databaseCollation] - Default collation applied by `db:create` via mysql/mariadb `CREATE DATABASE ... COLLATE`.
|
|
66
66
|
* @property {string} [database] - Database name for this connection.
|
|
67
|
+
* @property {number} [deadlockMaxRetries] - Maximum attempts for the outermost transaction when it keeps hitting deadlocks. Defaults to 8.
|
|
68
|
+
* @property {number} [deadlockBaseWaitMs] - Base delay (ms) for the deadlock retry backoff; the per-attempt ceiling doubles from here. Defaults to 50.
|
|
69
|
+
* @property {number} [deadlockMaxWaitMs] - Cap (ms) on the deadlock retry backoff ceiling so the jittered wait stays bounded. Defaults to 1000.
|
|
67
70
|
* @property {typeof import("./database/drivers/base.js").default} [driver] - Driver class to use for this database.
|
|
68
71
|
* @property {typeof import("./database/pool/base.js").default} [poolType] - Pool class to use for this database.
|
|
69
72
|
* @property {function() : ?} [getConnection] - Custom connection factory override.
|
|
@@ -122,6 +122,7 @@ import TableData from "../table-data/index.js"
|
|
|
122
122
|
import TableColumn from "../table-data/table-column.js"
|
|
123
123
|
import TableForeignKey from "../table-data/table-foreign-key.js"
|
|
124
124
|
import wait from "awaitery/build/wait.js"
|
|
125
|
+
import {optionalPositiveInteger} from "typanic"
|
|
125
126
|
|
|
126
127
|
/**
|
|
127
128
|
* Runs now ms.
|
|
@@ -864,7 +865,10 @@ export default class VelociousDatabaseDriversBase {
|
|
|
864
865
|
return await this._runTransactionAttempt(callback)
|
|
865
866
|
}
|
|
866
867
|
|
|
867
|
-
const
|
|
868
|
+
const args = this.getArgs()
|
|
869
|
+
const maxAttempts = optionalPositiveInteger(args.deadlockMaxRetries, "deadlockMaxRetries") ?? 8
|
|
870
|
+
const configuredBaseWaitMs = optionalPositiveInteger(args.deadlockBaseWaitMs, "deadlockBaseWaitMs")
|
|
871
|
+
const deadlockMaxWaitMs = optionalPositiveInteger(args.deadlockMaxWaitMs, "deadlockMaxWaitMs") ?? 1000
|
|
868
872
|
let attempt = 0
|
|
869
873
|
|
|
870
874
|
while (true) {
|
|
@@ -876,10 +880,22 @@ export default class VelociousDatabaseDriversBase {
|
|
|
876
880
|
const retryInfo = error instanceof Error ? this.retryableDatabaseError(error) : {retry: false, reconnect: false}
|
|
877
881
|
|
|
878
882
|
if (retryInfo.deadlock && attempt < maxAttempts && this._transactionsCount == 0) {
|
|
879
|
-
|
|
883
|
+
// An explicitly-configured base wins so the tuning knob is effective even on drivers
|
|
884
|
+
// whose classifier supplies its own `waitMs` (MySQL/MariaDB return a fixed 50ms for
|
|
885
|
+
// deadlocks); otherwise honor that classifier hint, then fall back to 50ms.
|
|
886
|
+
const baseWaitMs = configuredBaseWaitMs ?? (typeof retryInfo.waitMs == "number" && retryInfo.waitMs > 0 ? retryInfo.waitMs : 50)
|
|
887
|
+
|
|
888
|
+
// Full-jitter exponential backoff: wait a uniform-random duration in
|
|
889
|
+
// [0, min(base * 2^(attempt-1), cap)]. The doubling ceiling spreads retries out as
|
|
890
|
+
// contention persists, and the jitter de-correlates transactions that deadlocked in
|
|
891
|
+
// lockstep so they stop re-colliding on the same wait (the linear `base * attempt`
|
|
892
|
+
// this replaces had every victim retry after an identical delay). `attempt` is
|
|
893
|
+
// 1-based here, so 2^(attempt-1) is 1, 2, 4, ... The cap keeps the tail sub-second.
|
|
894
|
+
const ceilingWaitMs = Math.min(baseWaitMs * (2 ** (attempt - 1)), deadlockMaxWaitMs)
|
|
895
|
+
const jitteredWaitMs = Math.floor(Math.random() * (ceilingWaitMs + 1))
|
|
880
896
|
|
|
881
897
|
this.logger.warn(`Retrying transaction after deadlock (attempt ${attempt}/${maxAttempts})`)
|
|
882
|
-
await
|
|
898
|
+
await this._waitMs(jitteredWaitMs)
|
|
883
899
|
continue
|
|
884
900
|
}
|
|
885
901
|
|
|
@@ -888,6 +904,16 @@ export default class VelociousDatabaseDriversBase {
|
|
|
888
904
|
}
|
|
889
905
|
}
|
|
890
906
|
|
|
907
|
+
/**
|
|
908
|
+
* Waits `ms` milliseconds. Isolated in its own method so tests can observe (and skip) the
|
|
909
|
+
* deadlock-retry backoff without a real timer.
|
|
910
|
+
* @param {number} ms - Milliseconds to wait.
|
|
911
|
+
* @returns {Promise<void>} - Resolves after the delay.
|
|
912
|
+
*/
|
|
913
|
+
async _waitMs(ms) {
|
|
914
|
+
await wait(ms)
|
|
915
|
+
}
|
|
916
|
+
|
|
891
917
|
/**
|
|
892
918
|
* Runs a single transaction attempt: starts a transaction (or a savepoint when nested), runs
|
|
893
919
|
* `callback`, and commits — rolling back on error. {@link transaction} wraps this with deadlock
|