velocious 1.0.539 → 1.0.541
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/store.js +28 -0
- package/build/configuration-types.js +3 -0
- package/build/database/drivers/base.js +29 -3
- package/build/database/record/index.js +41 -0
- 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 +30 -1
- 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/build/src/database/record/index.d.ts +31 -0
- package/build/src/database/record/index.d.ts.map +1 -1
- package/build/src/database/record/index.js +38 -1
- package/package.json +1 -1
- package/src/background-jobs/store.js +28 -0
- package/src/configuration-types.js +3 -0
- package/src/database/drivers/base.js +29 -3
- package/src/database/record/index.js +41 -0
package/package.json
CHANGED
|
@@ -476,6 +476,7 @@ export default class BackgroundJobsStore {
|
|
|
476
476
|
if (!job) return false
|
|
477
477
|
if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return false
|
|
478
478
|
|
|
479
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
479
480
|
const affectedRows = await this._updateAffectedRows(db, {
|
|
480
481
|
tableName: JOBS_TABLE,
|
|
481
482
|
data: {
|
|
@@ -504,6 +505,7 @@ export default class BackgroundJobsStore {
|
|
|
504
505
|
await this._withDb(async (db) => await db.transaction(async () => {
|
|
505
506
|
const job = await this._getJobRowById(db, jobId)
|
|
506
507
|
if (!job || job.handoffId !== handoffId || job.status !== "handed_off") return
|
|
508
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
507
509
|
const affectedRows = await this._updateAffectedRows(db, {
|
|
508
510
|
tableName: JOBS_TABLE,
|
|
509
511
|
data: {
|
|
@@ -723,6 +725,9 @@ export default class BackgroundJobsStore {
|
|
|
723
725
|
return await this._withDb(async (db) => await this._transactionResult(db, async () => {
|
|
724
726
|
const job = await this._getJobRowById(db, jobId)
|
|
725
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)
|
|
726
731
|
const affectedRows = await this._updateAffectedRows(db, {tableName: JOBS_TABLE, data: {status: "cancelled"}, conditions: {id: job.id, status: job.status}})
|
|
727
732
|
if (affectedRows !== 1) return false
|
|
728
733
|
if (job.status === "handed_off") await this._releaseConcurrency(db, job.concurrencyKey)
|
|
@@ -1198,6 +1203,7 @@ export default class BackgroundJobsStore {
|
|
|
1198
1203
|
shouldRetry
|
|
1199
1204
|
})
|
|
1200
1205
|
|
|
1206
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
1201
1207
|
const affectedRows = await this._updateAffectedRows(db, {
|
|
1202
1208
|
tableName: JOBS_TABLE,
|
|
1203
1209
|
data: update,
|
|
@@ -1473,6 +1479,28 @@ export default class BackgroundJobsStore {
|
|
|
1473
1479
|
if (this._normalizeNumber(configured.max_concurrency) !== maxConcurrency) throw new Error(`Conflicting maxConcurrency for background job concurrencyKey: ${concurrencyKey}`)
|
|
1474
1480
|
}
|
|
1475
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
|
+
|
|
1476
1504
|
/**
|
|
1477
1505
|
* Atomically reserves capacity for a key.
|
|
1478
1506
|
* @param {import("../database/drivers/base.js").default} db - Database connection.
|
|
@@ -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
|
|
@@ -3167,6 +3167,47 @@ class VelociousDatabaseRecord {
|
|
|
3167
3167
|
return await this._newQuery().findByOrFail(conditions)
|
|
3168
3168
|
}
|
|
3169
3169
|
|
|
3170
|
+
/**
|
|
3171
|
+
* Returns a scope whose eager finders run against an explicit `tenant` (and
|
|
3172
|
+
* therefore its database) instead of whatever tenant is ambient in
|
|
3173
|
+
* `Current.tenant()`. Use it to read a model that may live in a specific
|
|
3174
|
+
* tenant or in the default database from another tenant context — for example
|
|
3175
|
+
* `GithubWebhook.usingTenant(tenant).findBy({id})` — without depending on
|
|
3176
|
+
* which tenant happens to be active. The target tenant's connections are
|
|
3177
|
+
* ensured for the duration of each query.
|
|
3178
|
+
* @template {typeof VelociousDatabaseRecord} MC
|
|
3179
|
+
* @this {MC}
|
|
3180
|
+
* @param {?} tenant - Tenant descriptor to scope the queries to (as accepted by `configuration.runWithTenant`).
|
|
3181
|
+
* @returns {{find: (recordId: ?) => Promise<InstanceType<MC> | null>, findBy: (conditions: {[key: string]: string | number}) => Promise<InstanceType<MC> | null>, findByOrFail: (conditions: {[key: string]: string | number}) => Promise<InstanceType<MC>>}} - Eager finders scoped to the given tenant.
|
|
3182
|
+
*/
|
|
3183
|
+
static usingTenant(tenant) {
|
|
3184
|
+
const ModelClass = this
|
|
3185
|
+
|
|
3186
|
+
return {
|
|
3187
|
+
find: (recordId) => ModelClass._runUsingTenant(tenant, () => ModelClass.find(recordId)),
|
|
3188
|
+
findBy: (conditions) => ModelClass._runUsingTenant(tenant, () => ModelClass.findBy(conditions)),
|
|
3189
|
+
findByOrFail: (conditions) => ModelClass._runUsingTenant(tenant, () => ModelClass.findByOrFail(conditions))
|
|
3190
|
+
}
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
/**
|
|
3194
|
+
* Runs `callback` with the tenant switched to `tenant` and that tenant's
|
|
3195
|
+
* connections ensured. Backs `usingTenant`.
|
|
3196
|
+
* @template T
|
|
3197
|
+
* @param {?} tenant - Tenant descriptor.
|
|
3198
|
+
* @param {() => Promise<T>} callback - Query to run under the tenant.
|
|
3199
|
+
* @returns {Promise<T>} - Resolves with the callback's result.
|
|
3200
|
+
*/
|
|
3201
|
+
static async _runUsingTenant(tenant, callback) {
|
|
3202
|
+
const configuration = this._getConfiguration()
|
|
3203
|
+
|
|
3204
|
+
// Do NOT ensureInitialized() out here: for a tenant-switched model whose
|
|
3205
|
+
// first initialization would resolve metadata from the ambient tenant's
|
|
3206
|
+
// database, that must happen under the requested tenant. The finders inside
|
|
3207
|
+
// `callback` call ensureInitialized() themselves, now within this scope.
|
|
3208
|
+
return await configuration.runWithTenant(tenant, () => configuration.ensureConnections({name: `usingTenant: ${this.getModelName()}`}, callback))
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3170
3211
|
/**
|
|
3171
3212
|
* Runs find or create by.
|
|
3172
3213
|
* @template {typeof VelociousDatabaseRecord} MC
|