velocious 1.0.606 → 1.0.607
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 +44 -1
- package/build/background-jobs/job-semantics.js +145 -0
- package/build/background-jobs/local-adapter.js +161 -0
- package/build/background-jobs/local-dispatcher.js +418 -0
- package/build/background-jobs/local-job-registry.js +59 -0
- package/build/background-jobs/local-store.js +1219 -0
- package/build/background-jobs/store.js +33 -97
- package/build/background-jobs/types.js +26 -1
- package/build/configuration-types.js +3 -1
- package/build/configuration.js +16 -1
- package/build/database/drivers/sqlite/index.js +46 -41
- package/build/environment-handlers/browser.js +10 -0
- package/build/src/background-jobs/job-semantics.d.ts +65 -0
- package/build/src/background-jobs/job-semantics.d.ts.map +1 -0
- package/build/src/background-jobs/job-semantics.js +125 -0
- package/build/src/background-jobs/local-adapter.d.ts +161 -0
- package/build/src/background-jobs/local-adapter.d.ts.map +1 -0
- package/build/src/background-jobs/local-adapter.js +141 -0
- package/build/src/background-jobs/local-dispatcher.d.ts +185 -0
- package/build/src/background-jobs/local-dispatcher.d.ts.map +1 -0
- package/build/src/background-jobs/local-dispatcher.js +383 -0
- package/build/src/background-jobs/local-job-registry.d.ts +26 -0
- package/build/src/background-jobs/local-job-registry.d.ts.map +1 -0
- package/build/src/background-jobs/local-job-registry.js +51 -0
- package/build/src/background-jobs/local-store.d.ts +364 -0
- package/build/src/background-jobs/local-store.d.ts.map +1 -0
- package/build/src/background-jobs/local-store.js +1078 -0
- package/build/src/background-jobs/store.d.ts +0 -9
- package/build/src/background-jobs/store.d.ts.map +1 -1
- package/build/src/background-jobs/store.js +18 -88
- package/build/src/background-jobs/types.d.ts +97 -2
- package/build/src/background-jobs/types.d.ts.map +1 -1
- package/build/src/background-jobs/types.js +27 -2
- package/build/src/configuration-types.d.ts +9 -2
- package/build/src/configuration-types.d.ts.map +1 -1
- package/build/src/configuration-types.js +4 -2
- package/build/src/configuration.d.ts +5 -0
- package/build/src/configuration.d.ts.map +1 -1
- package/build/src/configuration.js +15 -2
- package/build/src/database/drivers/sqlite/index.d.ts +8 -0
- package/build/src/database/drivers/sqlite/index.d.ts.map +1 -1
- package/build/src/database/drivers/sqlite/index.js +47 -41
- package/build/src/environment-handlers/browser.d.ts +9 -0
- package/build/src/environment-handlers/browser.d.ts.map +1 -1
- package/build/src/environment-handlers/browser.js +10 -1
- package/build/src/testing/test-runner.d.ts +42 -10
- package/build/src/testing/test-runner.d.ts.map +1 -1
- package/build/src/testing/test-runner.js +135 -35
- package/build/testing/test-runner.js +141 -33
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/scripts/test-browser.js +5 -1
- package/src/background-jobs/job-semantics.js +145 -0
- package/src/background-jobs/local-adapter.js +161 -0
- package/src/background-jobs/local-dispatcher.js +418 -0
- package/src/background-jobs/local-job-registry.js +59 -0
- package/src/background-jobs/local-store.js +1219 -0
- package/src/background-jobs/store.js +33 -97
- package/src/background-jobs/types.js +26 -1
- package/src/configuration-types.js +3 -1
- package/src/configuration.js +16 -1
- package/src/database/drivers/sqlite/index.js +46 -41
- package/src/environment-handlers/browser.js +10 -0
- package/src/testing/test-runner.js +141 -33
|
@@ -0,0 +1,1219 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import UUID from "pure-uuid"
|
|
4
|
+
|
|
5
|
+
import TableData from "../database/table-data/index.js"
|
|
6
|
+
import TableIndex from "../database/table-data/table-index.js"
|
|
7
|
+
import sha256Hex from "../utils/sha256-hex.js"
|
|
8
|
+
import normalizeBackgroundJobError from "./normalize-error.js"
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_BACKGROUND_JOB_QUEUE,
|
|
11
|
+
QUEUE_CONCURRENCY_KEY_PREFIX,
|
|
12
|
+
normalizeBackgroundJobConcurrency,
|
|
13
|
+
normalizeBackgroundJobExecutionMode,
|
|
14
|
+
normalizeBackgroundJobMaxRetries,
|
|
15
|
+
normalizeBackgroundJobQueue,
|
|
16
|
+
normalizeBackgroundJobScheduledAtMs,
|
|
17
|
+
rescheduledBackgroundJobAtMs,
|
|
18
|
+
retryDelayMs
|
|
19
|
+
} from "./job-semantics.js"
|
|
20
|
+
|
|
21
|
+
export const LOCAL_BACKGROUND_JOBS_TABLE = "velocious_local_background_jobs"
|
|
22
|
+
export const LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE = "velocious_local_background_job_concurrency"
|
|
23
|
+
const MIGRATIONS_TABLE = "velocious_internal_migrations"
|
|
24
|
+
const MIGRATION_SCOPE = "local_background_jobs"
|
|
25
|
+
const MIGRATION_VERSION = "1"
|
|
26
|
+
const LOCAL_EXECUTION_MODES = [/** @type {const} */ ("inline")]
|
|
27
|
+
export const LOCAL_BACKGROUND_JOBS_INDEX_NAMES = [
|
|
28
|
+
"index_velocious_local_background_jobs_due",
|
|
29
|
+
"index_velocious_local_background_jobs_queue_status",
|
|
30
|
+
"index_velocious_local_background_jobs_deduplication",
|
|
31
|
+
"index_velocious_local_background_jobs_concurrency"
|
|
32
|
+
]
|
|
33
|
+
const EXPECTED_JOB_COLUMNS = [
|
|
34
|
+
"id",
|
|
35
|
+
"job_name",
|
|
36
|
+
"args_json",
|
|
37
|
+
"args_digest",
|
|
38
|
+
"execution_mode",
|
|
39
|
+
"queue",
|
|
40
|
+
"max_retries",
|
|
41
|
+
"attempts",
|
|
42
|
+
"status",
|
|
43
|
+
"scheduled_at_ms",
|
|
44
|
+
"created_at_ms",
|
|
45
|
+
"handed_off_at_ms",
|
|
46
|
+
"handoff_id",
|
|
47
|
+
"worker_id",
|
|
48
|
+
"completed_at_ms",
|
|
49
|
+
"failed_at_ms",
|
|
50
|
+
"last_error",
|
|
51
|
+
"concurrency_key",
|
|
52
|
+
"max_concurrency"
|
|
53
|
+
]
|
|
54
|
+
const EXPECTED_CONCURRENCY_COLUMNS = ["concurrency_key", "max_concurrency", "active_count"]
|
|
55
|
+
/** @type {WeakMap<import("../configuration.js").default, Map<string, Promise<void>>>} */
|
|
56
|
+
const deduplicatedEnqueueChains = new WeakMap()
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Creates the production clock used by local dispatch.
|
|
60
|
+
* @returns {import("./types.js").LocalBackgroundJobsClock} - Production clock.
|
|
61
|
+
*/
|
|
62
|
+
export function localBackgroundJobsClock() {
|
|
63
|
+
return {
|
|
64
|
+
clearTimeout: (timerId) => globalThis.clearTimeout(timerId),
|
|
65
|
+
now: () => Date.now(),
|
|
66
|
+
setTimeout: (callback, delayMs) => globalThis.setTimeout(callback, delayMs)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Namespaced portable SQLite persistence for local background jobs. */
|
|
71
|
+
export default class LocalBackgroundJobsStore {
|
|
72
|
+
/**
|
|
73
|
+
* Creates a store for one configuration and local database.
|
|
74
|
+
* @param {object} args - Store options.
|
|
75
|
+
* @param {import("../configuration.js").default} args.configuration - Owning configuration.
|
|
76
|
+
* @param {import("./types.js").LocalBackgroundJobsClock} [args.clock] - Persistence clock.
|
|
77
|
+
* @param {string} [args.databaseIdentifier] - Configured local database identifier.
|
|
78
|
+
* @param {() => void} [args.onCommittedEnqueue] - Commit-aware dispatcher wake.
|
|
79
|
+
*/
|
|
80
|
+
constructor({configuration, clock = localBackgroundJobsClock(), databaseIdentifier, onCommittedEnqueue}) {
|
|
81
|
+
this.clock = clock
|
|
82
|
+
this.configuration = configuration
|
|
83
|
+
this.databaseIdentifier = databaseIdentifier
|
|
84
|
+
this.onCommittedEnqueue = onCommittedEnqueue
|
|
85
|
+
this._isReady = false
|
|
86
|
+
/** @type {Promise<void> | null} */
|
|
87
|
+
this._readyPromise = null
|
|
88
|
+
/** @type {WeakMap<import("../database/drivers/base.js").default, {completion: Promise<void>, promise: Promise<void>}>} */
|
|
89
|
+
this._transactionReadyPromises = new WeakMap()
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolves the configured local database identifier.
|
|
94
|
+
* @returns {string} - Database identifier.
|
|
95
|
+
*/
|
|
96
|
+
getDatabaseIdentifier() {
|
|
97
|
+
return this.databaseIdentifier || this.configuration.getBackgroundJobsConfig().databaseIdentifier
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Ensures the versioned physical schema exists.
|
|
102
|
+
* @returns {Promise<void>} - Resolves when ready.
|
|
103
|
+
*/
|
|
104
|
+
async ensureReady() {
|
|
105
|
+
if (this._isReady) return
|
|
106
|
+
|
|
107
|
+
await this._withDb(async (db) => await this._ensureReadyWithDb(db))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Clears the per-instance readiness latch for a deliberate adapter reopen.
|
|
112
|
+
* @returns {void} - No return value.
|
|
113
|
+
*/
|
|
114
|
+
resetReadiness() {
|
|
115
|
+
this._isReady = false
|
|
116
|
+
this._readyPromise = null
|
|
117
|
+
this._transactionReadyPromises = new WeakMap()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Coordinates physical and transaction-local schema readiness.
|
|
122
|
+
* @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
|
|
123
|
+
* @returns {Promise<void>} - Resolves when this caller can use the schema.
|
|
124
|
+
*/
|
|
125
|
+
async _ensureReadyWithDb(db) {
|
|
126
|
+
if (this._isReady) return
|
|
127
|
+
|
|
128
|
+
const transactionCompletion = db.insideTransaction() ? db.transactionCompletion() : null
|
|
129
|
+
const transactionReady = this._transactionReadyPromises.get(db)
|
|
130
|
+
|
|
131
|
+
if (transactionCompletion && transactionReady?.completion === transactionCompletion) {
|
|
132
|
+
await transactionReady.promise
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (this._readyPromise) {
|
|
137
|
+
const readyPromise = this._readyPromise
|
|
138
|
+
|
|
139
|
+
await readyPromise
|
|
140
|
+
if (this._readyPromise === readyPromise) this._readyPromise = null
|
|
141
|
+
if (this._isReady) return
|
|
142
|
+
|
|
143
|
+
await this.ensureReady()
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (transactionCompletion) {
|
|
148
|
+
const schemaReadyPromise = this._applySchema(db)
|
|
149
|
+
const transactionReadyPromise = schemaReadyPromise.then(() => undefined)
|
|
150
|
+
const transactionReady = {completion: transactionCompletion, promise: transactionReadyPromise}
|
|
151
|
+
const durableReadyPromise = schemaReadyPromise.then(async (changed) => {
|
|
152
|
+
if (!changed) {
|
|
153
|
+
this._isReady = true
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
await transactionCompletion
|
|
158
|
+
}, () => {
|
|
159
|
+
// The transaction-local caller below owns and rethrows this same schema error.
|
|
160
|
+
// This branch only settles the shared durability barrier so it cannot become
|
|
161
|
+
// an independent unhandled rejection while failed ownership is cleared.
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
this._transactionReadyPromises.set(db, transactionReady)
|
|
165
|
+
this._readyPromise = durableReadyPromise
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
await transactionReadyPromise
|
|
169
|
+
} catch (error) {
|
|
170
|
+
if (this._transactionReadyPromises.get(db) === transactionReady) this._transactionReadyPromises.delete(db)
|
|
171
|
+
if (this._readyPromise === durableReadyPromise) this._readyPromise = null
|
|
172
|
+
throw error
|
|
173
|
+
}
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
this._readyPromise = this._transactionResult(db, async () => await this._applySchema(db)).then(() => {
|
|
178
|
+
this._isReady = true
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
await this._readyPromise
|
|
183
|
+
} finally {
|
|
184
|
+
if (!this._isReady) this._readyPromise = null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Creates or repairs version-one tables and indexes.
|
|
190
|
+
* @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
|
|
191
|
+
* @returns {Promise<boolean>} - Whether schema state changed.
|
|
192
|
+
*/
|
|
193
|
+
async _applySchema(db) {
|
|
194
|
+
let changed = false
|
|
195
|
+
|
|
196
|
+
if (!(await db.tableExists(MIGRATIONS_TABLE))) {
|
|
197
|
+
await db.createTable(this._migrationsTableData())
|
|
198
|
+
changed = true
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!(await db.tableExists(LOCAL_BACKGROUND_JOBS_TABLE))) {
|
|
202
|
+
await db.createTable(this._jobsTableData())
|
|
203
|
+
changed = true
|
|
204
|
+
} else {
|
|
205
|
+
await this._assertColumns(db, LOCAL_BACKGROUND_JOBS_TABLE, EXPECTED_JOB_COLUMNS)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (!(await db.tableExists(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE))) {
|
|
209
|
+
await db.createTable(this._concurrencyTableData())
|
|
210
|
+
changed = true
|
|
211
|
+
} else {
|
|
212
|
+
await this._assertColumns(db, LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE, EXPECTED_CONCURRENCY_COLUMNS)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (await this._ensureIndexes(db)) changed = true
|
|
216
|
+
|
|
217
|
+
if (!(await this._hasMigration(db))) {
|
|
218
|
+
await db.upsert({
|
|
219
|
+
tableName: MIGRATIONS_TABLE,
|
|
220
|
+
data: {
|
|
221
|
+
applied_at_ms: this.clock.now(),
|
|
222
|
+
key: this._migrationKey(),
|
|
223
|
+
scope: MIGRATION_SCOPE,
|
|
224
|
+
version: MIGRATION_VERSION
|
|
225
|
+
},
|
|
226
|
+
conflictColumns: ["key"],
|
|
227
|
+
updateColumns: ["scope", "version", "applied_at_ms"]
|
|
228
|
+
})
|
|
229
|
+
changed = true
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return changed
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Builds the migration ledger table definition.
|
|
237
|
+
* @returns {TableData} - Migration ledger table.
|
|
238
|
+
*/
|
|
239
|
+
_migrationsTableData() {
|
|
240
|
+
const table = new TableData(MIGRATIONS_TABLE, {ifNotExists: true})
|
|
241
|
+
|
|
242
|
+
table.string("key", {null: false, primaryKey: true})
|
|
243
|
+
table.string("scope", {null: false})
|
|
244
|
+
table.string("version", {null: false})
|
|
245
|
+
table.bigint("applied_at_ms", {null: false})
|
|
246
|
+
return table
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Builds the local jobs table definition.
|
|
251
|
+
* @returns {TableData} - Local jobs table definition.
|
|
252
|
+
*/
|
|
253
|
+
_jobsTableData() {
|
|
254
|
+
const table = new TableData(LOCAL_BACKGROUND_JOBS_TABLE, {ifNotExists: true})
|
|
255
|
+
|
|
256
|
+
table.string("id", {null: false, primaryKey: true})
|
|
257
|
+
table.string("job_name", {null: false})
|
|
258
|
+
table.text("args_json", {null: false})
|
|
259
|
+
table.string("args_digest", {maxLength: 64, null: false})
|
|
260
|
+
table.string("execution_mode", {null: false})
|
|
261
|
+
table.string("queue", {null: false})
|
|
262
|
+
table.integer("max_retries", {null: false})
|
|
263
|
+
table.integer("attempts", {null: false})
|
|
264
|
+
table.string("status", {null: false})
|
|
265
|
+
table.bigint("scheduled_at_ms", {null: false})
|
|
266
|
+
table.bigint("created_at_ms", {null: false})
|
|
267
|
+
table.bigint("handed_off_at_ms", {null: true})
|
|
268
|
+
table.string("handoff_id", {null: true})
|
|
269
|
+
table.string("worker_id", {null: true})
|
|
270
|
+
table.bigint("completed_at_ms", {null: true})
|
|
271
|
+
table.bigint("failed_at_ms", {null: true})
|
|
272
|
+
table.text("last_error", {null: true})
|
|
273
|
+
table.string("concurrency_key", {null: true})
|
|
274
|
+
table.integer("max_concurrency", {null: true})
|
|
275
|
+
table.addIndex(new TableIndex(["status", "scheduled_at_ms", "created_at_ms", "id"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[0]}))
|
|
276
|
+
table.addIndex(new TableIndex(["queue", "status", "created_at_ms"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[1]}))
|
|
277
|
+
table.addIndex(new TableIndex(["args_digest"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[2]}))
|
|
278
|
+
table.addIndex(new TableIndex(["status", "concurrency_key", "scheduled_at_ms"], {name: LOCAL_BACKGROUND_JOBS_INDEX_NAMES[3]}))
|
|
279
|
+
return table
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Builds the local concurrency counter table definition.
|
|
284
|
+
* @returns {TableData} - Concurrency counter table definition.
|
|
285
|
+
*/
|
|
286
|
+
_concurrencyTableData() {
|
|
287
|
+
const table = new TableData(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE, {ifNotExists: true})
|
|
288
|
+
|
|
289
|
+
table.string("concurrency_key", {null: false, primaryKey: true})
|
|
290
|
+
table.integer("max_concurrency", {null: false})
|
|
291
|
+
table.integer("active_count", {null: false})
|
|
292
|
+
return table
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Rejects an incompatible current-version table rather than rebuilding data.
|
|
297
|
+
* @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
|
|
298
|
+
* @param {string} tableName - Table name.
|
|
299
|
+
* @param {string[]} expectedColumns - Required columns.
|
|
300
|
+
* @returns {Promise<void>} - Resolves when compatible.
|
|
301
|
+
*/
|
|
302
|
+
async _assertColumns(db, tableName, expectedColumns) {
|
|
303
|
+
const table = await db.getTableByNameOrFail(tableName)
|
|
304
|
+
const columns = await table.getColumns()
|
|
305
|
+
const names = new Set(columns.map((column) => column.getName()))
|
|
306
|
+
const missing = expectedColumns.filter((columnName) => !names.has(columnName))
|
|
307
|
+
|
|
308
|
+
if (missing.length === 0) return
|
|
309
|
+
|
|
310
|
+
const error = new Error(`Incompatible local background-jobs schema for ${tableName}; missing columns: ${missing.join(", ")}`)
|
|
311
|
+
|
|
312
|
+
this._reportFrameworkError({error, stage: "local-background-jobs-schema"})
|
|
313
|
+
throw error
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Recreates missing indexes declared by the current schema.
|
|
318
|
+
* @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
|
|
319
|
+
* @returns {Promise<boolean>} - Whether an index was created.
|
|
320
|
+
*/
|
|
321
|
+
async _ensureIndexes(db) {
|
|
322
|
+
db.clearSchemaCache()
|
|
323
|
+
const jobsTable = await db.getTableByNameOrFail(LOCAL_BACKGROUND_JOBS_TABLE)
|
|
324
|
+
const existingNames = new Set((await jobsTable.getIndexes()).map((index) => index.getName()))
|
|
325
|
+
let changed = false
|
|
326
|
+
|
|
327
|
+
for (const index of this._jobsTableData().getIndexes()) {
|
|
328
|
+
const indexName = index.getName()
|
|
329
|
+
|
|
330
|
+
if (!indexName || existingNames.has(indexName)) continue
|
|
331
|
+
|
|
332
|
+
const sqls = await db.createIndexSQLs({
|
|
333
|
+
columns: index.getColumns(),
|
|
334
|
+
ifNotExists: true,
|
|
335
|
+
name: indexName,
|
|
336
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE,
|
|
337
|
+
unique: index.getUnique()
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
for (const sql of sqls) await db.query(sql)
|
|
341
|
+
changed = true
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (changed) db.clearSchemaCache()
|
|
345
|
+
return changed
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Checks whether the current local schema version is recorded.
|
|
350
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
351
|
+
* @returns {Promise<boolean>} - Whether version one is recorded.
|
|
352
|
+
*/
|
|
353
|
+
async _hasMigration(db) {
|
|
354
|
+
const rows = await db
|
|
355
|
+
.newQuery()
|
|
356
|
+
.from(MIGRATIONS_TABLE)
|
|
357
|
+
.where({key: this._migrationKey()})
|
|
358
|
+
.limit(1)
|
|
359
|
+
.results()
|
|
360
|
+
|
|
361
|
+
return rows.length > 0
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Builds the scoped migration key.
|
|
366
|
+
* @returns {string} - Scoped migration key.
|
|
367
|
+
*/
|
|
368
|
+
_migrationKey() { return `${MIGRATION_SCOPE}:${MIGRATION_VERSION}` }
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Enqueues a local job in the caller's active transaction when present.
|
|
372
|
+
* @param {object} args - Enqueue request.
|
|
373
|
+
* @param {string} args.jobName - Registered job name.
|
|
374
|
+
* @param {Array<ReturnType<typeof JSON.parse>>} args.args - Serialized job arguments.
|
|
375
|
+
* @param {import("./types.js").BackgroundJobOptions} [args.options] - Job options.
|
|
376
|
+
* @returns {Promise<string>} - Durable job id.
|
|
377
|
+
*/
|
|
378
|
+
async enqueue({jobName, args, options = {}}) {
|
|
379
|
+
await this.ensureReady()
|
|
380
|
+
|
|
381
|
+
const preparedJob = this._prepareJob({args, jobName, options})
|
|
382
|
+
const mutate = async (holdUntil = (/** @type {Promise<void>} */ _completion) => {}) => await this._withDb(async (connection) => {
|
|
383
|
+
if (connection.insideTransaction()) holdUntil(connection.transactionCompletion())
|
|
384
|
+
|
|
385
|
+
return await this._mutate(connection, async (db) => {
|
|
386
|
+
let jobId = preparedJob.jobId
|
|
387
|
+
|
|
388
|
+
if (preparedJob.concurrency) await this._ensureConcurrency(db, preparedJob.concurrency)
|
|
389
|
+
|
|
390
|
+
if (options.deduplicateWhileQueued) {
|
|
391
|
+
const existing = await db
|
|
392
|
+
.newQuery()
|
|
393
|
+
.from(LOCAL_BACKGROUND_JOBS_TABLE)
|
|
394
|
+
.select("id")
|
|
395
|
+
.where({
|
|
396
|
+
args_digest: preparedJob.argsDigest,
|
|
397
|
+
args_json: preparedJob.argsJson,
|
|
398
|
+
job_name: preparedJob.jobName,
|
|
399
|
+
queue: preparedJob.queue,
|
|
400
|
+
status: "queued"
|
|
401
|
+
})
|
|
402
|
+
.where(`scheduled_at_ms <= ${db.quote(preparedJob.scheduledAtMs)}`)
|
|
403
|
+
.order("scheduled_at_ms ASC")
|
|
404
|
+
.order("created_at_ms ASC")
|
|
405
|
+
.limit(1)
|
|
406
|
+
.results()
|
|
407
|
+
|
|
408
|
+
const existingRow = /** @type {{id: string | number} | undefined} */ (existing[0])
|
|
409
|
+
|
|
410
|
+
if (existingRow) jobId = String(existingRow.id)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
if (jobId === preparedJob.jobId) await this._insertPreparedJob(db, preparedJob)
|
|
414
|
+
if (this.onCommittedEnqueue) await db.afterCommit(this.onCommittedEnqueue)
|
|
415
|
+
|
|
416
|
+
return jobId
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
if (options.deduplicateWhileQueued) return await this._serializeDeduplicatedEnqueue(preparedJob, mutate)
|
|
421
|
+
return await mutate()
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Serializes matching in-process deduplication checks through commit while
|
|
426
|
+
* leaving unrelated job identities independent.
|
|
427
|
+
* @template T
|
|
428
|
+
* @param {import("./types.js").PreparedLocalBackgroundJob} preparedJob - Prepared job identity.
|
|
429
|
+
* @param {(holdUntil: (completion: Promise<void>) => void) => Promise<T>} callback - Deduplication mutation.
|
|
430
|
+
* @returns {Promise<T>} - Mutation result.
|
|
431
|
+
*/
|
|
432
|
+
async _serializeDeduplicatedEnqueue(preparedJob, callback) {
|
|
433
|
+
let chains = deduplicatedEnqueueChains.get(this.configuration)
|
|
434
|
+
|
|
435
|
+
if (!chains) {
|
|
436
|
+
chains = new Map()
|
|
437
|
+
deduplicatedEnqueueChains.set(this.configuration, chains)
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
const key = sha256Hex(JSON.stringify([
|
|
441
|
+
this.getDatabaseIdentifier(),
|
|
442
|
+
preparedJob.jobName,
|
|
443
|
+
preparedJob.argsDigest,
|
|
444
|
+
preparedJob.queue
|
|
445
|
+
]))
|
|
446
|
+
const previous = chains.get(key) || Promise.resolve()
|
|
447
|
+
let release = () => {}
|
|
448
|
+
const running = new Promise((resolve) => { release = () => resolve(undefined) })
|
|
449
|
+
const chain = previous.then(() => running)
|
|
450
|
+
/** @type {Promise<void> | undefined} */
|
|
451
|
+
let completion
|
|
452
|
+
const finish = () => {
|
|
453
|
+
release()
|
|
454
|
+
if (chains.get(key) === chain) chains.delete(key)
|
|
455
|
+
if (chains.size === 0) deduplicatedEnqueueChains.delete(this.configuration)
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
chains.set(key, chain)
|
|
459
|
+
await previous
|
|
460
|
+
|
|
461
|
+
try {
|
|
462
|
+
const result = await callback((transactionCompletion) => { completion = transactionCompletion })
|
|
463
|
+
|
|
464
|
+
if (completion) {
|
|
465
|
+
completion.then(finish, finish)
|
|
466
|
+
} else {
|
|
467
|
+
finish()
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return result
|
|
471
|
+
} catch (error) {
|
|
472
|
+
finish()
|
|
473
|
+
throw error
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Prepares validated local job data for insertion.
|
|
479
|
+
* @param {{args: Array<ReturnType<typeof JSON.parse>>, jobName: string, options: import("./types.js").BackgroundJobOptions}} args - Job request.
|
|
480
|
+
* @returns {import("./types.js").PreparedLocalBackgroundJob} - Prepared row data.
|
|
481
|
+
*/
|
|
482
|
+
_prepareJob({args, jobName, options}) {
|
|
483
|
+
if (options.idempotencyKey !== undefined) {
|
|
484
|
+
throw new Error("idempotencyKey is not supported by the local background-jobs adapter")
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const createdAtMs = this.clock.now()
|
|
488
|
+
const queue = normalizeBackgroundJobQueue(options)
|
|
489
|
+
const queues = this.configuration.getBackgroundJobsConfig().queues
|
|
490
|
+
const argsJson = JSON.stringify(args || [])
|
|
491
|
+
const executionMode = normalizeBackgroundJobExecutionMode(options, "inline", LOCAL_EXECUTION_MODES)
|
|
492
|
+
|
|
493
|
+
if (typeof argsJson !== "string") throw new TypeError("Local background job arguments must be JSON serializable")
|
|
494
|
+
if (executionMode !== "inline") throw new Error("Local background job execution mode invariant was violated")
|
|
495
|
+
|
|
496
|
+
return {
|
|
497
|
+
argsDigest: sha256Hex(argsJson),
|
|
498
|
+
argsJson,
|
|
499
|
+
concurrency: normalizeBackgroundJobConcurrency({options, queue, queues}),
|
|
500
|
+
createdAtMs,
|
|
501
|
+
executionMode,
|
|
502
|
+
jobId: new UUID(4).format(),
|
|
503
|
+
jobName,
|
|
504
|
+
maxRetries: normalizeBackgroundJobMaxRetries(options.maxRetries),
|
|
505
|
+
queue,
|
|
506
|
+
scheduledAtMs: normalizeBackgroundJobScheduledAtMs(options.scheduledAtMs, createdAtMs)
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Inserts one prepared local job row and its concurrency metadata.
|
|
512
|
+
* @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
|
|
513
|
+
* @param {import("./types.js").PreparedLocalBackgroundJob} preparedJob - Prepared row data.
|
|
514
|
+
* @returns {Promise<void>} - Resolves after insertion.
|
|
515
|
+
*/
|
|
516
|
+
async _insertPreparedJob(db, preparedJob) {
|
|
517
|
+
await db.insert({
|
|
518
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE,
|
|
519
|
+
data: {
|
|
520
|
+
args_digest: preparedJob.argsDigest,
|
|
521
|
+
args_json: preparedJob.argsJson,
|
|
522
|
+
attempts: 0,
|
|
523
|
+
completed_at_ms: null,
|
|
524
|
+
concurrency_key: preparedJob.concurrency?.concurrencyKey || null,
|
|
525
|
+
created_at_ms: preparedJob.createdAtMs,
|
|
526
|
+
execution_mode: preparedJob.executionMode,
|
|
527
|
+
failed_at_ms: null,
|
|
528
|
+
handed_off_at_ms: null,
|
|
529
|
+
handoff_id: null,
|
|
530
|
+
id: preparedJob.jobId,
|
|
531
|
+
job_name: preparedJob.jobName,
|
|
532
|
+
last_error: null,
|
|
533
|
+
max_concurrency: preparedJob.concurrency?.maxConcurrency || null,
|
|
534
|
+
max_retries: preparedJob.maxRetries,
|
|
535
|
+
queue: preparedJob.queue,
|
|
536
|
+
scheduled_at_ms: preparedJob.scheduledAtMs,
|
|
537
|
+
status: "queued",
|
|
538
|
+
worker_id: null
|
|
539
|
+
}
|
|
540
|
+
})
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Reconciles configured queue-derived caps and durable counters.
|
|
545
|
+
* @returns {Promise<void>} - Resolves after reconciliation.
|
|
546
|
+
*/
|
|
547
|
+
async reconcileQueueConcurrency() {
|
|
548
|
+
await this.ensureReady()
|
|
549
|
+
|
|
550
|
+
await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
|
|
551
|
+
const queues = this.configuration.getBackgroundJobsConfig().queues
|
|
552
|
+
const rows = await db.newQuery().from(LOCAL_BACKGROUND_JOBS_TABLE).where({status: "queued"}).results()
|
|
553
|
+
|
|
554
|
+
for (const rawRow of rows) {
|
|
555
|
+
await this._reconcileQueuedJobConcurrency(db, this._normalizeRow(rawRow), queues)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
await this._rebuildConcurrencyCounts(db)
|
|
559
|
+
}))
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Applies current queue-derived concurrency policy to one queued row.
|
|
564
|
+
* Explicit concurrency keys remain owned by the enqueue contract.
|
|
565
|
+
* @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
|
|
566
|
+
* @param {import("./types.js").BackgroundJobRow} job - Queued job snapshot.
|
|
567
|
+
* @param {Record<string, {maxConcurrent?: number, priority?: number}>} queues - Current queue policy snapshot.
|
|
568
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow>} - Reconciled snapshot.
|
|
569
|
+
*/
|
|
570
|
+
async _reconcileQueuedJobConcurrency(db, job, queues) {
|
|
571
|
+
const currentIsQueueDerived = Boolean(job.concurrencyKey?.startsWith(QUEUE_CONCURRENCY_KEY_PREFIX))
|
|
572
|
+
|
|
573
|
+
if (job.concurrencyKey && !currentIsQueueDerived) return job
|
|
574
|
+
|
|
575
|
+
const concurrency = normalizeBackgroundJobConcurrency({
|
|
576
|
+
options: {},
|
|
577
|
+
queue: job.queue,
|
|
578
|
+
queues
|
|
579
|
+
})
|
|
580
|
+
|
|
581
|
+
if (!concurrency) {
|
|
582
|
+
if (currentIsQueueDerived) {
|
|
583
|
+
await db.update({
|
|
584
|
+
conditions: {id: job.id, status: "queued"},
|
|
585
|
+
data: {concurrency_key: null, max_concurrency: null},
|
|
586
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE
|
|
587
|
+
})
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return {...job, concurrencyKey: null, maxConcurrency: null}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
await this._ensureConcurrency(db, concurrency)
|
|
594
|
+
if (job.concurrencyKey !== concurrency.concurrencyKey || job.maxConcurrency !== concurrency.maxConcurrency) {
|
|
595
|
+
await db.update({
|
|
596
|
+
conditions: {id: job.id, status: "queued"},
|
|
597
|
+
data: {concurrency_key: concurrency.concurrencyKey, max_concurrency: concurrency.maxConcurrency},
|
|
598
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE
|
|
599
|
+
})
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
return {...job, concurrencyKey: concurrency.concurrencyKey, maxConcurrency: concurrency.maxConcurrency}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Finds the next eligible local job.
|
|
607
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Next eligible local job.
|
|
608
|
+
*/
|
|
609
|
+
async nextAvailableJob() {
|
|
610
|
+
await this.ensureReady()
|
|
611
|
+
|
|
612
|
+
return await this._withDb(async (db) => {
|
|
613
|
+
const jobsTable = db.quoteTable(LOCAL_BACKGROUND_JOBS_TABLE)
|
|
614
|
+
const concurrencyTable = db.quoteTable(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE)
|
|
615
|
+
const priorityOrder = this._queuePriorityOrderSql(db)
|
|
616
|
+
let query = db
|
|
617
|
+
.newQuery()
|
|
618
|
+
.from(LOCAL_BACKGROUND_JOBS_TABLE)
|
|
619
|
+
.where({status: "queued"})
|
|
620
|
+
.where(`scheduled_at_ms <= ${db.quote(this.clock.now())}`)
|
|
621
|
+
.where(
|
|
622
|
+
`(${jobsTable}.${db.quoteColumn("concurrency_key")} IS NULL OR EXISTS (` +
|
|
623
|
+
`SELECT 1 FROM ${concurrencyTable} WHERE ` +
|
|
624
|
+
`${concurrencyTable}.${db.quoteColumn("concurrency_key")} = ${jobsTable}.${db.quoteColumn("concurrency_key")} AND ` +
|
|
625
|
+
`${concurrencyTable}.${db.quoteColumn("active_count")} < ${concurrencyTable}.${db.quoteColumn("max_concurrency")}))`
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
if (priorityOrder) query = query.order(`${priorityOrder} DESC`)
|
|
629
|
+
|
|
630
|
+
const rows = await query
|
|
631
|
+
.order("scheduled_at_ms ASC")
|
|
632
|
+
.order("created_at_ms ASC")
|
|
633
|
+
.order("id ASC")
|
|
634
|
+
.limit(1)
|
|
635
|
+
.results()
|
|
636
|
+
|
|
637
|
+
return rows[0] ? this._normalizeRow(rows[0]) : null
|
|
638
|
+
})
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Finds the soonest future queued job.
|
|
643
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Soonest future queued job.
|
|
644
|
+
*/
|
|
645
|
+
async nextScheduledJob() {
|
|
646
|
+
await this.ensureReady()
|
|
647
|
+
|
|
648
|
+
return await this._withDb(async (db) => {
|
|
649
|
+
const rows = await db
|
|
650
|
+
.newQuery()
|
|
651
|
+
.from(LOCAL_BACKGROUND_JOBS_TABLE)
|
|
652
|
+
.where({status: "queued"})
|
|
653
|
+
.where(`scheduled_at_ms > ${db.quote(this.clock.now())}`)
|
|
654
|
+
.order("scheduled_at_ms ASC")
|
|
655
|
+
.order("created_at_ms ASC")
|
|
656
|
+
.order("id ASC")
|
|
657
|
+
.limit(1)
|
|
658
|
+
.results()
|
|
659
|
+
|
|
660
|
+
return rows[0] ? this._normalizeRow(rows[0]) : null
|
|
661
|
+
})
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* Finds a persisted local job by id.
|
|
666
|
+
* @param {string} jobId - Job id.
|
|
667
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Persisted job.
|
|
668
|
+
*/
|
|
669
|
+
async getJob(jobId) {
|
|
670
|
+
await this.ensureReady()
|
|
671
|
+
|
|
672
|
+
return await this._withDb(async (db) => await this._getJob(db, jobId))
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Lists local jobs in creation order.
|
|
677
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow[]>} - All local jobs in creation order.
|
|
678
|
+
*/
|
|
679
|
+
async listJobs() {
|
|
680
|
+
await this.ensureReady()
|
|
681
|
+
|
|
682
|
+
return await this._withDb(async (db) => {
|
|
683
|
+
const rows = await db
|
|
684
|
+
.newQuery()
|
|
685
|
+
.from(LOCAL_BACKGROUND_JOBS_TABLE)
|
|
686
|
+
.order("created_at_ms ASC")
|
|
687
|
+
.order("id ASC")
|
|
688
|
+
.results()
|
|
689
|
+
|
|
690
|
+
return rows.map((row) => this._normalizeRow(row))
|
|
691
|
+
})
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Atomically reserves concurrency and claims one queued job.
|
|
696
|
+
* @param {{jobId: string, workerId?: string}} args - Claim request.
|
|
697
|
+
* @returns {Promise<import("./types.js").BackgroundJobHandoff | null>} - Fenced claim.
|
|
698
|
+
*/
|
|
699
|
+
async markHandedOff({jobId, workerId}) {
|
|
700
|
+
await this.ensureReady()
|
|
701
|
+
|
|
702
|
+
return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
|
|
703
|
+
const job = await this._getJob(db, jobId)
|
|
704
|
+
|
|
705
|
+
if (!job || job.status !== "queued" || Number(job.scheduledAtMs) > this.clock.now()) return null
|
|
706
|
+
if (job.concurrencyKey && !(await this._reserveConcurrency(db, job.concurrencyKey))) return null
|
|
707
|
+
|
|
708
|
+
const handedOffAtMs = this.clock.now()
|
|
709
|
+
const handoffId = new UUID(4).format()
|
|
710
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
711
|
+
conditions: {id: jobId, status: "queued"},
|
|
712
|
+
data: {handed_off_at_ms: handedOffAtMs, handoff_id: handoffId, status: "handed_off", worker_id: workerId || "local"},
|
|
713
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE
|
|
714
|
+
})
|
|
715
|
+
|
|
716
|
+
if (affectedRows !== 1) {
|
|
717
|
+
await this._releaseConcurrency(db, job.concurrencyKey)
|
|
718
|
+
return null
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
return {handedOffAtMs, handoffId}
|
|
722
|
+
}))
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Finds active local handoffs owned by one worker.
|
|
727
|
+
* @param {{workerId: string}} args - Worker identity.
|
|
728
|
+
* @returns {Promise<Array<{jobId: string, handoffId: string}>>} - Active worker handoffs.
|
|
729
|
+
*/
|
|
730
|
+
async handedOffJobsForWorker({workerId}) {
|
|
731
|
+
await this.ensureReady()
|
|
732
|
+
|
|
733
|
+
const rows = await this._withDb(async (db) => await db
|
|
734
|
+
.newQuery()
|
|
735
|
+
.from(LOCAL_BACKGROUND_JOBS_TABLE)
|
|
736
|
+
.where({status: "handed_off", worker_id: workerId})
|
|
737
|
+
.results())
|
|
738
|
+
/** @type {Array<{jobId: string, handoffId: string}>} */
|
|
739
|
+
const handoffs = []
|
|
740
|
+
|
|
741
|
+
for (const rawRow of rows) {
|
|
742
|
+
const job = this._normalizeRow(rawRow)
|
|
743
|
+
|
|
744
|
+
if (job.handoffId) handoffs.push({jobId: job.id, handoffId: job.handoffId})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
return handoffs
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Returns an exact active handoff to the queue.
|
|
752
|
+
* @param {{jobId: string, handoffId: string}} args - Handoff release.
|
|
753
|
+
* @returns {Promise<void>} - Resolves after the fenced release.
|
|
754
|
+
*/
|
|
755
|
+
async markReturnedToQueue({jobId, handoffId}) {
|
|
756
|
+
await this.ensureReady()
|
|
757
|
+
|
|
758
|
+
await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
|
|
759
|
+
const job = await this._getJob(db, jobId)
|
|
760
|
+
|
|
761
|
+
if (!this._acceptsHandoff(job, handoffId)) return
|
|
762
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
763
|
+
|
|
764
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
765
|
+
conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"},
|
|
766
|
+
data: {
|
|
767
|
+
handed_off_at_ms: null,
|
|
768
|
+
handoff_id: null,
|
|
769
|
+
scheduled_at_ms: this.clock.now(),
|
|
770
|
+
status: "queued",
|
|
771
|
+
worker_id: null
|
|
772
|
+
},
|
|
773
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE
|
|
774
|
+
})
|
|
775
|
+
|
|
776
|
+
if (affectedRows === 1) await this._releaseConcurrency(db, job.concurrencyKey)
|
|
777
|
+
}))
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Applies a fenced successful acknowledgement.
|
|
782
|
+
* @param {{jobId: string, handoffId?: string}} args - Completion report.
|
|
783
|
+
* @returns {Promise<boolean>} - Whether the lease won.
|
|
784
|
+
*/
|
|
785
|
+
async markCompleted({jobId, handoffId}) {
|
|
786
|
+
await this.ensureReady()
|
|
787
|
+
|
|
788
|
+
return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
|
|
789
|
+
const job = await this._getJob(db, jobId)
|
|
790
|
+
|
|
791
|
+
if (!this._acceptsHandoff(job, handoffId)) return false
|
|
792
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
793
|
+
|
|
794
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
795
|
+
conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"},
|
|
796
|
+
data: {completed_at_ms: this.clock.now(), status: "completed"},
|
|
797
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE
|
|
798
|
+
})
|
|
799
|
+
|
|
800
|
+
if (affectedRows !== 1) return false
|
|
801
|
+
await this._releaseConcurrency(db, job.concurrencyKey)
|
|
802
|
+
return true
|
|
803
|
+
}))
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Applies a fenced reschedule without consuming an attempt.
|
|
808
|
+
* @param {{jobId: string, handoffId?: string, delayMs: number}} args - Reschedule report.
|
|
809
|
+
* @returns {Promise<boolean>} - Whether the lease won.
|
|
810
|
+
*/
|
|
811
|
+
async markRescheduled({jobId, handoffId, delayMs}) {
|
|
812
|
+
await this.ensureReady()
|
|
813
|
+
|
|
814
|
+
return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
|
|
815
|
+
const job = await this._getJob(db, jobId)
|
|
816
|
+
|
|
817
|
+
if (!this._acceptsHandoff(job, handoffId)) return false
|
|
818
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
819
|
+
|
|
820
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
821
|
+
conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"},
|
|
822
|
+
data: {
|
|
823
|
+
handed_off_at_ms: null,
|
|
824
|
+
handoff_id: null,
|
|
825
|
+
scheduled_at_ms: rescheduledBackgroundJobAtMs(delayMs, this.clock.now()),
|
|
826
|
+
status: "queued",
|
|
827
|
+
worker_id: null
|
|
828
|
+
},
|
|
829
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE
|
|
830
|
+
})
|
|
831
|
+
|
|
832
|
+
if (affectedRows !== 1) return false
|
|
833
|
+
await this._releaseConcurrency(db, job.concurrencyKey)
|
|
834
|
+
return true
|
|
835
|
+
}))
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
/**
|
|
839
|
+
* Applies a fenced failure, retry, or terminal transition.
|
|
840
|
+
* @param {{jobId: string, handoffId?: string, error: ReturnType<typeof JSON.parse>}} args - Failure report.
|
|
841
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Accepted transition snapshot.
|
|
842
|
+
*/
|
|
843
|
+
async markFailed({jobId, handoffId, error}) {
|
|
844
|
+
await this.ensureReady()
|
|
845
|
+
|
|
846
|
+
return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
|
|
847
|
+
const job = await this._getJob(db, jobId)
|
|
848
|
+
|
|
849
|
+
if (!this._acceptsHandoff(job, handoffId)) return null
|
|
850
|
+
|
|
851
|
+
return await this._applyFailure(db, job, error)
|
|
852
|
+
}))
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Turns every abandoned local handoff into the normal failure/retry path.
|
|
857
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow[]>} - Recovered transitions.
|
|
858
|
+
*/
|
|
859
|
+
async recoverHandedOffJobs() {
|
|
860
|
+
await this.ensureReady()
|
|
861
|
+
|
|
862
|
+
return await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
|
|
863
|
+
const queues = this.configuration.getBackgroundJobsConfig().queues
|
|
864
|
+
const rows = await db.newQuery().from(LOCAL_BACKGROUND_JOBS_TABLE).where({status: "handed_off"}).results()
|
|
865
|
+
/** @type {import("./types.js").BackgroundJobRow[]} */
|
|
866
|
+
const recovered = []
|
|
867
|
+
|
|
868
|
+
for (const rawRow of rows) {
|
|
869
|
+
const job = this._normalizeRow(rawRow)
|
|
870
|
+
const updated = await this._applyFailure(db, job, new Error("Local background job recovered after an interrupted dispatcher"))
|
|
871
|
+
|
|
872
|
+
if (!updated) continue
|
|
873
|
+
|
|
874
|
+
const reconciled = updated.status === "queued"
|
|
875
|
+
? await this._reconcileQueuedJobConcurrency(db, updated, queues)
|
|
876
|
+
: updated
|
|
877
|
+
|
|
878
|
+
recovered.push(reconciled)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
await this._rebuildConcurrencyCounts(db)
|
|
882
|
+
return recovered
|
|
883
|
+
}))
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* Deletes local queue state for focused tests.
|
|
888
|
+
* @returns {Promise<void>} - Resolves after deletion.
|
|
889
|
+
*/
|
|
890
|
+
async clearAll() {
|
|
891
|
+
await this.ensureReady()
|
|
892
|
+
await this._withDb(async (connection) => await this._mutate(connection, async (db) => {
|
|
893
|
+
await db.query(`DELETE FROM ${db.quoteTable(LOCAL_BACKGROUND_JOBS_TABLE)}`)
|
|
894
|
+
await db.query(`DELETE FROM ${db.quoteTable(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE)}`)
|
|
895
|
+
}))
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/**
|
|
899
|
+
* Applies the common retry or exhausted failure transition.
|
|
900
|
+
* @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
|
|
901
|
+
* @param {import("./types.js").BackgroundJobRow} job - Active handoff.
|
|
902
|
+
* @param {ReturnType<typeof JSON.parse>} error - Performance error.
|
|
903
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Transition snapshot.
|
|
904
|
+
*/
|
|
905
|
+
async _applyFailure(db, job, error) {
|
|
906
|
+
const attempts = (job.attempts || 0) + 1
|
|
907
|
+
const maxRetries = normalizeBackgroundJobMaxRetries(job.maxRetries)
|
|
908
|
+
const willRetry = attempts <= maxRetries
|
|
909
|
+
const nowMs = this.clock.now()
|
|
910
|
+
/** @type {Record<string, ReturnType<typeof JSON.parse>>} */
|
|
911
|
+
const data = {
|
|
912
|
+
attempts,
|
|
913
|
+
handed_off_at_ms: null,
|
|
914
|
+
handoff_id: null,
|
|
915
|
+
last_error: normalizeBackgroundJobError(error),
|
|
916
|
+
status: willRetry ? "queued" : "failed",
|
|
917
|
+
worker_id: null
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
if (willRetry) {
|
|
921
|
+
Object.assign(data, {scheduled_at_ms: nowMs + retryDelayMs(attempts)})
|
|
922
|
+
} else {
|
|
923
|
+
Object.assign(data, {failed_at_ms: nowMs})
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
await this._lockConcurrencyRow(db, job.concurrencyKey)
|
|
927
|
+
const affectedRows = await this._updateAffectedRows(db, {
|
|
928
|
+
conditions: {handoff_id: job.handoffId, id: job.id, status: "handed_off"},
|
|
929
|
+
data,
|
|
930
|
+
tableName: LOCAL_BACKGROUND_JOBS_TABLE
|
|
931
|
+
})
|
|
932
|
+
|
|
933
|
+
if (affectedRows !== 1) return null
|
|
934
|
+
await this._releaseConcurrency(db, job.concurrencyKey)
|
|
935
|
+
|
|
936
|
+
return {
|
|
937
|
+
...job,
|
|
938
|
+
attempts,
|
|
939
|
+
failedAtMs: willRetry ? job.failedAtMs : nowMs,
|
|
940
|
+
handedOffAtMs: null,
|
|
941
|
+
handoffId: null,
|
|
942
|
+
lastError: data.last_error,
|
|
943
|
+
scheduledAtMs: willRetry ? Number(data.scheduled_at_ms) : job.scheduledAtMs,
|
|
944
|
+
status: data.status,
|
|
945
|
+
workerId: null
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
/**
|
|
950
|
+
* Ensures that a durable concurrency counter exists with the required cap.
|
|
951
|
+
* @param {import("../database/drivers/base.js").default} db - Local SQLite connection.
|
|
952
|
+
* @param {import("./types.js").ResolvedBackgroundJobConcurrency} concurrency - Desired counter.
|
|
953
|
+
* @returns {Promise<void>} - Resolves when ensured.
|
|
954
|
+
*/
|
|
955
|
+
async _ensureConcurrency(db, concurrency) {
|
|
956
|
+
await db.upsert({
|
|
957
|
+
conflictColumns: ["concurrency_key"],
|
|
958
|
+
data: {active_count: 0, concurrency_key: concurrency.concurrencyKey, max_concurrency: concurrency.maxConcurrency},
|
|
959
|
+
tableName: LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE,
|
|
960
|
+
updateColumns: ["concurrency_key"]
|
|
961
|
+
})
|
|
962
|
+
|
|
963
|
+
const rows = await db
|
|
964
|
+
.newQuery()
|
|
965
|
+
.from(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE)
|
|
966
|
+
.where({concurrency_key: concurrency.concurrencyKey})
|
|
967
|
+
.limit(1)
|
|
968
|
+
.results()
|
|
969
|
+
|
|
970
|
+
const existingRow = /** @type {{max_concurrency: number | string}} */ (rows[0])
|
|
971
|
+
const existingCap = Number(existingRow.max_concurrency)
|
|
972
|
+
|
|
973
|
+
if (existingCap === concurrency.maxConcurrency) return
|
|
974
|
+
if (!concurrency.queueDerived) throw new Error(`Conflicting maxConcurrency for background job concurrencyKey: ${concurrency.concurrencyKey}`)
|
|
975
|
+
|
|
976
|
+
await db.update({
|
|
977
|
+
conditions: {concurrency_key: concurrency.concurrencyKey},
|
|
978
|
+
data: {max_concurrency: concurrency.maxConcurrency},
|
|
979
|
+
tableName: LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE
|
|
980
|
+
})
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Atomically reserves one slot for a concurrency key.
|
|
985
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
986
|
+
* @param {string} concurrencyKey - Concurrency key.
|
|
987
|
+
* @returns {Promise<boolean>} - Whether a slot was reserved.
|
|
988
|
+
*/
|
|
989
|
+
async _reserveConcurrency(db, concurrencyKey) {
|
|
990
|
+
const table = db.quoteTable(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE)
|
|
991
|
+
const count = db.quoteColumn("active_count")
|
|
992
|
+
const affectedRows = await db.affectedRows(
|
|
993
|
+
`UPDATE ${table} SET ${count} = ${count} + 1 ` +
|
|
994
|
+
`WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)} ` +
|
|
995
|
+
`AND ${count} < ${db.quoteColumn("max_concurrency")}`
|
|
996
|
+
)
|
|
997
|
+
|
|
998
|
+
return affectedRows === 1
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Releases one slot for a concurrency key.
|
|
1003
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
1004
|
+
* @param {string | null} concurrencyKey - Concurrency key.
|
|
1005
|
+
* @returns {Promise<void>} - Resolves after release.
|
|
1006
|
+
*/
|
|
1007
|
+
async _releaseConcurrency(db, concurrencyKey) {
|
|
1008
|
+
if (!concurrencyKey) return
|
|
1009
|
+
|
|
1010
|
+
const table = db.quoteTable(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE)
|
|
1011
|
+
const count = db.quoteColumn("active_count")
|
|
1012
|
+
|
|
1013
|
+
await db.affectedRows(
|
|
1014
|
+
`UPDATE ${table} SET ${count} = ${count} - 1 ` +
|
|
1015
|
+
`WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)} AND ${count} > 0`
|
|
1016
|
+
)
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Acquires the transaction's write lock for a concurrency counter row.
|
|
1021
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
1022
|
+
* @param {string | null} concurrencyKey - Concurrency key.
|
|
1023
|
+
* @returns {Promise<void>} - Resolves after locking.
|
|
1024
|
+
*/
|
|
1025
|
+
async _lockConcurrencyRow(db, concurrencyKey) {
|
|
1026
|
+
if (!concurrencyKey) return
|
|
1027
|
+
|
|
1028
|
+
const table = db.quoteTable(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE)
|
|
1029
|
+
const count = db.quoteColumn("active_count")
|
|
1030
|
+
|
|
1031
|
+
await db.query(
|
|
1032
|
+
`UPDATE ${table} SET ${count} = ${count} ` +
|
|
1033
|
+
`WHERE ${db.quoteColumn("concurrency_key")} = ${db.quote(concurrencyKey)}`
|
|
1034
|
+
)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Rebuilds active counters from durable handed-off jobs.
|
|
1039
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
1040
|
+
* @returns {Promise<void>} - Resolves after counter rebuild.
|
|
1041
|
+
*/
|
|
1042
|
+
async _rebuildConcurrencyCounts(db) {
|
|
1043
|
+
const concurrencyTable = db.quoteTable(LOCAL_BACKGROUND_JOB_CONCURRENCY_TABLE)
|
|
1044
|
+
const jobsTable = db.quoteTable(LOCAL_BACKGROUND_JOBS_TABLE)
|
|
1045
|
+
|
|
1046
|
+
await db.query(
|
|
1047
|
+
`UPDATE ${concurrencyTable} SET ${db.quoteColumn("active_count")} = (` +
|
|
1048
|
+
`SELECT COUNT(*) FROM ${jobsTable} WHERE ${jobsTable}.${db.quoteColumn("status")} = ${db.quote("handed_off")} AND ` +
|
|
1049
|
+
`${jobsTable}.${db.quoteColumn("concurrency_key")} = ${concurrencyTable}.${db.quoteColumn("concurrency_key")})`
|
|
1050
|
+
)
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Builds the configured queue-priority ordering expression.
|
|
1055
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
1056
|
+
* @returns {string | null} - Queue priority expression.
|
|
1057
|
+
*/
|
|
1058
|
+
_queuePriorityOrderSql(db) {
|
|
1059
|
+
const queues = this.configuration.getBackgroundJobsConfig().queues
|
|
1060
|
+
const prioritized = Object.entries(queues)
|
|
1061
|
+
.filter(([, queue]) => Number.isFinite(queue?.priority) && Number(queue.priority) !== 0)
|
|
1062
|
+
.map(([queueName, queue]) => [queueName, Number(queue.priority)])
|
|
1063
|
+
|
|
1064
|
+
if (prioritized.length === 0) return null
|
|
1065
|
+
|
|
1066
|
+
const whens = prioritized
|
|
1067
|
+
.map(([queue, priority]) => `WHEN ${db.quote(queue)} THEN ${priority}`)
|
|
1068
|
+
.join(" ")
|
|
1069
|
+
|
|
1070
|
+
return `CASE COALESCE(${db.quoteColumn("queue")}, ${db.quote(DEFAULT_BACKGROUND_JOB_QUEUE)}) ${whens} ELSE 0 END`
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Checks whether a persisted handoff owns the supplied acknowledgement fence.
|
|
1075
|
+
* @param {import("./types.js").BackgroundJobRow | null} job - Persisted job.
|
|
1076
|
+
* @param {string | undefined} handoffId - Handoff fence.
|
|
1077
|
+
* @returns {job is import("./types.js").BackgroundJobRow} - Whether accepted.
|
|
1078
|
+
*/
|
|
1079
|
+
_acceptsHandoff(job, handoffId) {
|
|
1080
|
+
return Boolean(job && job.status === "handed_off" && job.handoffId && job.handoffId === handoffId)
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
/**
|
|
1084
|
+
* Finds a persisted local job using the current connection.
|
|
1085
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
1086
|
+
* @param {string} jobId - Job id.
|
|
1087
|
+
* @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Persisted row.
|
|
1088
|
+
*/
|
|
1089
|
+
async _getJob(db, jobId) {
|
|
1090
|
+
const rows = await db.newQuery().from(LOCAL_BACKGROUND_JOBS_TABLE).where({id: jobId}).limit(1).results()
|
|
1091
|
+
|
|
1092
|
+
return rows[0] ? this._normalizeRow(rows[0]) : null
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
/**
|
|
1096
|
+
* Normalizes one raw local database row.
|
|
1097
|
+
* @param {Record<string, ReturnType<typeof JSON.parse>>} row - Raw row.
|
|
1098
|
+
* @returns {import("./types.js").BackgroundJobRow} - Normalized row.
|
|
1099
|
+
*/
|
|
1100
|
+
_normalizeRow(row) {
|
|
1101
|
+
const parsedArgs = JSON.parse(String(row.args_json))
|
|
1102
|
+
const executionMode = normalizeBackgroundJobExecutionMode({executionMode: String(row.execution_mode)}, "inline", LOCAL_EXECUTION_MODES)
|
|
1103
|
+
|
|
1104
|
+
if (!Array.isArray(parsedArgs)) throw new Error(`Invalid local background job args_json for job: ${String(row.id)}`)
|
|
1105
|
+
if (executionMode !== "inline") throw new Error("Local background job execution mode invariant was violated")
|
|
1106
|
+
|
|
1107
|
+
return {
|
|
1108
|
+
args: parsedArgs,
|
|
1109
|
+
attempts: this._numberOrNull(row.attempts),
|
|
1110
|
+
completedAtMs: this._numberOrNull(row.completed_at_ms),
|
|
1111
|
+
concurrencyKey: row.concurrency_key === null || row.concurrency_key === undefined ? null : String(row.concurrency_key),
|
|
1112
|
+
createdAtMs: this._numberOrNull(row.created_at_ms),
|
|
1113
|
+
executionMode,
|
|
1114
|
+
failedAtMs: this._numberOrNull(row.failed_at_ms),
|
|
1115
|
+
handedOffAtMs: this._numberOrNull(row.handed_off_at_ms),
|
|
1116
|
+
handoffId: row.handoff_id === null || row.handoff_id === undefined ? null : String(row.handoff_id),
|
|
1117
|
+
id: String(row.id),
|
|
1118
|
+
jobName: String(row.job_name),
|
|
1119
|
+
lastError: row.last_error === null || row.last_error === undefined ? null : String(row.last_error),
|
|
1120
|
+
maxConcurrency: this._numberOrNull(row.max_concurrency),
|
|
1121
|
+
maxRetries: this._numberOrNull(row.max_retries),
|
|
1122
|
+
orphanedAtMs: null,
|
|
1123
|
+
queue: row.queue ? String(row.queue) : DEFAULT_BACKGROUND_JOB_QUEUE,
|
|
1124
|
+
scheduleKey: null,
|
|
1125
|
+
scheduledAtMs: this._numberOrNull(row.scheduled_at_ms),
|
|
1126
|
+
status: row.status ? String(row.status) : "queued",
|
|
1127
|
+
workerId: row.worker_id === null || row.worker_id === undefined ? null : String(row.worker_id)
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
/**
|
|
1132
|
+
* Normalizes one nullable database number.
|
|
1133
|
+
* @param {ReturnType<typeof JSON.parse>} value - Database number.
|
|
1134
|
+
* @returns {number | null} - Normalized number.
|
|
1135
|
+
*/
|
|
1136
|
+
_numberOrNull(value) {
|
|
1137
|
+
if (value === null || value === undefined || value === "") return null
|
|
1138
|
+
|
|
1139
|
+
const number = Number(value)
|
|
1140
|
+
|
|
1141
|
+
return Number.isNaN(number) ? null : number
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
/**
|
|
1145
|
+
* Executes a structured update and reports its affected-row count.
|
|
1146
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
1147
|
+
* @param {import("../database/drivers/base.js").UpdateSqlArgsType} args - Update arguments.
|
|
1148
|
+
* @returns {Promise<number>} - Affected rows.
|
|
1149
|
+
*/
|
|
1150
|
+
async _updateAffectedRows(db, args) { return await db.affectedRows(db.updateSql(args)) }
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* Joins an ambient app transaction or uses the database's scoped operation lease.
|
|
1154
|
+
* @template T
|
|
1155
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
1156
|
+
* @param {(db: import("../database/drivers/base.js").default) => Promise<T>} callback - Mutation.
|
|
1157
|
+
* @returns {Promise<T>} - Mutation result.
|
|
1158
|
+
*/
|
|
1159
|
+
async _mutate(db, callback) {
|
|
1160
|
+
if (db.insideTransaction()) return await this._transactionResult(db, async () => await callback(db))
|
|
1161
|
+
|
|
1162
|
+
return await this.configuration.withTransaction({
|
|
1163
|
+
databaseIdentifier: this.getDatabaseIdentifier(),
|
|
1164
|
+
name: "Local background jobs mutation"
|
|
1165
|
+
}, async (operation) => await callback(operation.connection()))
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Runs a callback in a transaction and returns its captured result.
|
|
1170
|
+
* @template T
|
|
1171
|
+
* @param {import("../database/drivers/base.js").default} db - Connection.
|
|
1172
|
+
* @param {() => Promise<T>} callback - Transaction callback.
|
|
1173
|
+
* @returns {Promise<T>} - Callback result.
|
|
1174
|
+
*/
|
|
1175
|
+
async _transactionResult(db, callback) {
|
|
1176
|
+
let completed = false
|
|
1177
|
+
/** @type {T | undefined} */
|
|
1178
|
+
let result
|
|
1179
|
+
|
|
1180
|
+
await db.transaction(async () => {
|
|
1181
|
+
result = await callback()
|
|
1182
|
+
completed = true
|
|
1183
|
+
})
|
|
1184
|
+
|
|
1185
|
+
if (!completed) throw new Error("Local background jobs transaction callback was not invoked")
|
|
1186
|
+
return /** @type {T} */ (result)
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Runs a callback with the configured local database connection.
|
|
1191
|
+
* @template T
|
|
1192
|
+
* @param {(db: import("../database/drivers/base.js").default) => Promise<T>} callback - Connection callback.
|
|
1193
|
+
* @returns {Promise<T>} - Callback result.
|
|
1194
|
+
*/
|
|
1195
|
+
async _withDb(callback) {
|
|
1196
|
+
const databaseIdentifier = this.getDatabaseIdentifier()
|
|
1197
|
+
|
|
1198
|
+
return await this.configuration.ensureConnections({databaseIdentifiers: [databaseIdentifier], name: "Local background jobs store"}, async (dbs) => {
|
|
1199
|
+
const db = dbs[databaseIdentifier]
|
|
1200
|
+
|
|
1201
|
+
if (!db) throw new Error(`No local background-jobs database connection available for identifier: ${databaseIdentifier}`)
|
|
1202
|
+
|
|
1203
|
+
return await callback(db)
|
|
1204
|
+
})
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
/**
|
|
1208
|
+
* Reports an unexpected local-store failure through framework channels.
|
|
1209
|
+
* @param {{error: Error, stage: string}} args - Error report.
|
|
1210
|
+
* @returns {void} - No return value.
|
|
1211
|
+
*/
|
|
1212
|
+
_reportFrameworkError({error, stage}) {
|
|
1213
|
+
const payload = {context: {stage}, error}
|
|
1214
|
+
const errorEvents = this.configuration.getErrorEvents()
|
|
1215
|
+
|
|
1216
|
+
errorEvents.emit("framework-error", payload)
|
|
1217
|
+
errorEvents.emit("all-error", {...payload, errorType: "framework-error"})
|
|
1218
|
+
}
|
|
1219
|
+
}
|