@crombolo/data 0.11.0 → 0.13.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crombolo/data",
3
- "version": "0.11.0",
3
+ "version": "0.13.1",
4
4
  "description": "SQLite and Postgres storage for crombolo: schema, migrations and repositories.",
5
5
  "license": "MIT",
6
6
  "author": "Crombolo",
@@ -28,6 +28,6 @@
28
28
  "access": "public"
29
29
  },
30
30
  "dependencies": {
31
- "@crombolo/core": "0.11.0"
31
+ "@crombolo/core": "0.13.1"
32
32
  }
33
33
  }
package/src/ambient.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** The transaction the current async chain is inside, shared by the drivers and the lock managers. */
2
+ import { AsyncLocalStorage } from 'node:async_hooks'
3
+
4
+ /** Cleared when its transaction ends, so work left running afterwards falls back to the pool. */
5
+ export interface AmbientTransaction<T> {
6
+ owner: object
7
+ tx: T | undefined
8
+ }
9
+
10
+ export const ambient = new AsyncLocalStorage<AmbientTransaction<unknown>>()
11
+
12
+ /** The open transaction this chain holds on `owner`'s connection, if any. */
13
+ export function ambientFor<T>(owner: object): T | undefined {
14
+ const store = ambient.getStore()
15
+ return store?.owner === owner ? (store.tx as T | undefined) : undefined
16
+ }
17
+
18
+ export function insideTransaction(): boolean {
19
+ return ambient.getStore()?.tx !== undefined
20
+ }
package/src/busy.ts ADDED
@@ -0,0 +1,74 @@
1
+ /** Lock contention: recognising it in driver errors, retrying it, and the error an editor sees. */
2
+
3
+ export type Contention = 'deadlock' | 'lockTimeout' | 'busy'
4
+
5
+ /** A write gave up waiting for another to finish. Answered as 503 with Retry-After. */
6
+ export class ContentBusyError extends Error {
7
+ constructor(
8
+ readonly contention: Contention,
9
+ readonly retryAfterSeconds = 2,
10
+ ) {
11
+ super('Someone else is changing this content right now. Try again in a moment.')
12
+ }
13
+ }
14
+
15
+ const POSTGRES_CODES: Record<string, Contention> = {
16
+ '40P01': 'deadlock',
17
+ '40001': 'deadlock',
18
+ '55P03': 'lockTimeout',
19
+ }
20
+
21
+ /** Which kind of contention a driver error reports, if any. */
22
+ export function classifyContention(error: unknown): Contention | undefined {
23
+ if (error instanceof ContentBusyError) return error.contention
24
+ if (!error || typeof error !== 'object') return undefined
25
+ const { code, errno, message } = error as { code?: unknown; errno?: unknown; message?: unknown }
26
+ // Bun's Postgres driver reports the SQLSTATE as `errno`, other drivers as `code`.
27
+ for (const state of [errno, code])
28
+ if (typeof state === 'string' && POSTGRES_CODES[state]) return POSTGRES_CODES[state]
29
+ if (
30
+ typeof code === 'string' &&
31
+ (code.startsWith('SQLITE_BUSY') || code.startsWith('SQLITE_LOCKED'))
32
+ )
33
+ return 'busy'
34
+ if (errno === 5 || errno === 6) return 'busy'
35
+ if (typeof message === 'string' && /database (table )?is locked/i.test(message)) return 'busy'
36
+ return undefined
37
+ }
38
+
39
+ export interface RetryPolicy {
40
+ /** Attempts in all, the first included. */
41
+ attempts: number
42
+ /** Waits between attempts; injected so a test does not sleep. */
43
+ sleep: (ms: number) => Promise<void>
44
+ backoffMs: number
45
+ }
46
+
47
+ export const DEFAULT_RETRY: RetryPolicy = {
48
+ attempts: 2,
49
+ sleep: (ms) => Bun.sleep(ms),
50
+ backoffMs: 50,
51
+ }
52
+
53
+ /**
54
+ * Runs `fn`, retrying a deadlock: the database rolled the transaction back, so
55
+ * running it again is safe. A lock timeout is not retried, since the wait has
56
+ * already been spent. Contention that outlasts the policy becomes ContentBusyError.
57
+ */
58
+ export async function retryContention<T>(
59
+ fn: () => Promise<T>,
60
+ policy: RetryPolicy = DEFAULT_RETRY,
61
+ ): Promise<T> {
62
+ for (let attempt = 1; ; attempt++) {
63
+ try {
64
+ return await fn()
65
+ } catch (error) {
66
+ const contention = classifyContention(error)
67
+ if (!contention) throw error
68
+ if (contention !== 'deadlock' || attempt >= policy.attempts) {
69
+ throw error instanceof ContentBusyError ? error : new ContentBusyError(contention)
70
+ }
71
+ await policy.sleep(policy.backoffMs * attempt)
72
+ }
73
+ }
74
+ }
@@ -23,6 +23,12 @@ export interface Finding {
23
23
  subjectType:
24
24
  | 'document'
25
25
  | 'document-type'
26
+ | 'media-type'
27
+ | 'member-type'
28
+ | 'member'
29
+ | 'member-group'
30
+ | 'user'
31
+ | 'user-group'
26
32
  | 'property'
27
33
  | 'migration'
28
34
  | 'file'
package/src/cluster.ts CHANGED
@@ -20,6 +20,9 @@ export interface CacheInstruction {
20
20
  createdBy: string | null
21
21
  }
22
22
 
23
+ /** Where Postgres says an instruction was written, so nodes poll now rather than on their timer. */
24
+ export const CACHE_INSTRUCTION_CHANNEL = 'crombolo_cache_instruction'
25
+
23
26
  export async function appendCacheInstruction(
24
27
  db: Db,
25
28
  instruction: { kind: CacheInstructionKind; payload?: Record<string, unknown>; by?: string },
@@ -33,6 +36,9 @@ export async function appendCacheInstruction(
33
36
  instruction.by ?? null,
34
37
  ],
35
38
  )
39
+ // Delivered at commit, so a write that rolls back wakes nobody.
40
+ if (db.dialect.name === 'postgres')
41
+ await db.exec('SELECT pg_notify(?, ?)', [CACHE_INSTRUCTION_CHANNEL, instruction.kind])
36
42
  }
37
43
 
38
44
  /** The highest instruction id, so a fresh node ignores history. */
@@ -60,25 +66,28 @@ export interface ServerIdentity {
60
66
  revision: string
61
67
  /** `all`, `api` or `web`: what this node serves, so the cluster can be read back by role. */
62
68
  role?: string
69
+ /** The Crombolo release this node runs; `version` is the schema set's. */
70
+ cromboloVersion?: string
63
71
  }
64
72
 
65
73
  /** Records this node in `server`; called at boot and on each poll. */
66
74
  export async function touchServer(db: Db, identity: ServerIdentity): Promise<void> {
67
75
  const now = DbDate.toDb(new Date())
68
76
  const role = identity.role ?? null
77
+ const crombolo = identity.cromboloVersion ?? null
69
78
  const existing = await db.query<{ node_id: string }>(
70
79
  'SELECT node_id FROM server WHERE node_id = ?',
71
80
  [identity.nodeId],
72
81
  )
73
82
  if (existing[0]) {
74
83
  await db.exec(
75
- 'UPDATE server SET version = ?, revision = ?, role = ?, last_seen = ? WHERE node_id = ?',
76
- [identity.version, identity.revision, role, now, identity.nodeId],
84
+ 'UPDATE server SET version = ?, revision = ?, role = ?, crombolo_version = ?, last_seen = ? WHERE node_id = ?',
85
+ [identity.version, identity.revision, role, crombolo, now, identity.nodeId],
77
86
  )
78
87
  } else {
79
88
  await db.exec(
80
- 'INSERT INTO server (node_id, version, revision, role, last_seen) VALUES (?, ?, ?, ?, ?)',
81
- [identity.nodeId, identity.version, identity.revision, role, now],
89
+ 'INSERT INTO server (node_id, version, revision, role, crombolo_version, last_seen) VALUES (?, ?, ?, ?, ?, ?)',
90
+ [identity.nodeId, identity.version, identity.revision, role, crombolo, now],
82
91
  )
83
92
  }
84
93
  }
@@ -89,13 +98,15 @@ export interface LiveNode {
89
98
  role: string | null
90
99
  version: string | null
91
100
  revision: string | null
101
+ /** Null for a row written before node releases were recorded. */
102
+ cromboloVersion: string | null
92
103
  lastSeen: Date
93
104
  }
94
105
 
95
106
  /** The nodes that have polled within `seenWithinMs`: the cluster as it stands. */
96
107
  export async function liveNodes(db: Db, options: { seenWithinMs: number }): Promise<LiveNode[]> {
97
108
  const rows = await db.query(
98
- 'SELECT node_id, role, version, revision, last_seen FROM server WHERE last_seen >= ? ORDER BY node_id',
109
+ 'SELECT node_id, role, version, revision, crombolo_version, last_seen FROM server WHERE last_seen >= ? ORDER BY node_id',
99
110
  [DbDate.toDb(new Date(Date.now() - options.seenWithinMs))],
100
111
  )
101
112
  return rows.map((row) => ({
@@ -103,6 +114,7 @@ export async function liveNodes(db: Db, options: { seenWithinMs: number }): Prom
103
114
  role: (row.role as string | null) ?? null,
104
115
  version: (row.version as string | null) ?? null,
105
116
  revision: (row.revision as string | null) ?? null,
117
+ cromboloVersion: (row.crombolo_version as string | null) ?? null,
106
118
  lastSeen: DbDate.fromDb(row.last_seen) as Date,
107
119
  }))
108
120
  }
package/src/database.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /** Driver-agnostic database handle. */
2
2
 
3
3
  import { Database as BunSqlite } from 'bun:sqlite'
4
- import { AsyncLocalStorage } from 'node:async_hooks'
4
+ import { ambient, ambientFor } from './ambient.ts'
5
5
  import { type Dialect, type DialectName, dialectFor } from './dialect.ts'
6
- import { createLockManager, type LockManager } from './locks.ts'
6
+ import { createLockManager, DEFAULT_LOCK_TIMEOUT_MS, type LockManager } from './locks.ts'
7
7
 
8
8
  export interface Db {
9
9
  readonly dialect: Dialect
@@ -12,6 +12,8 @@ export interface Db {
12
12
  exec(sql: string, params?: unknown[]): Promise<void>
13
13
  transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T>
14
14
  close(): Promise<void>
15
+ /** Postgres only: calls `onNotify` for each `NOTIFY` on `channel`; resolves to the way to stop. */
16
+ listen?(channel: string, onNotify: (payload: string) => void): Promise<() => Promise<void>>
15
17
  }
16
18
 
17
19
  export interface ConnectOptions {
@@ -22,10 +24,15 @@ export interface ConnectOptions {
22
24
  file?: string
23
25
  /** Postgres connection string. Ignored for SQLite. */
24
26
  url?: string
27
+ /** How long a write waits for a content lock; defaults to `CROMBOLO_LOCK_TIMEOUT_SECONDS`, else 15s. */
28
+ lockTimeoutMs?: number
25
29
  }
26
30
 
27
- /** The connection the current async chain holds a transaction on. */
28
- const transacting = new AsyncLocalStorage<BunSqlite>()
31
+ export function resolveLockTimeoutMs(options: ConnectOptions = {}): number {
32
+ if (options.lockTimeoutMs !== undefined) return options.lockTimeoutMs
33
+ const seconds = Number(Bun.env.CROMBOLO_LOCK_TIMEOUT_SECONDS)
34
+ return Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : DEFAULT_LOCK_TIMEOUT_MS
35
+ }
29
36
 
30
37
  /**
31
38
  * One connection has one transaction at a time, but async chains interleave:
@@ -45,9 +52,10 @@ class SqliteDb implements Db {
45
52
  locks?: LockManager,
46
53
  inTransaction = false,
47
54
  gate: { tail: Promise<void> } = { tail: Promise.resolve() },
55
+ lockTimeoutMs?: number,
48
56
  ) {
49
57
  this.#db = db
50
- this.locks = locks ?? createLockManager(this)
58
+ this.locks = locks ?? createLockManager(this, { timeoutMs: lockTimeoutMs })
51
59
  this.#inTransaction = inTransaction
52
60
  this.#gate = gate
53
61
  }
@@ -63,7 +71,7 @@ class SqliteDb implements Db {
63
71
  async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
64
72
  if (this.#inTransaction) return fn(this)
65
73
  const tx = new SqliteDb(this.#db, this.locks, true, this.#gate)
66
- if (transacting.getStore() === this.#db) return fn(tx)
74
+ if (ambientFor(this.#db)) return fn(tx)
67
75
  const previous = this.#gate.tail
68
76
  let release!: () => void
69
77
  this.#gate.tail = new Promise<void>((resolve) => {
@@ -72,13 +80,16 @@ class SqliteDb implements Db {
72
80
  await previous
73
81
  try {
74
82
  this.#db.exec('BEGIN')
83
+ const store = { owner: this.#db, tx: tx as unknown }
75
84
  try {
76
- const result = await transacting.run(this.#db, () => fn(tx))
85
+ const result = await ambient.run(store, () => fn(tx))
77
86
  this.#db.exec('COMMIT')
78
87
  return result
79
88
  } catch (error) {
80
89
  this.#db.exec('ROLLBACK')
81
90
  throw error
91
+ } finally {
92
+ store.tx = undefined
82
93
  }
83
94
  } finally {
84
95
  release()
@@ -94,6 +105,10 @@ interface SqlTag {
94
105
  unsafe(sql: string, params?: unknown[]): Promise<unknown>
95
106
  begin<T>(fn: (tx: SqlTag) => Promise<T>): Promise<T>
96
107
  close(): Promise<void>
108
+ listen(
109
+ channel: string,
110
+ onNotify: (payload: string) => void,
111
+ ): Promise<{ unlisten(): Promise<void> }>
97
112
  }
98
113
 
99
114
  class PostgresDb implements Db {
@@ -102,9 +117,9 @@ class PostgresDb implements Db {
102
117
  #sql: SqlTag
103
118
  #inTransaction: boolean
104
119
 
105
- constructor(sql: SqlTag, locks?: LockManager, inTransaction = false) {
120
+ constructor(sql: SqlTag, locks?: LockManager, inTransaction = false, lockTimeoutMs?: number) {
106
121
  this.#sql = sql
107
- this.locks = locks ?? createLockManager(this)
122
+ this.locks = locks ?? createLockManager(this, { timeoutMs: lockTimeoutMs })
108
123
  this.#inTransaction = inTransaction
109
124
  }
110
125
 
@@ -116,15 +131,33 @@ class PostgresDb implements Db {
116
131
  await this.#sql.unsafe(this.dialect.render(sql), params)
117
132
  }
118
133
 
119
- /** Nested calls join the enclosing transaction, as they do on SQLite. */
134
+ /**
135
+ * Nested calls join the enclosing transaction, as they do on SQLite. The
136
+ * chain is marked as inside one, so a deadlock is not retried into a
137
+ * transaction Postgres has already aborted.
138
+ */
120
139
  async transaction<T>(fn: (tx: Db) => Promise<T>): Promise<T> {
121
140
  if (this.#inTransaction) return fn(this)
122
- return this.#sql.begin(async (tx) => fn(new PostgresDb(tx, this.locks, true)))
141
+ return this.#sql.begin(async (sql) => {
142
+ const tx = new PostgresDb(sql, this.locks, true)
143
+ const store = { owner: this, tx: tx as unknown }
144
+ try {
145
+ return await ambient.run(store, () => fn(tx))
146
+ } finally {
147
+ store.tx = undefined
148
+ }
149
+ })
123
150
  }
124
151
 
125
152
  async close(): Promise<void> {
126
153
  await this.#sql.close()
127
154
  }
155
+
156
+ async listen(channel: string, onNotify: (payload: string) => void): Promise<() => Promise<void>> {
157
+ if (this.#inTransaction) throw new Error('A transaction cannot listen.')
158
+ const subscription = await this.#sql.listen(channel, onNotify)
159
+ return () => subscription.unlisten()
160
+ }
128
161
  }
129
162
 
130
163
  export function resolveDialectName(options: ConnectOptions = {}): DialectName {
@@ -139,7 +172,7 @@ export async function connect(options: ConnectOptions = {}): Promise<Db> {
139
172
  // A modest pool: many short-lived connections exhaust Postgres' client limit,
140
173
  // and a single process does not need more.
141
174
  const sql = new SQL({ url, max: options.poolSize ?? 5 }) as unknown as SqlTag
142
- const db = new PostgresDb(sql)
175
+ const db = new PostgresDb(sql, undefined, false, resolveLockTimeoutMs(options))
143
176
  await db.exec('CREATE EXTENSION IF NOT EXISTS citext')
144
177
  return db
145
178
  }
@@ -147,5 +180,5 @@ export async function connect(options: ConnectOptions = {}): Promise<Db> {
147
180
  sqlite.exec('PRAGMA journal_mode = WAL')
148
181
  sqlite.exec('PRAGMA foreign_keys = ON')
149
182
  sqlite.exec('PRAGMA busy_timeout = 5000')
150
- return new SqliteDb(sqlite)
183
+ return new SqliteDb(sqlite, undefined, false, undefined, resolveLockTimeoutMs(options))
151
184
  }
package/src/index.ts CHANGED
@@ -1,5 +1,7 @@
1
+ export { insideTransaction } from './ambient.ts'
1
2
  export * from './audit.ts'
2
3
  export * from './bundle-tables/index.ts'
4
+ export * from './busy.ts'
3
5
  export * from './change-report.ts'
4
6
  export * from './cluster.ts'
5
7
  export * from './database.ts'
@@ -9,12 +11,16 @@ export * from './maintenance.ts'
9
11
  export * from './migrations.ts'
10
12
  export * from './node-ref.ts'
11
13
  export * from './pending.ts'
14
+ export * from './relations.ts'
15
+ export * from './repositories/approvals.ts'
12
16
  export * from './repositories/bundles.ts'
13
17
  export * from './repositories/content-types.ts'
14
18
  export * from './repositories/data-types.ts'
19
+ export * from './repositories/delivery-member-tokens.ts'
15
20
  export * from './repositories/dictionary.ts'
16
21
  export * from './repositories/documents.ts'
17
22
  export * from './repositories/domains.ts'
23
+ export * from './repositories/dynamic-roots.ts'
18
24
  export * from './repositories/folders.ts'
19
25
  export * from './repositories/form-entries.ts'
20
26
  export * from './repositories/form-workflows.ts'
@@ -28,9 +34,12 @@ export * from './repositories/redirects.ts'
28
34
  export * from './repositories/references.ts'
29
35
  export * from './repositories/tags.ts'
30
36
  export * from './repositories/templates.ts'
37
+ export * from './repositories/transfer-answers.ts'
31
38
  export * from './repositories/transfer-runs.ts'
32
39
  export * from './repositories/user-data.ts'
33
40
  export * from './repositories/users.ts'
41
+ export * from './repositories/webhooks.ts'
42
+ export * from './schema/approvals.ts'
34
43
  export * from './schema/assistant.ts'
35
44
  export * from './schema/audit-log.ts'
36
45
  export * from './schema/built-in-content-types.ts'
@@ -43,6 +52,8 @@ export * from './schema/client-id-prefix.ts'
43
52
  export * from './schema/content.ts'
44
53
  export * from './schema/content-seed.ts'
45
54
  export * from './schema/created-packages.ts'
55
+ export * from './schema/delivery-member-tokens.ts'
56
+ export * from './schema/diagnostics.ts'
46
57
  export * from './schema/elements.ts'
47
58
  export * from './schema/form-permission-prefix.ts'
48
59
  export * from './schema/group-permissions.ts'
@@ -58,12 +69,18 @@ export * from './schema/rich-text-ui.ts'
58
69
  export * from './schema/search-index.ts'
59
70
  export * from './schema/seed.ts'
60
71
  export * from './schema/since-version.ts'
72
+ export * from './schema/transfer-answers.ts'
61
73
  export * from './schema/transfer-run-package.ts'
62
74
  export * from './schema/transfer-runs.ts'
63
75
  export * from './schema/upgrade-report.ts'
64
76
  export * from './schema/users-localisation.ts'
65
77
  export * from './schema/values.ts'
78
+ export * from './schema/webhooks.ts'
66
79
  export * from './schema-ops.ts'
67
80
  export * from './schema-state.ts'
68
81
  export * from './search.ts'
69
82
  export * from './search-text.ts'
83
+ export * from './setup.ts'
84
+ export * from './tree-invariants.ts'
85
+ export * from './url-options.ts'
86
+ export * from './workflow-store.ts'
package/src/locks.ts CHANGED
@@ -4,8 +4,12 @@
4
4
  * and necessary, while Postgres uses advisory locks.
5
5
  */
6
6
  import { AsyncLocalStorage } from 'node:async_hooks'
7
+ import { insideTransaction } from './ambient.ts'
8
+ import { ContentBusyError, DEFAULT_RETRY, type RetryPolicy, retryContention } from './busy.ts'
7
9
  import type { Db } from './database.ts'
8
10
 
11
+ export const DEFAULT_LOCK_TIMEOUT_MS = 15_000
12
+
9
13
  /** Mirrors Umbraco's Constants.Locks. */
10
14
  export const Locks = {
11
15
  Servers: -331,
@@ -55,49 +59,114 @@ async function reentrant<T>(
55
59
  })
56
60
  }
57
61
 
58
- class SqliteLockManager implements LockManager {
59
- #queues = new Map<number, Promise<unknown>>()
62
+ export interface LockOptions {
63
+ /** How long to wait for the lock before answering busy. */
64
+ timeoutMs?: number
65
+ retry?: RetryPolicy
66
+ }
60
67
 
61
- withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
62
- return reentrant(id, fn, (run) => this.#acquire(id, run))
63
- }
68
+ /**
69
+ * An in-process FIFO per lock id with a bounded wait. Ownership passes straight
70
+ * to the next waiter on release, and a waiter that times out leaves the line.
71
+ */
72
+ export class LockQueue {
73
+ #lines = new Map<number, Array<() => void>>()
64
74
 
65
- async #acquire<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
66
- const previous = this.#queues.get(id) ?? Promise.resolve()
67
- let release!: () => void
68
- const gate = new Promise<void>((resolve) => {
69
- release = resolve
70
- })
71
- this.#queues.set(
72
- id,
73
- previous.then(() => gate),
74
- )
75
- await previous
75
+ async hold<T>(id: number, timeoutMs: number, fn: () => Promise<T>): Promise<T> {
76
+ await this.#acquire(id, timeoutMs)
76
77
  try {
77
78
  return await fn()
78
79
  } finally {
79
- release()
80
- if (this.#queues.get(id) === gate) this.#queues.delete(id)
80
+ this.#release(id)
81
+ }
82
+ }
83
+
84
+ #acquire(id: number, timeoutMs: number): Promise<void> {
85
+ const line = this.#lines.get(id)
86
+ if (!line) {
87
+ this.#lines.set(id, [])
88
+ return Promise.resolve()
81
89
  }
90
+ return new Promise<void>((resolve, reject) => {
91
+ const grant = () => {
92
+ clearTimeout(timer)
93
+ resolve()
94
+ }
95
+ const timer = setTimeout(() => {
96
+ const at = line.indexOf(grant)
97
+ if (at >= 0) line.splice(at, 1)
98
+ reject(new ContentBusyError('lockTimeout'))
99
+ }, timeoutMs)
100
+ line.push(grant)
101
+ })
102
+ }
103
+
104
+ #release(id: number): void {
105
+ const next = this.#lines.get(id)?.shift()
106
+ if (next) next()
107
+ else this.#lines.delete(id)
82
108
  }
83
109
  }
84
110
 
111
+ /** Retries only from outside any transaction: one the database aborted cannot be resumed. */
112
+ function guarded<T>(fn: () => Promise<T>, retry: RetryPolicy): Promise<T> {
113
+ return retryContention(fn, insideTransaction() ? { ...retry, attempts: 1 } : retry)
114
+ }
115
+
116
+ class SqliteLockManager implements LockManager {
117
+ #queue = new LockQueue()
118
+ #timeoutMs: number
119
+ #retry: RetryPolicy
120
+
121
+ constructor(options: LockOptions) {
122
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
123
+ this.#retry = options.retry ?? DEFAULT_RETRY
124
+ }
125
+
126
+ withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
127
+ return reentrant(id, fn, (run) =>
128
+ guarded(() => this.#queue.hold(id, this.#timeoutMs, run), this.#retry),
129
+ )
130
+ }
131
+ }
132
+
133
+ /**
134
+ * The in-process queue first, then the advisory lock, which orders the nodes.
135
+ * Without the queue every waiting request held a pooled connection blocked on
136
+ * the advisory lock, and a pool full of waiters left the holder none to work on.
137
+ */
85
138
  class PostgresLockManager implements LockManager {
86
139
  #db: Db
87
- constructor(db: Db) {
140
+ #queue = new LockQueue()
141
+ #timeoutMs: number
142
+ #retry: RetryPolicy
143
+
144
+ constructor(db: Db, options: LockOptions) {
88
145
  this.#db = db
146
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS
147
+ this.#retry = options.retry ?? DEFAULT_RETRY
89
148
  }
90
149
 
91
150
  withLock<T>(id: LockId, fn: () => Promise<T>): Promise<T> {
151
+ const timeout = Math.max(1, Math.round(this.#timeoutMs))
92
152
  return reentrant(id, fn, (run) =>
93
- this.#db.transaction(async (tx) => {
94
- await tx.exec('SELECT pg_advisory_xact_lock(?)', [id])
95
- return run()
96
- }),
153
+ guarded(
154
+ () =>
155
+ this.#queue.hold(id, this.#timeoutMs, () =>
156
+ this.#db.transaction(async (tx) => {
157
+ await tx.exec(`SET LOCAL lock_timeout = ${timeout}`)
158
+ await tx.exec('SELECT pg_advisory_xact_lock(?)', [id])
159
+ return run()
160
+ }),
161
+ ),
162
+ this.#retry,
163
+ ),
97
164
  )
98
165
  }
99
166
  }
100
167
 
101
- export function createLockManager(db: Db): LockManager {
102
- return db.dialect.name === 'postgres' ? new PostgresLockManager(db) : new SqliteLockManager()
168
+ export function createLockManager(db: Db, options: LockOptions = {}): LockManager {
169
+ return db.dialect.name === 'postgres'
170
+ ? new PostgresLockManager(db, options)
171
+ : new SqliteLockManager(options)
103
172
  }
package/src/migrations.ts CHANGED
@@ -349,6 +349,26 @@ export async function readLedger(db: Db): Promise<LedgerRow[]> {
349
349
  }))
350
350
  }
351
351
 
352
+ /**
353
+ * A step failed and was rolled back; every step before it is applied. Says which,
354
+ * and that running the upgrade again carries on from there.
355
+ */
356
+ export class MigrationStepError extends Error {
357
+ constructor(
358
+ readonly step: string,
359
+ readonly applied: readonly string[],
360
+ readonly bundle: string | undefined,
361
+ override readonly cause: unknown,
362
+ ) {
363
+ const reason = cause instanceof Error ? cause.message : String(cause)
364
+ super(
365
+ `The upgrade stopped at ${bundle ? `the bundle "${bundle}"'s step` : 'step'} ${step}: ${reason}. ` +
366
+ `Nothing from that step was kept${applied.length > 0 ? `; the ${applied.length} step(s) before it are applied` : ''}. ` +
367
+ 'Fix the cause and run the upgrade again: it carries on from this step.',
368
+ )
369
+ }
370
+ }
371
+
352
372
  /** The database is at a state this code's plan does not know: newer code wrote it. */
353
373
  export class UpgradeStateError extends Error {
354
374
  constructor(
@@ -440,23 +460,29 @@ export async function migrate(
440
460
  await verifyApplied(db, plan, state)
441
461
  for (const next of pending) {
442
462
  const started = performance.now()
443
- await db.transaction(async (tx) => {
444
- await next.up(tx)
445
- await next.backfill?.(tx)
446
- })
463
+ // The state and the ledger row commit with the step, so an interrupted
464
+ // run resumes at the first step that did not land and never repeats one.
465
+ await db
466
+ .transaction(async (tx) => {
467
+ await next.up(tx)
468
+ await next.backfill?.(tx)
469
+ await writeState(tx, next.to, plan.stateKey)
470
+ await recordInLedger(tx, {
471
+ name: next.name,
472
+ kind: next.kind ?? 'expand',
473
+ fromState: next.from,
474
+ toState: next.to,
475
+ durationMs: Math.round(performance.now() - started),
476
+ checksum: next.checksum ?? next.name,
477
+ appliedBy: options.by ?? null,
478
+ note: next.release ? `release ${next.release}` : null,
479
+ bundle: plan.bundle ?? null,
480
+ })
481
+ })
482
+ .catch((error: unknown) => {
483
+ throw new MigrationStepError(next.name, applied, plan.bundle, error)
484
+ })
447
485
  state = next.to
448
- await writeState(db, state, plan.stateKey)
449
- await recordInLedger(db, {
450
- name: next.name,
451
- kind: next.kind ?? 'expand',
452
- fromState: next.from,
453
- toState: next.to,
454
- durationMs: Math.round(performance.now() - started),
455
- checksum: next.checksum ?? next.name,
456
- appliedBy: options.by ?? null,
457
- note: next.release ? `release ${next.release}` : null,
458
- bundle: plan.bundle ?? null,
459
- })
460
486
  applied.push(next.name)
461
487
  }
462
488
  return { from, to: state, applied }