velocious 1.0.698 → 1.0.700
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 -2
- package/build/configuration-types.js +23 -0
- package/build/configuration.js +136 -1
- package/build/database/advisory-lock-backend-policy.js +204 -0
- package/build/database/advisory-lock-file-backend.js +348 -0
- package/build/database/advisory-lock-runner.js +239 -16
- package/build/database/drivers/base.js +318 -15
- package/build/database/drivers/mssql/index.js +1 -1
- package/build/database/drivers/mysql/index.js +4 -2
- package/build/database/drivers/pgsql/index.js +4 -2
- package/build/database/drivers/sqlite/base.js +13 -129
- package/build/database/drivers/sqlite/index.js +41 -62
- package/build/database/migrator.js +120 -50
- package/build/database/record/attachments/store.js +84 -1
- package/build/environment-handlers/base.js +13 -0
- package/build/environment-handlers/node.js +11 -0
- package/build/http-server/server-lock.js +21 -115
- package/build/src/configuration-types.d.ts +68 -0
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +21 -1
- package/build/src/configuration.d.ts +100 -1
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +124 -2
- package/build/src/database/advisory-lock-backend-policy.d.ts +145 -0
- package/build/src/database/advisory-lock-backend-policy.d.ts.map +1 -0
- package/build/src/database/advisory-lock-backend-policy.js +184 -0
- package/build/src/database/advisory-lock-file-backend.d.ts +194 -0
- package/build/src/database/advisory-lock-file-backend.d.ts.map +1 -0
- package/build/src/database/advisory-lock-file-backend.js +313 -0
- package/build/src/database/advisory-lock-runner.d.ts +116 -15
- package/build/src/database/advisory-lock-runner.d.ts.map +1 -1
- package/build/src/database/advisory-lock-runner.js +190 -17
- package/build/src/database/drivers/base.d.ts +125 -4
- package/build/src/database/drivers/base.d.ts.map +1 -1
- package/build/src/database/drivers/base.js +282 -17
- package/build/src/database/drivers/mssql/index.d.ts +1 -1
- package/build/src/database/drivers/mssql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mssql/index.js +2 -2
- package/build/src/database/drivers/mysql/index.d.ts +4 -2
- package/build/src/database/drivers/mysql/index.d.ts.map +1 -1
- package/build/src/database/drivers/mysql/index.js +5 -3
- package/build/src/database/drivers/pgsql/index.d.ts +4 -2
- package/build/src/database/drivers/pgsql/index.d.ts.map +1 -1
- package/build/src/database/drivers/pgsql/index.js +5 -3
- package/build/src/database/drivers/sqlite/base.d.ts +11 -49
- package/build/src/database/drivers/sqlite/base.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/base.js +14 -114
- package/build/src/database/drivers/sqlite/index.d.ts +28 -19
- package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.js +41 -59
- package/build/src/database/migrator.d.ts.map +1 -1
- package/build/src/database/migrator.js +110 -56
- package/build/src/database/record/attachments/store.d.ts +0 -3
- package/build/src/database/record/attachments/store.d.ts.map +1 -1
- package/build/src/database/record/attachments/store.js +71 -2
- package/build/src/environment-handlers/base.d.ts +14 -0
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +13 -1
- package/build/src/environment-handlers/node.d.ts +11 -0
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +11 -1
- package/build/src/http-server/server-lock.d.ts +13 -37
- package/build/src/http-server/server-lock.d.ts.map +1 -1
- package/build/src/http-server/server-lock.js +22 -109
- package/build/src/testing/test-runner.d.ts +74 -0
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +140 -1
- package/build/src/testing/test-tenant-database-pool.d.ts +183 -0
- package/build/src/testing/test-tenant-database-pool.d.ts.map +1 -0
- package/build/src/testing/test-tenant-database-pool.js +388 -0
- package/build/src/testing/velocious-attempt-executor.d.ts.map +1 -1
- package/build/src/testing/velocious-attempt-executor.js +43 -1
- package/build/testing/test-runner.js +152 -0
- package/build/testing/test-tenant-database-pool.js +438 -0
- package/build/testing/velocious-attempt-executor.js +44 -0
- package/package.json +1 -1
- package/src/configuration-types.js +23 -0
- package/src/configuration.js +136 -1
- package/src/database/advisory-lock-backend-policy.js +204 -0
- package/src/database/advisory-lock-file-backend.js +348 -0
- package/src/database/advisory-lock-runner.js +239 -16
- package/src/database/drivers/base.js +318 -15
- package/src/database/drivers/mssql/index.js +1 -1
- package/src/database/drivers/mysql/index.js +4 -2
- package/src/database/drivers/pgsql/index.js +4 -2
- package/src/database/drivers/sqlite/base.js +13 -129
- package/src/database/drivers/sqlite/index.js +41 -62
- package/src/database/migrator.js +120 -50
- package/src/database/record/attachments/store.js +84 -1
- package/src/environment-handlers/base.js +13 -0
- package/src/environment-handlers/node.js +11 -0
- package/src/http-server/server-lock.js +21 -115
- package/src/testing/test-runner.js +152 -0
- package/src/testing/test-tenant-database-pool.js +438 -0
- package/src/testing/velocious-attempt-executor.js +44 -0
|
@@ -110,42 +110,34 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
|
|
|
110
110
|
}
|
|
111
111
|
|
|
112
112
|
/**
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
*
|
|
113
|
+
* The filesystem lock directory is this driver's driver-native tier on
|
|
114
|
+
* top of the shared process-local pre-filter tier from the base driver,
|
|
115
|
+
* so SQLite deployments with multiple Node processes writing to the same
|
|
116
|
+
* database file see consistent advisory-lock mutual exclusion across
|
|
117
|
+
* processes, not just within a single process.
|
|
118
|
+
* @returns {boolean} - Always true for the Node SQLite driver.
|
|
119
|
+
*/
|
|
120
|
+
supportsDriverNativeAdvisoryLock() {
|
|
121
|
+
return true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Filesystem lock directory as the driver-native tier on top of the
|
|
126
|
+
* shared process-local pre-filter tier from the base driver, so SQLite
|
|
127
|
+
* deployments with multiple Node processes writing to the same database
|
|
128
|
+
* file see consistent advisory-lock mutual exclusion across processes,
|
|
129
|
+
* not just within a single process.
|
|
117
130
|
*
|
|
118
|
-
* The
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
* typical single-process traffic pays at most two `fs.mkdir` calls
|
|
123
|
-
* (create and remove) per critical section.
|
|
131
|
+
* The base driver's process-local tier serializes same-process callers
|
|
132
|
+
* first (no filesystem round trip for uncontended traffic); this hook
|
|
133
|
+
* is only reached once the tier has granted the caller its turn, and its
|
|
134
|
+
* result is what confirms cross-process exclusion for SQLite.
|
|
124
135
|
* @param {string} name - Lock name.
|
|
125
136
|
* @param {{timeoutMs?: number | null}} [args] - Optional timeout in milliseconds; `null`, `undefined`, or negative blocks forever.
|
|
126
137
|
* @returns {Promise<boolean>} - Whether the advisory lock was acquired.
|
|
127
138
|
*/
|
|
128
139
|
async _acquireAdvisoryLock(name, {timeoutMs} = {}) {
|
|
129
|
-
|
|
130
|
-
const remainingForInProcess = deadline !== null ? Math.max(0, deadline - Date.now()) : null
|
|
131
|
-
const inProcessAcquired = await super._acquireAdvisoryLock(name, {timeoutMs: remainingForInProcess})
|
|
132
|
-
|
|
133
|
-
if (!inProcessAcquired) return false
|
|
134
|
-
|
|
135
|
-
try {
|
|
136
|
-
const remainingForFile = deadline !== null ? Math.max(0, deadline - Date.now()) : null
|
|
137
|
-
const fileAcquired = await this._acquireAdvisoryLockFile(name, {timeoutMs: remainingForFile})
|
|
138
|
-
|
|
139
|
-
if (!fileAcquired) {
|
|
140
|
-
await super._releaseAdvisoryLock(name)
|
|
141
|
-
return false
|
|
142
|
-
}
|
|
143
|
-
} catch (error) {
|
|
144
|
-
await super._releaseAdvisoryLock(name)
|
|
145
|
-
throw error
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
return true
|
|
140
|
+
return await this._acquireAdvisoryLockFile(name, {timeoutMs})
|
|
149
141
|
}
|
|
150
142
|
|
|
151
143
|
/**
|
|
@@ -154,53 +146,40 @@ export default class VelociousDatabaseDriversSqliteNode extends Base {
|
|
|
154
146
|
* @returns {Promise<boolean>} - Whether the advisory lock was acquired immediately.
|
|
155
147
|
*/
|
|
156
148
|
async _tryAcquireAdvisoryLock(name) {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (!inProcessAcquired) return false
|
|
160
|
-
|
|
161
|
-
try {
|
|
162
|
-
const fileAcquired = await this._tryAcquireAdvisoryLockFile(name)
|
|
163
|
-
|
|
164
|
-
if (!fileAcquired) {
|
|
165
|
-
await super._releaseAdvisoryLock(name)
|
|
166
|
-
return false
|
|
167
|
-
}
|
|
168
|
-
} catch (error) {
|
|
169
|
-
await super._releaseAdvisoryLock(name)
|
|
170
|
-
throw error
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
return true
|
|
149
|
+
return await this._tryAcquireAdvisoryLockFile(name)
|
|
174
150
|
}
|
|
175
151
|
|
|
176
152
|
/**
|
|
177
|
-
* Releases the lock
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
* else's lock directory.
|
|
153
|
+
* Releases the on-disk lock directory for a lock this driver instance
|
|
154
|
+
* acquired through the base driver's two-tier chain. A caller that tries
|
|
155
|
+
* to release a lock it never acquired (or that was already released by
|
|
156
|
+
* another driver) gets `false` back and the filesystem state is left
|
|
157
|
+
* alone so we never delete somebody else's lock directory.
|
|
183
158
|
* @param {string} name - Lock name.
|
|
184
159
|
* @returns {Promise<boolean>} - Whether the advisory lock was released.
|
|
185
160
|
*/
|
|
186
161
|
async _releaseAdvisoryLock(name) {
|
|
187
|
-
const
|
|
162
|
+
const lockPath = this._advisoryLockPath(name)
|
|
188
163
|
|
|
189
|
-
|
|
190
|
-
await
|
|
164
|
+
try {
|
|
165
|
+
await fs.stat(lockPath)
|
|
166
|
+
} catch {
|
|
167
|
+
return false
|
|
191
168
|
}
|
|
192
169
|
|
|
193
|
-
|
|
170
|
+
await this._releaseAdvisoryLockFile(name)
|
|
171
|
+
|
|
172
|
+
return true
|
|
194
173
|
}
|
|
195
174
|
|
|
196
175
|
/**
|
|
197
|
-
*
|
|
176
|
+
* Filesystem-tier view for the base driver's `isAdvisoryLockHeld`, which
|
|
177
|
+
* answers from the process-local tier first and only asks this hook when
|
|
178
|
+
* the tier has no owner for the name.
|
|
198
179
|
* @param {string} name - Lock name.
|
|
199
|
-
* @returns {Promise<boolean>} - Whether the advisory lock is held.
|
|
180
|
+
* @returns {Promise<boolean>} - Whether the advisory lock file is held.
|
|
200
181
|
*/
|
|
201
|
-
async
|
|
202
|
-
if (await super.isAdvisoryLockHeld(name)) return true
|
|
203
|
-
|
|
182
|
+
async _isAdvisoryLockHeldNative(name) {
|
|
204
183
|
return await this._isAdvisoryLockFileHeld(name)
|
|
205
184
|
}
|
|
206
185
|
|
|
@@ -4,10 +4,24 @@ import {digg} from "diggerize"
|
|
|
4
4
|
import * as inflection from "inflection"
|
|
5
5
|
import Logger from "../logger.js"
|
|
6
6
|
import MigrationsLedger from "./migrations-ledger.js"
|
|
7
|
+
import AdvisoryLockRunner from "./advisory-lock-runner.js"
|
|
7
8
|
import {NotImplementedError} from "./migration/index.js"
|
|
8
9
|
import { migrationExecutionPhase, migrationRunsInExecutionPhase } from "./migration-execution-phase.js"
|
|
9
10
|
import restArgsError from "../utils/rest-args-error.js"
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Builds the advisory-lock name that serialises migrations for one database identifier.
|
|
14
|
+
*
|
|
15
|
+
* The name is derived from the database identifier only (not the migration) so that
|
|
16
|
+
* concurrent processes queue on one lock while applying the pending set, instead of
|
|
17
|
+
* racing on individual DDL statements.
|
|
18
|
+
* @param {string} dbIdentifier - Database identifier being migrated.
|
|
19
|
+
* @returns {string} - Advisory lock name.
|
|
20
|
+
*/
|
|
21
|
+
function migrationsAdvisoryLockName(dbIdentifier) {
|
|
22
|
+
return `velocious:migrations:${dbIdentifier}`
|
|
23
|
+
}
|
|
24
|
+
|
|
11
25
|
export default class VelociousDatabaseMigrator {
|
|
12
26
|
/**
|
|
13
27
|
* Migrations versions.
|
|
@@ -49,8 +63,11 @@ export default class VelociousDatabaseMigrator {
|
|
|
49
63
|
* @returns {Promise<void>} - Resolves when complete.
|
|
50
64
|
*/
|
|
51
65
|
async prepare() {
|
|
66
|
+
console.error("[MIG-DEBUG] prepare() start")
|
|
52
67
|
await this.createMigrationsTable()
|
|
68
|
+
console.error("[MIG-DEBUG] createMigrationsTable done")
|
|
53
69
|
await this.loadMigrationsVersions()
|
|
70
|
+
console.error("[MIG-DEBUG] prepare() done")
|
|
54
71
|
}
|
|
55
72
|
|
|
56
73
|
/**
|
|
@@ -115,6 +132,7 @@ export default class VelociousDatabaseMigrator {
|
|
|
115
132
|
*/
|
|
116
133
|
async migrateFiles(files, importCallback) {
|
|
117
134
|
let appliedCount = 0
|
|
135
|
+
console.error("[MIG-DEBUG] migrateFiles count=" + files.length)
|
|
118
136
|
|
|
119
137
|
await this.configuration.ensureConnections({databaseIdentifiers: this.databaseIdentifiers, name: "Database migrator: migrate files"}, async () => {
|
|
120
138
|
for (const migration of files) {
|
|
@@ -278,10 +296,29 @@ export default class VelociousDatabaseMigrator {
|
|
|
278
296
|
|
|
279
297
|
if (!environmentHandler || Object.keys(filteredDbs).length == 0) return
|
|
280
298
|
|
|
299
|
+
// Serialise the framework-schema bootstrap across processes. This creates the attachments
|
|
300
|
+
// and background-jobs tables, and it runs once per process outside the per-migration loop:
|
|
301
|
+
// without this lock, two processes bootstrapping one database race on CREATE TABLE /
|
|
302
|
+
// CREATE INDEX and one dies mid-boot (duplicate object/index).
|
|
303
|
+
for (const [dbIdentifier, db] of Object.entries(filteredDbs)) {
|
|
304
|
+
const runner = new AdvisoryLockRunner({
|
|
305
|
+
configuration: this.configuration,
|
|
306
|
+
connectionProvider: () => db,
|
|
307
|
+
databaseIdentifier: dbIdentifier
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
await runner.withAdvisoryLock(
|
|
311
|
+
migrationsAdvisoryLockName(dbIdentifier),
|
|
312
|
+
async () => {
|
|
313
|
+
await environmentHandler.ensureFrameworkSchema({dbs: {[dbIdentifier]: db}})
|
|
314
|
+
},
|
|
315
|
+
{dedicatedConnection: true}
|
|
316
|
+
)
|
|
317
|
+
}
|
|
318
|
+
|
|
281
319
|
// Ensure Velocious' own framework schema before the structure dump. The dump is
|
|
282
320
|
// gated to enabled environments, but migration-enabled databases must include
|
|
283
321
|
// framework tables so `db:migrate` and schema:load produce a complete database.
|
|
284
|
-
await environmentHandler.ensureFrameworkSchema({dbs: filteredDbs})
|
|
285
322
|
await environmentHandler.afterMigrations({dbs: filteredDbs})
|
|
286
323
|
}
|
|
287
324
|
|
|
@@ -487,6 +524,7 @@ export default class VelociousDatabaseMigrator {
|
|
|
487
524
|
*/
|
|
488
525
|
async runMigrationFile({migration, requireMigration, direction = "up"}) {
|
|
489
526
|
if (!this.configuration) throw new Error("No configuration set")
|
|
527
|
+
console.error("[MIG-DEBUG] runMigrationFile file=" + (migration && migration.file))
|
|
490
528
|
if (!this.configuration.isDatabasePoolInitialized()) await this.configuration.initializeDatabasePool()
|
|
491
529
|
if (!this.migrationsVersions) await this.loadMigrationsVersions()
|
|
492
530
|
|
|
@@ -542,67 +580,99 @@ export default class VelociousDatabaseMigrator {
|
|
|
542
580
|
continue
|
|
543
581
|
}
|
|
544
582
|
|
|
545
|
-
if (direction == "up") {
|
|
546
|
-
if (this.hasRunMigrationVersion(dbIdentifier, migration.date)) {
|
|
547
|
-
this.logger.debug(`${dbIdentifier} has already run migration ${migration.file}`)
|
|
548
|
-
continue
|
|
549
|
-
}
|
|
550
|
-
} else if (direction == "down") {
|
|
551
|
-
if (!this.hasRunMigrationVersion(dbIdentifier, migration.date)) {
|
|
552
|
-
this.logger.debug(`${dbIdentifier} hasn't run migration ${migration.file}`)
|
|
553
|
-
continue
|
|
554
|
-
}
|
|
555
|
-
} else {
|
|
556
|
-
throw new Error(`Unknown direction: ${direction}`)
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
this.logger.debug(`Running migration on ${dbIdentifier}: ${migration.file}`, {migrationDatabaseIdentifiers})
|
|
560
|
-
|
|
561
|
-
applied = true
|
|
562
583
|
const db = dbs[dbIdentifier]
|
|
563
|
-
const
|
|
564
|
-
|
|
584
|
+
const dateString = `${digg(migration, "date")}`
|
|
585
|
+
|
|
586
|
+
// Serialise the check-then-apply across processes. Without this, two shards can both
|
|
587
|
+
// observe the migration as unapplied and both execute its DDL; the loser dies with the
|
|
588
|
+
// driver's duplicate-object error mid-boot.
|
|
589
|
+
//
|
|
590
|
+
// The version is re-read *inside* the lock: while this process waited, another process
|
|
591
|
+
// may already have applied the migration, in which case this one must become a no-op.
|
|
592
|
+
console.error("[MIG-DEBUG] acquiring lock db=" + dbIdentifier + " file=" + migration.file)
|
|
593
|
+
const runner = new AdvisoryLockRunner({
|
|
565
594
|
configuration: this.configuration,
|
|
566
|
-
|
|
567
|
-
|
|
595
|
+
connectionProvider: () => db,
|
|
596
|
+
databaseIdentifier: dbIdentifier
|
|
568
597
|
})
|
|
569
|
-
const dateString = `${digg(migration, "date")}`
|
|
570
598
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
599
|
+
const appliedInLock = await runner.withAdvisoryLock(
|
|
600
|
+
migrationsAdvisoryLockName(dbIdentifier),
|
|
601
|
+
async () => {
|
|
602
|
+
// Re-read the applied set from the database while holding the lock. The version
|
|
603
|
+
// list was loaded during prepare(), before any other process may have finished
|
|
604
|
+
// applying migrations, so trusting that cached snapshot here would let this
|
|
605
|
+
// process re-run DDL that another process has already committed.
|
|
606
|
+
await this.loadMigrationsVersionsForDatabase({dbIdentifier, db})
|
|
607
|
+
|
|
608
|
+
if (direction == "up") {
|
|
609
|
+
if (this.hasRunMigrationVersion(dbIdentifier, migration.date)) {
|
|
610
|
+
this.logger.debug(`${dbIdentifier} has already run migration ${migration.file}`)
|
|
611
|
+
return false
|
|
612
|
+
}
|
|
613
|
+
} else if (direction == "down") {
|
|
614
|
+
if (!this.hasRunMigrationVersion(dbIdentifier, migration.date)) {
|
|
615
|
+
this.logger.debug(`${dbIdentifier} hasn't run migration ${migration.file}`)
|
|
616
|
+
return false
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
throw new Error(`Unknown direction: ${direction}`)
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
this.logger.debug(`Running migration on ${dbIdentifier}: ${migration.file}`, {migrationDatabaseIdentifiers})
|
|
623
|
+
|
|
624
|
+
const MigrationClass = migrationClass
|
|
625
|
+
const migrationInstance = new MigrationClass({
|
|
626
|
+
configuration: this.configuration,
|
|
627
|
+
databaseIdentifier: dbIdentifier,
|
|
628
|
+
db
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
if (direction == "up") {
|
|
576
632
|
try {
|
|
577
|
-
await migrationInstance.
|
|
578
|
-
} catch (
|
|
579
|
-
if (
|
|
580
|
-
|
|
633
|
+
await migrationInstance.change()
|
|
634
|
+
} catch (changeError) {
|
|
635
|
+
if (changeError instanceof NotImplementedError) {
|
|
636
|
+
try {
|
|
637
|
+
await migrationInstance.up()
|
|
638
|
+
} catch (upError) {
|
|
639
|
+
if (upError instanceof NotImplementedError) {
|
|
640
|
+
throw new Error(`'change' or 'up' didn't exist on migration: ${migration.file}`, {cause: upError})
|
|
641
|
+
} else {
|
|
642
|
+
throw upError
|
|
643
|
+
}
|
|
644
|
+
}
|
|
581
645
|
} else {
|
|
582
|
-
throw
|
|
646
|
+
throw changeError
|
|
583
647
|
}
|
|
584
648
|
}
|
|
585
|
-
} else {
|
|
586
|
-
throw changeError
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
649
|
|
|
590
|
-
|
|
591
|
-
} else if (direction == "down") {
|
|
592
|
-
try {
|
|
593
|
-
await migrationInstance.down()
|
|
594
|
-
} catch (downError) {
|
|
595
|
-
if (downError instanceof NotImplementedError) {
|
|
596
|
-
throw new Error(`'down' didn't exist on migration: ${migration.file} or migrating down with a change method isn't currently supported`, {cause: downError})
|
|
650
|
+
await MigrationsLedger.recordVersion(db, dateString)
|
|
597
651
|
} else {
|
|
598
|
-
|
|
652
|
+
try {
|
|
653
|
+
await migrationInstance.down()
|
|
654
|
+
} catch (downError) {
|
|
655
|
+
if (downError instanceof NotImplementedError) {
|
|
656
|
+
throw new Error(`'down' didn't exist on migration: ${migration.file} or migrating down with a change method isn't currently supported`, {cause: downError})
|
|
657
|
+
} else {
|
|
658
|
+
throw downError
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
await MigrationsLedger.removeVersion(db, dateString)
|
|
599
663
|
}
|
|
600
|
-
}
|
|
601
664
|
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
665
|
+
return true
|
|
666
|
+
},
|
|
667
|
+
// Pin the lock to one physical session: MSSQL/explicit advisory locks are
|
|
668
|
+
// session-scoped, and a pooled connection can route acquire and release to
|
|
669
|
+
// different sessions, which would let two processes both "hold" the lock.
|
|
670
|
+
{dedicatedConnection: true}
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if (!appliedInLock) continue
|
|
674
|
+
|
|
675
|
+
applied = true
|
|
606
676
|
}
|
|
607
677
|
|
|
608
678
|
return applied
|
|
@@ -15,6 +15,10 @@ const ATTACHMENT_OWNER_INDEX_NAME = "index_velocious_attachments_on_record_type_
|
|
|
15
15
|
const ATTACHMENT_RECORD_ID_DIGEST_LENGTH = 64
|
|
16
16
|
const ATTACHMENT_RECORD_ID_DIGEST_MIGRATION_BATCH_SIZE = 100
|
|
17
17
|
const ATTACHMENT_SCHEMA_LOCK_NAME = "velocious-attachments-schema"
|
|
18
|
+
/** SQL Server deadlock victim — raised when concurrent sessions deadlock on the same catalog reads. */
|
|
19
|
+
const MSSQL_DEADLOCK_VICTIM_ERROR_NUMBER = 1205
|
|
20
|
+
/** Retry budget for transient catalog deadlocks while bootstrapping attachment schema. */
|
|
21
|
+
const ATTACHMENT_SCHEMA_MAX_ATTEMPTS = 3
|
|
18
22
|
|
|
19
23
|
/**
|
|
20
24
|
* Stores by configuration.
|
|
@@ -158,6 +162,52 @@ export function recordAttachmentsStoreForModel(model) {
|
|
|
158
162
|
/**
|
|
159
163
|
* Attachment persistence store.
|
|
160
164
|
*/
|
|
165
|
+
/**
|
|
166
|
+
* Returns whether an error means a concurrent process created the same schema object.
|
|
167
|
+
*
|
|
168
|
+
* Drivers report this differently: SQLite raises "index ... already exists", MSSQL raises
|
|
169
|
+
* "There is already an object named ..." (2714), PostgreSQL raises DuplicateTable (42P07) or
|
|
170
|
+
* DuplicateObject (42710). Matching on the message keeps the check driver-agnostic without
|
|
171
|
+
* hiding unrelated failures.
|
|
172
|
+
* @param {unknown} error - Thrown error.
|
|
173
|
+
* @returns {boolean} - Whether this is a concurrent-creation collision.
|
|
174
|
+
*/
|
|
175
|
+
function isConcurrentSchemaCreationError(error) {
|
|
176
|
+
if (!error) return false
|
|
177
|
+
|
|
178
|
+
const code = typeof error === "object" && "number" in error ? Number(error.number) : NaN
|
|
179
|
+
|
|
180
|
+
// MSSQL 2714 (duplicate object), 1913 (duplicate index); PostgreSQL 42P07 / 42710.
|
|
181
|
+
if (code === 2714 || code === 1913 || code === 42) return true
|
|
182
|
+
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
184
|
+
|
|
185
|
+
return /already exists/i.test(message) || /already an object named/i.test(message) || /duplicate (table|object|key)/i.test(message)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Whether an error is a retryable database deadlock victim.
|
|
190
|
+
*
|
|
191
|
+
* Matched by numeric error number, never by message text: SQL Server raises 1205 when a
|
|
192
|
+
* concurrent session wins the lock and this one is chosen as the victim. The victim's
|
|
193
|
+
* transaction has already rolled back, so the operation is safe to retry.
|
|
194
|
+
* @param {unknown} error - Thrown error.
|
|
195
|
+
* @returns {boolean} - Whether this is a retryable deadlock victim.
|
|
196
|
+
*/
|
|
197
|
+
function isDeadlockVictimError(error) {
|
|
198
|
+
let current = error
|
|
199
|
+
|
|
200
|
+
while (current instanceof Error) {
|
|
201
|
+
const errorNumber = /** @type {{number?: unknown}} */ (current).number
|
|
202
|
+
|
|
203
|
+
if (typeof errorNumber === "number" && errorNumber === MSSQL_DEADLOCK_VICTIM_ERROR_NUMBER) return true
|
|
204
|
+
|
|
205
|
+
current = current.cause
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return false
|
|
209
|
+
}
|
|
210
|
+
|
|
161
211
|
export default class RecordAttachmentsStore {
|
|
162
212
|
/**
|
|
163
213
|
* Runs constructor.
|
|
@@ -237,7 +287,40 @@ export default class RecordAttachmentsStore {
|
|
|
237
287
|
table.bigint("updated_at_ms", {null: false})
|
|
238
288
|
table.addIndex(new TableIndex(["record_type", "record_id_digest"], {name: ATTACHMENT_OWNER_INDEX_NAME}))
|
|
239
289
|
|
|
240
|
-
|
|
290
|
+
let attempt = 0
|
|
291
|
+
|
|
292
|
+
while (true) {
|
|
293
|
+
attempt++
|
|
294
|
+
|
|
295
|
+
try {
|
|
296
|
+
await db.createTable(table)
|
|
297
|
+
|
|
298
|
+
break
|
|
299
|
+
} catch (error) {
|
|
300
|
+
// A concurrent process may win the create between our tableExists() check and this
|
|
301
|
+
// call. createTable guards the table with ifNotExists but NOT its indexes, so the
|
|
302
|
+
// loser fails with a duplicate-object error. Treat that as "someone else bootstrapped
|
|
303
|
+
// it": reconcile through the upgrade path instead of failing the caller.
|
|
304
|
+
if (isConcurrentSchemaCreationError(error)) {
|
|
305
|
+
await this.ensureAttachmentStoreSchema({db})
|
|
306
|
+
|
|
307
|
+
return
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// Concurrent boots also deadlock on the INFORMATION_SCHEMA metadata scan that
|
|
311
|
+
// getTableByName performs on SQL Server. The victim transaction rolled back cleanly,
|
|
312
|
+
// so retrying it is safe. Without this the loser takes down its caller even though
|
|
313
|
+
// another process is building the very table it needs.
|
|
314
|
+
if (isDeadlockVictimError(error) && attempt < ATTACHMENT_SCHEMA_MAX_ATTEMPTS) {
|
|
315
|
+
db.clearSchemaCache()
|
|
316
|
+
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
throw error
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
241
324
|
this._driverColumnsAvailable = true
|
|
242
325
|
this._contentBase64Nullable = true
|
|
243
326
|
this._schemaReadyGeneration = this._schemaCacheGeneration(db)
|
|
@@ -36,6 +36,19 @@ export default class VelociousEnvironmentHandlerBase {
|
|
|
36
36
|
*/
|
|
37
37
|
resolveTestSharedTransactionPoolType({configuredPoolType}) { return configuredPoolType }
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Creates the file-tier advisory-lock backend (STC-213). Only filesystem
|
|
41
|
+
* capable environments (the Node handler) can provide this tier; browser
|
|
42
|
+
* and Expo configurations have no `fs`, so the default refuses loudly
|
|
43
|
+
* instead of half-working when a deployment explicitly enables
|
|
44
|
+
* `advisoryLocks.backends.file.enabled`.
|
|
45
|
+
* @param {{directory: string, leaseMs: number, renewIntervalMs: number}} _args - Backend options.
|
|
46
|
+
* @returns {Promise<import("../database/advisory-lock-file-backend.js").default>} - Resolves with the created backend in filesystem-capable handlers.
|
|
47
|
+
*/
|
|
48
|
+
async createAdvisoryLockFileBackend(_args) {
|
|
49
|
+
throw new Error("The advisory-lock file tier requires a filesystem-capable environment handler (Node)")
|
|
50
|
+
}
|
|
51
|
+
|
|
39
52
|
/**
|
|
40
53
|
* Node test runtimes may replace a physical child connection with a broker
|
|
41
54
|
* proxy. Other environments never participate in this test-only protocol.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
3
|
import "../database/annotations-async-hooks.js"
|
|
4
|
+
import AdvisoryLockFileBackend from "../database/advisory-lock-file-backend.js"
|
|
4
5
|
import Base from "./base.js"
|
|
5
6
|
import BackgroundJobsClient from "../background-jobs/client.js"
|
|
6
7
|
import SqlBackgroundJobsAdapter from "../background-jobs/sql-adapter.js"
|
|
@@ -106,6 +107,16 @@ export default class VelociousEnvironmentHandlerNode extends Base{
|
|
|
106
107
|
return AsyncTrackedMultiConnectionPool
|
|
107
108
|
}
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Creates the filesystem-backed advisory-lock backend for the file tier
|
|
112
|
+
* (STC-213). The Node handler is the only filesystem-capable environment.
|
|
113
|
+
* @param {{directory: string, leaseMs: number, renewIntervalMs: number}} args - Backend options.
|
|
114
|
+
* @returns {Promise<import("../database/advisory-lock-file-backend.js").default>} - Resolves with the created backend.
|
|
115
|
+
*/
|
|
116
|
+
async createAdvisoryLockFileBackend(args) {
|
|
117
|
+
return new AdvisoryLockFileBackend(args)
|
|
118
|
+
}
|
|
119
|
+
|
|
109
120
|
/**
|
|
110
121
|
* Creates a test-only child proxy when TestRunner supplied an active broker.
|
|
111
122
|
* @param {{DriverClass: typeof import("../database/drivers/base.js").default, config: import("../configuration-types.js").DatabaseConfigurationType, configuration: import("../configuration.js").default, databaseIdentifier: string, reuseKey?: string}} args - Connection details.
|