velocious 1.0.530 → 1.0.533

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.
Files changed (43) hide show
  1. package/README.md +6 -4
  2. package/build/background-jobs/main.js +1 -2
  3. package/build/background-jobs/store.js +94 -79
  4. package/build/background-jobs/types.js +1 -3
  5. package/build/background-jobs/web/controller.js +0 -1
  6. package/build/background-jobs/worker.js +73 -15
  7. package/build/database/drivers/mysql/sql/alter-table.js +42 -0
  8. package/build/database/query/preloader/belongs-to.js +9 -9
  9. package/build/database/query/preloader/has-many.js +8 -8
  10. package/build/database/query/preloader/has-one.js +5 -5
  11. package/build/database/tenants/schema-cloner.js +46 -9
  12. package/build/src/background-jobs/main.d.ts.map +1 -1
  13. package/build/src/background-jobs/main.js +2 -3
  14. package/build/src/background-jobs/store.d.ts +14 -23
  15. package/build/src/background-jobs/store.d.ts.map +1 -1
  16. package/build/src/background-jobs/store.js +83 -84
  17. package/build/src/background-jobs/types.d.ts +2 -12
  18. package/build/src/background-jobs/types.d.ts.map +1 -1
  19. package/build/src/background-jobs/types.js +2 -4
  20. package/build/src/background-jobs/web/controller.d.ts.map +1 -1
  21. package/build/src/background-jobs/web/controller.js +1 -2
  22. package/build/src/background-jobs/worker.d.ts +38 -5
  23. package/build/src/background-jobs/worker.d.ts.map +1 -1
  24. package/build/src/background-jobs/worker.js +72 -18
  25. package/build/src/database/drivers/mysql/sql/alter-table.d.ts.map +1 -1
  26. package/build/src/database/drivers/mysql/sql/alter-table.js +36 -1
  27. package/build/src/database/query/preloader/belongs-to.js +10 -12
  28. package/build/src/database/query/preloader/has-many.js +9 -11
  29. package/build/src/database/query/preloader/has-one.js +6 -7
  30. package/build/src/database/tenants/schema-cloner.d.ts +10 -0
  31. package/build/src/database/tenants/schema-cloner.d.ts.map +1 -1
  32. package/build/src/database/tenants/schema-cloner.js +36 -9
  33. package/package.json +3 -1
  34. package/src/background-jobs/main.js +1 -2
  35. package/src/background-jobs/store.js +94 -79
  36. package/src/background-jobs/types.js +1 -3
  37. package/src/background-jobs/web/controller.js +0 -1
  38. package/src/background-jobs/worker.js +73 -15
  39. package/src/database/drivers/mysql/sql/alter-table.js +42 -0
  40. package/src/database/query/preloader/belongs-to.js +9 -9
  41. package/src/database/query/preloader/has-many.js +8 -8
  42. package/src/database/query/preloader/has-one.js +5 -5
  43. package/src/database/tenants/schema-cloner.js +46 -9
package/README.md CHANGED
@@ -2007,13 +2007,13 @@ VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS=5400000
2007
2007
 
2008
2008
  `VELOCIOUS_BACKGROUND_JOBS_WORKER_SHUTDOWN_TIMEOUT_MS` (default: `indefinite`) bounds how long a `background-jobs-worker` waits for in-flight jobs on `SIGTERM`/`SIGINT` before terminating any forked or spawned child runners still running, so they are not orphaned across a deploy. The default waits for jobs to finish and never interrupts a running job; set a positive number of milliseconds for a finite cap (keep it shorter than your process supervisor's graceful-stop window so the worker reaps its own runners first). See [docs/background-jobs.md](docs/background-jobs.md#worker-shutdown-and-process-job-draining).
2009
2009
 
2010
- `maxConcurrentInlineJobs` (default: `4`) caps how many `executionMode: "inline"` jobs a single `background-jobs-worker` process runs in parallel. Concurrency is at the JS event-loop level: every job in flight shares the worker's process and DB connection pool, so the cap should fit the pool, not the CPU count. Forking remains the right tool when you need memory isolation across long-running jobs or want to use more cores. The older `forked: false` option still maps to inline mode.
2010
+ `maxConcurrentInlineJobs` (default: `4`) caps how many `executionMode: "inline"` jobs a single `background-jobs-worker` process runs in parallel. Concurrency is at the JS event-loop level: every job in flight shares the worker's process and DB connection pool, so the cap should fit the pool, not the CPU count. Forking remains the right tool when you need memory isolation across long-running jobs or want to use more cores; select it with `executionMode: "forked"`.
2011
2011
 
2012
- New jobs default to `executionMode: "pooled"`: a worker runs them in warm, reusable Node child runners. `pooledRunnerCount` (default: `4`) bounds this independent per-worker pool, and `pooledRunnerConcurrency` (default: `1`) sets how many jobs each child runs at once on its own event loop, so total pooled capacity is `pooledRunnerCount × pooledRunnerConcurrency` — raise concurrency for I/O-bound jobs to get high throughput from a bounded, isolated set of processes. `pooledRunnerCount`, `pooledRunnerConcurrency`, and `pooledRunnerMaxJobs` must be finite positive integers; the RSS and lifetime limits must be finite positive numbers. A child is recycled after an acknowledged job when it reaches `pooledRunnerMaxJobs` (default: `100`), `pooledRunnerMaxRssBytes` (default: `536870912`, or 512 MiB), or `pooledRunnerMaxLifetimeMs` (default: `3600000`, or one hour). Pooled rows remain readable by older mains during rolling upgrades and run there with legacy one-shot fork behavior. `forked: true` remains an explicit alias for `"forked"`; `forked: false` remains inline. See [execution modes and pooled runners](docs/background-jobs.md#execution-modes-and-pooled-runners).
2012
+ New jobs default to `executionMode: "pooled"`: a worker runs them in warm, reusable Node child runners. `pooledRunnerCount` (default: `4`) bounds this independent per-worker pool, and `pooledRunnerConcurrency` (default: `1`) sets how many jobs each child runs at once on its own event loop, so total pooled capacity is `pooledRunnerCount × pooledRunnerConcurrency` — raise concurrency for I/O-bound jobs to get high throughput from a bounded, isolated set of processes. `pooledRunnerCount`, `pooledRunnerConcurrency`, and `pooledRunnerMaxJobs` must be finite positive integers; the RSS and lifetime limits must be finite positive numbers. A child is recycled after an acknowledged job when it reaches `pooledRunnerMaxJobs` (default: `100`), `pooledRunnerMaxRssBytes` (default: `536870912`, or 512 MiB), or `pooledRunnerMaxLifetimeMs` (default: `3600000`, or one hour). `execution_mode` is the single source of truth for a job's runtime pooled rows persist as `execution_mode = "pooled"` directly. See [execution modes and pooled runners](docs/background-jobs.md#execution-modes-and-pooled-runners).
2013
2013
 
2014
2014
  `maxConcurrentForkedJobs` (default: `4`) caps how many out-of-process `executionMode: "forked"` or `executionMode: "spawned"` jobs one worker may keep in flight. Forked jobs use `child_process.fork()` with an attached IPC channel. After the main process acknowledges their durable status report, forked and spawned one-shot runners exit without waiting for graceful Beacon/database teardown; the OS closes their process-owned resources. A missing or rejected status acknowledgement makes the runner exit as failed instead of reporting clean success. Spawned jobs use the legacy `background-jobs-runner` CLI process via `child_process.spawn()` and are only for callers that intentionally want that spawned behavior.
2015
2015
 
2016
- `jobTimeoutMs` (or `VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS`, milliseconds; default: disabled) is a wall-clock backstop for a single `"forked"` runner. A forked job still running after the timeout is terminated (`SIGTERM`, then `SIGKILL` after the reaping grace) and reported `failed`, so a genuinely-hung runner can't pin a worker — and its whole-app boot and DB connections — indefinitely (notably a retired-release worker draining after a deploy). It's a coarse safety net, not per-job tuning: it applies to every forked runner, so set it well above the longest legitimate forked job. Omit it, or set `null`/`<= 0`, to disable. `"inline"` jobs are not covered — they share the worker's process and can't be killed without killing the worker. See [docs/background-jobs.md](docs/background-jobs.md#forked-job-timeout-hung-runner-backstop).
2016
+ `jobTimeoutMs` (or `VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS`, milliseconds; default: disabled) is a wall-clock backstop for `"forked"` and `"pooled"` jobs. A job still running after the timeout is terminated (`SIGTERM`, then `SIGKILL` after the reaping grace) and reported `failed`, so a genuinely-hung job can't pin a worker's capacity — and its whole-app boot and DB connections — indefinitely (notably a retired-release worker draining after a deploy). For a **pooled** job the whole child running it is killed, so its concurrent in-flight siblings on that child are also reported `failed` and requeued — a hung JS job can't be cancelled any other way — before a replacement child is spawned. It's a coarse safety net, not per-job tuning: it applies to every forked and pooled job, so set it well above the longest legitimate job. Omit it, or set `null`/`<= 0`, to disable. `"inline"` jobs are not covered — they share the worker's process and can't be killed without killing the worker. See [docs/background-jobs.md](docs/background-jobs.md#job-timeout-hung-runner-backstop).
2017
2017
 
2018
2018
  ### Dispatch strategy
2019
2019
 
@@ -2075,7 +2075,7 @@ await MyJob.performLaterWithOptions({
2075
2075
 
2076
2076
  Until `scheduledAtMs` is reached, the job remains queued but is not eligible for dispatch. The event-driven dispatcher arms its timer for the earliest future job and wakes at that timestamp. Omitting `scheduledAtMs` keeps the immediate-enqueue behavior.
2077
2077
 
2078
- The older `options: {forked: false}` form is still accepted as an alias for inline execution, and `options: {forked: true}` maps to forked execution. To use the previous spawned CLI runner behavior explicitly, pass `options: {executionMode: "spawned"}`.
2078
+ Select a non-default runtime explicitly with `options: {executionMode: "inline" | "forked" | "spawned"}`.
2079
2079
 
2080
2080
  Inline jobs share the worker process and run concurrently up to `maxConcurrentInlineJobs`, so a single slow inline job no longer blocks the queue. A single worker can also override the configured cap explicitly:
2081
2081
 
@@ -2327,4 +2327,6 @@ npx velocious db:tenants:migrate projectTenant --parallel 20
2327
2327
 
2328
2328
  At runtime, the apartment-style `Tenant` façade (`velocious/build/src/tenants/tenant.js`) is the single entry point: `Tenant.with(tenant, callback)` / `Tenant.current()` to switch into and read a tenant context, `Tenant.each({identifier, callback, parallel?, filter?})` to run a callback within every provider-listed tenant, and `Tenant.drop({identifier, tenant})` (plus the `db:tenants:drop` CLI command) to drop a tenant's database through the provider's `dropDatabase` hook. `Tenant.with` and `Tenant.each` run their callbacks inside `ensureConnections`, so switching into a tenant establishes its database connections (global + tenant) and passes them to the callback — the tenant is immediately queryable without the caller wiring up connections (already-open connections are reused, so nesting does not double-connect). `Tenant.with` is generic over its callback's return type, so a value returned from inside the tenant context comes back to the caller with its type preserved (no cast needed). `Tenant.aggregateAcross({identifier, aggregates, keyColumns, subquery, tenants?, filter?})` runs one aggregate over the same table across many tenant databases and returns the merged result — grouping tenants by server and using a single cross-database `UNION ALL` where the driver supports two-part `` `database`.`table` `` references (MySQL/MariaDB) or one query per tenant otherwise (PostgreSQL/SQLite/MSSQL).
2329
2329
 
2330
+ `SchemaCloner` adds a missing auto-increment column and its separate source unique index in one schema alteration, including on MySQL/MariaDB where an auto-increment column must be keyed when it is created.
2331
+
2330
2332
  See [docs/tenant-databases.md](docs/tenant-databases.md) for the full configuration and migration pattern.
@@ -1142,8 +1142,7 @@ export default class BackgroundJobsMain {
1142
1142
  workerId: worker.workerId,
1143
1143
  handedOffAtMs: handoff.handedOffAtMs,
1144
1144
  options: {
1145
- executionMode: job.executionMode,
1146
- forked: job.forked
1145
+ executionMode: job.executionMode
1147
1146
  }
1148
1147
  }
1149
1148
  })
@@ -11,6 +11,16 @@ const MIGRATIONS_TABLE = "velocious_internal_migrations"
11
11
  const MIGRATION_SCOPE = "background_jobs"
12
12
  const MIGRATION_VERSION = "20250215000000"
13
13
  const EXECUTION_MODE_BACKFILL_MIGRATION_VERSION = "20260607131010"
14
+ // Drops the redundant legacy `forked` boolean column and rewrites pooled rows to
15
+ // persist `execution_mode = "pooled"` directly (retiring the pooled-as-forked
16
+ // handoff-marker workaround), leaving `execution_mode` as the single source of
17
+ // truth for a job's runtime.
18
+ const DROP_FORKED_COLUMN_MIGRATION_VERSION = "20260719000000"
19
+ // Legacy marker prefix used by rows written before this migration: pooled jobs
20
+ // used to persist as `execution_mode = "forked"` plus a `velocious-pooled:*`
21
+ // handoff id. Retained only to detect and convert those rows in the migration.
22
+ const LEGACY_POOLED_HANDOFF_ID_PREFIX = "velocious-pooled:"
23
+ const LEGACY_POOLED_QUEUED_HANDOFF_ID = `${LEGACY_POOLED_HANDOFF_ID_PREFIX}queued`
14
24
  const JOBS_TABLE = "background_jobs"
15
25
  const CONCURRENCY_TABLE = "background_job_concurrency"
16
26
  const DEFAULT_MAX_RETRIES = 10
@@ -25,18 +35,6 @@ const EXECUTION_MODES = ["inline", "forked", "pooled", "spawned"]
25
35
  * process — the same isolation as forked without paying a fresh process per job.
26
36
  * @type {import("./types.js").BackgroundJobExecutionMode} */
27
37
  const DEFAULT_EXECUTION_MODE = "pooled"
28
- /**
29
- * Execution mode a legacy `forked = true` row (persisted before the
30
- * `execution_mode` column existed) backfills to. It must stay `"forked"` so an
31
- * upgrade never silently reinterprets already-persisted forked jobs as pooled —
32
- * distinct from {@link DEFAULT_EXECUTION_MODE}, which only governs new enqueues.
33
- * @type {import("./types.js").BackgroundJobExecutionMode} */
34
- const LEGACY_FORKED_EXECUTION_MODE = "forked"
35
- // Pooled jobs persist with the legacy-safe `forked` mode so a pre-pool main can
36
- // deserialize and run them during a rolling upgrade. Old versions ignore the
37
- // nullable handoff id on queued rows, making it a backward-compatible marker.
38
- const POOLED_HANDOFF_ID_PREFIX = "velocious-pooled:"
39
- const POOLED_QUEUED_HANDOFF_ID = `${POOLED_HANDOFF_ID_PREFIX}queued`
40
38
  const DEFAULT_QUEUE = "default"
41
39
  // Queue-derived durable concurrency keys are namespaced so they can't collide
42
40
  // with explicit caller-supplied concurrencyKeys.
@@ -154,8 +152,7 @@ export default class BackgroundJobsStore {
154
152
  id: jobId,
155
153
  job_name: jobName,
156
154
  args_json: argsJson,
157
- forked: executionMode !== "inline",
158
- execution_mode: executionMode === "pooled" ? LEGACY_FORKED_EXECUTION_MODE : executionMode,
155
+ execution_mode: executionMode,
159
156
  queue,
160
157
  max_retries: maxRetries,
161
158
  attempts: 0,
@@ -164,7 +161,7 @@ export default class BackgroundJobsStore {
164
161
  created_at_ms: now,
165
162
  concurrency_key: concurrency?.concurrencyKey || null,
166
163
  max_concurrency: concurrency?.maxConcurrency || null,
167
- handoff_id: executionMode === "pooled" ? POOLED_QUEUED_HANDOFF_ID : null
164
+ handoff_id: null
168
165
  }
169
166
  })
170
167
  })
@@ -175,7 +172,6 @@ export default class BackgroundJobsStore {
175
172
  /**
176
173
  * Runs next available job.
177
174
  * @param {object} [args] - Options.
178
- * @param {boolean} [args.forked] - Compatibility filter for non-inline vs inline jobs.
179
175
  * @param {import("./types.js").BackgroundJobExecutionMode | import("./types.js").BackgroundJobExecutionMode[]} [args.executionMode] - Execution mode or modes to match.
180
176
  * @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Next job.
181
177
  */
@@ -186,7 +182,6 @@ export default class BackgroundJobsStore {
186
182
  return await this._nextQueuedJob({
187
183
  db,
188
184
  scheduledAtOperator: "<=",
189
- forked: args.forked,
190
185
  executionMode: args.executionMode
191
186
  })
192
187
  })
@@ -213,11 +208,10 @@ export default class BackgroundJobsStore {
213
208
  * @param {object} args - Options.
214
209
  * @param {import("../database/drivers/base.js").default} args.db - Database connection.
215
210
  * @param {"<=" | ">"} args.scheduledAtOperator - Scheduled timestamp operator.
216
- * @param {boolean} [args.forked] - Compatibility filter for non-inline vs inline jobs.
217
211
  * @param {import("./types.js").BackgroundJobExecutionMode | import("./types.js").BackgroundJobExecutionMode[]} [args.executionMode] - Execution mode or modes to match.
218
212
  * @returns {Promise<import("./types.js").BackgroundJobRow | null>} - Next matching queued job.
219
213
  */
220
- async _nextQueuedJob({db, scheduledAtOperator, forked, executionMode}) {
214
+ async _nextQueuedJob({db, scheduledAtOperator, executionMode}) {
221
215
  const now = Date.now()
222
216
  let query = db
223
217
  .newQuery()
@@ -236,9 +230,6 @@ export default class BackgroundJobsStore {
236
230
  )
237
231
  }
238
232
 
239
- if (typeof forked === "boolean") {
240
- query = query.where({forked})
241
- }
242
233
  if (executionMode) query = this._whereExecutionMode({db, executionMode, query})
243
234
 
244
235
  if (scheduledAtOperator === "<=") {
@@ -420,9 +411,7 @@ export default class BackgroundJobsStore {
420
411
  const queuedJob = await this._getJobRowById(db, jobId)
421
412
  if (!queuedJob || queuedJob.status !== "queued") return null
422
413
  if (queuedJob.concurrencyKey && !(await this._reserveConcurrency(db, queuedJob.concurrencyKey))) return null
423
- const handoffId = queuedJob.executionMode === "pooled"
424
- ? `${POOLED_HANDOFF_ID_PREFIX}${randomUUID()}`
425
- : randomUUID()
414
+ const handoffId = randomUUID()
426
415
 
427
416
  const affectedRows = await this._updateAffectedRows(db, {
428
417
  tableName: JOBS_TABLE,
@@ -496,7 +485,7 @@ export default class BackgroundJobsStore {
496
485
  status: "queued",
497
486
  scheduled_at_ms: Date.now(),
498
487
  handed_off_at_ms: null,
499
- handoff_id: job.executionMode === "pooled" ? POOLED_QUEUED_HANDOFF_ID : null,
488
+ handoff_id: null,
500
489
  worker_id: null
501
490
  },
502
491
  conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"}
@@ -842,7 +831,6 @@ export default class BackgroundJobsStore {
842
831
  table.string("id", {primaryKey: true})
843
832
  table.string("job_name", {null: false, index: true})
844
833
  table.text("args_json", {null: false})
845
- table.boolean("forked", {null: false})
846
834
  table.string("execution_mode", {null: false})
847
835
  table.string("queue", {null: true, index: true})
848
836
  table.integer("max_retries", {null: false})
@@ -916,6 +904,7 @@ export default class BackgroundJobsStore {
916
904
  }
917
905
 
918
906
  await this._backfillExecutionModesOnce(db)
907
+ await this._dropForkedColumnOnce(db)
919
908
 
920
909
  const lockName = `${MIGRATION_SCOPE}:concurrency_columns`
921
910
  const acquired = await db.acquireAdvisoryLock(lockName)
@@ -1001,12 +990,20 @@ export default class BackgroundJobsStore {
1001
990
  try {
1002
991
  if (await this._hasMigration(db, migrationVersion)) return
1003
992
 
993
+ // A table created after the `forked` column was dropped has nothing to
994
+ // backfill from; record the migration so it is not re-attempted.
995
+ db.clearSchemaCache()
996
+ if (!(await (await db.getTableByNameOrFail(JOBS_TABLE)).getColumnByName("forked"))) {
997
+ await this._recordMigration(db, migrationVersion)
998
+ return
999
+ }
1000
+
1004
1001
  const tableNameSql = db.quoteTable(JOBS_TABLE)
1005
1002
  const forkedColumnSql = db.quoteColumn("forked")
1006
1003
  const executionModeColumnSql = db.quoteColumn("execution_mode")
1007
1004
 
1008
1005
  await db.query(
1009
- `UPDATE ${tableNameSql} SET ${executionModeColumnSql} = ${db.quote(LEGACY_FORKED_EXECUTION_MODE)} ` +
1006
+ `UPDATE ${tableNameSql} SET ${executionModeColumnSql} = ${db.quote("forked")} ` +
1010
1007
  `WHERE ${forkedColumnSql} = ${db.quote(true)} AND ${executionModeColumnSql} IS NULL`
1011
1008
  )
1012
1009
  await db.query(
@@ -1020,6 +1017,60 @@ export default class BackgroundJobsStore {
1020
1017
  }
1021
1018
  }
1022
1019
 
1020
+ /**
1021
+ * Rewrites pre-existing pooled rows (persisted as `execution_mode = "forked"`
1022
+ * plus a `velocious-pooled:*` handoff marker) to `execution_mode = "pooled"`,
1023
+ * clears the queued marker, then drops the now-redundant `forked` column so
1024
+ * `execution_mode` is the single source of truth. Runs once, guarded by the
1025
+ * migration ledger and a per-key advisory lock; a fresh table (created without
1026
+ * the column) short-circuits.
1027
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
1028
+ * @returns {Promise<void>} - Resolves when complete.
1029
+ */
1030
+ async _dropForkedColumnOnce(db) {
1031
+ const migrationVersion = DROP_FORKED_COLUMN_MIGRATION_VERSION
1032
+ const migrationKey = this._migrationKey(migrationVersion)
1033
+
1034
+ if (await this._hasMigration(db, migrationVersion)) return
1035
+
1036
+ await db.acquireAdvisoryLock(migrationKey)
1037
+
1038
+ try {
1039
+ if (await this._hasMigration(db, migrationVersion)) return
1040
+
1041
+ db.clearSchemaCache()
1042
+
1043
+ if (await (await db.getTableByNameOrFail(JOBS_TABLE)).getColumnByName("forked")) {
1044
+ const tableNameSql = db.quoteTable(JOBS_TABLE)
1045
+ const executionModeColumnSql = db.quoteColumn("execution_mode")
1046
+ const handoffIdColumnSql = db.quoteColumn("handoff_id")
1047
+
1048
+ // Pooled rows used to persist as execution_mode "forked" + a pooled handoff
1049
+ // marker; recover their real mode before the marker is cleared.
1050
+ await db.query(
1051
+ `UPDATE ${tableNameSql} SET ${executionModeColumnSql} = ${db.quote("pooled")} ` +
1052
+ `WHERE ${executionModeColumnSql} = ${db.quote("forked")} ` +
1053
+ `AND ${handoffIdColumnSql} LIKE ${db.quote(`${LEGACY_POOLED_HANDOFF_ID_PREFIX}%`)}`
1054
+ )
1055
+ // The queued-pooled marker was a sentinel, not a real lease; clear it.
1056
+ await db.query(
1057
+ `UPDATE ${tableNameSql} SET ${handoffIdColumnSql} = NULL ` +
1058
+ `WHERE ${handoffIdColumnSql} = ${db.quote(LEGACY_POOLED_QUEUED_HANDOFF_ID)}`
1059
+ )
1060
+
1061
+ const dropForked = new TableData(JOBS_TABLE)
1062
+ dropForked.addColumn("forked", {dropColumn: true})
1063
+ for (const sql of await db.alterTableSQLs(dropForked)) await db.query(sql)
1064
+
1065
+ db.clearSchemaCache()
1066
+ }
1067
+
1068
+ await this._recordMigration(db, migrationVersion)
1069
+ } finally {
1070
+ await db.releaseAdvisoryLock(migrationKey)
1071
+ }
1072
+ }
1073
+
1023
1074
  /**
1024
1075
  * Runs record migration.
1025
1076
  * @param {import("../database/drivers/base.js").default} db - Database connection.
@@ -1097,7 +1148,6 @@ export default class BackgroundJobsStore {
1097
1148
  scheduledAt,
1098
1149
  shouldRetry
1099
1150
  })
1100
- if (shouldRetry && job.executionMode === "pooled") update.handoff_id = POOLED_QUEUED_HANDOFF_ID
1101
1151
 
1102
1152
  const affectedRows = await this._updateAffectedRows(db, {
1103
1153
  tableName: JOBS_TABLE,
@@ -1206,18 +1256,17 @@ export default class BackgroundJobsStore {
1206
1256
  * @returns {import("./types.js").BackgroundJobRow} - Normalized job row.
1207
1257
  */
1208
1258
  _normalizeJobRow(row) {
1209
- const persistedExecutionMode = row.execution_mode ? String(row.execution_mode) : null
1210
1259
  const handoffId = row.handoff_id ? String(row.handoff_id) : null
1211
- const executionMode = persistedExecutionMode
1212
- ? this._normalizePersistedExecutionMode({executionMode: persistedExecutionMode, handoffId})
1213
- : this._normalizeExecutionMode({forked: this._normalizeBoolean(row.forked)})
1260
+ // `execution_mode` is the single source of truth for a job's runtime and is
1261
+ // written on every enqueue; the drop-forked migration backfills any pre-existing
1262
+ // rows before the legacy `forked` column is removed.
1263
+ const executionMode = row.execution_mode ? this._normalizeExecutionModeName(String(row.execution_mode)) : DEFAULT_EXECUTION_MODE
1214
1264
 
1215
1265
  return {
1216
1266
  id: String(row.id),
1217
1267
  jobName: String(row.job_name),
1218
1268
  args: this._parseArgs(row.args_json),
1219
1269
  executionMode,
1220
- forked: executionMode !== "inline",
1221
1270
  queue: row.queue ? String(row.queue) : DEFAULT_QUEUE,
1222
1271
  status: row.status ? String(row.status) : "queued",
1223
1272
  attempts: this._normalizeNumber(row.attempts),
@@ -1501,19 +1550,6 @@ export default class BackgroundJobsStore {
1501
1550
  return numeric
1502
1551
  }
1503
1552
 
1504
- /**
1505
- * Runs normalize boolean.
1506
- * @param {?} value - Input value.
1507
- * @returns {boolean} - Normalized boolean.
1508
- */
1509
- _normalizeBoolean(value) {
1510
- if (value === null || value === undefined) return false
1511
- if (typeof value === "boolean") return value
1512
- if (typeof value === "number") return value !== 0
1513
-
1514
- return value === "true"
1515
- }
1516
-
1517
1553
  /**
1518
1554
  * Runs normalize execution mode.
1519
1555
  * @param {import("./types.js").BackgroundJobOptions} [options] - Job options.
@@ -1525,11 +1561,14 @@ export default class BackgroundJobsStore {
1525
1561
  if (executionMode) {
1526
1562
  return this._normalizeExecutionModeName(executionMode)
1527
1563
  }
1528
- // The legacy `forked` flag stays authoritative when supplied: `false` is
1529
- // inline and `true` is forked (never the pooled default). Only an enqueue
1530
- // that names neither option falls through to the pooled default.
1531
- if (options?.forked === false) return "inline"
1532
- if (options?.forked === true) return LEGACY_FORKED_EXECUTION_MODE
1564
+
1565
+ // The `forked` option alias was removed. Reject it loudly instead of silently
1566
+ // defaulting to pooled, which would turn an explicitly inline (`forked: false`)
1567
+ // or one-shot forked (`forked: true`) job into a pooled child-runner job — a
1568
+ // silent semantic change for any not-yet-migrated caller.
1569
+ if (options && "forked" in options) {
1570
+ throw new Error("The background job `forked` option was removed; pass `executionMode` (\"inline\", \"forked\", \"pooled\", or \"spawned\") instead")
1571
+ }
1533
1572
 
1534
1573
  return DEFAULT_EXECUTION_MODE
1535
1574
  }
@@ -1548,20 +1587,8 @@ export default class BackgroundJobsStore {
1548
1587
  }
1549
1588
 
1550
1589
  /**
1551
- * Normalizes a legacy-safe persisted execution mode.
1552
- * @param {object} args - Options.
1553
- * @param {string} args.executionMode - Persisted legacy execution mode.
1554
- * @param {string | null} args.handoffId - Persisted handoff id or pooled marker.
1555
- * @returns {import("./types.js").BackgroundJobExecutionMode} - Runtime execution mode.
1556
- */
1557
- _normalizePersistedExecutionMode({executionMode, handoffId}) {
1558
- if (executionMode === LEGACY_FORKED_EXECUTION_MODE && handoffId?.startsWith(POOLED_HANDOFF_ID_PREFIX)) return "pooled"
1559
-
1560
- return this._normalizeExecutionModeName(executionMode)
1561
- }
1562
-
1563
- /**
1564
- * Filters persisted legacy-safe execution modes.
1590
+ * Filters queued jobs by one or more execution modes against the
1591
+ * `execution_mode` column (the single source of truth).
1565
1592
  * @param {object} args - Options.
1566
1593
  * @param {import("../database/drivers/base.js").default} args.db - Database connection.
1567
1594
  * @param {import("./types.js").BackgroundJobExecutionMode | import("./types.js").BackgroundJobExecutionMode[]} args.executionMode - Runtime modes.
@@ -1570,20 +1597,8 @@ export default class BackgroundJobsStore {
1570
1597
  */
1571
1598
  _whereExecutionMode({db, executionMode, query}) {
1572
1599
  const executionModes = Array.isArray(executionMode) ? executionMode : [executionMode]
1573
- /** @type {string[]} */
1574
- const conditions = []
1575
1600
  const executionModeColumn = db.quoteColumn("execution_mode")
1576
- const handoffIdColumn = db.quoteColumn("handoff_id")
1577
-
1578
- for (const mode of executionModes) {
1579
- if (mode === "pooled") {
1580
- conditions.push(`(${executionModeColumn} = ${db.quote(LEGACY_FORKED_EXECUTION_MODE)} AND ${handoffIdColumn} = ${db.quote(POOLED_QUEUED_HANDOFF_ID)})`)
1581
- } else if (mode === LEGACY_FORKED_EXECUTION_MODE) {
1582
- conditions.push(`(${executionModeColumn} = ${db.quote(mode)} AND (${handoffIdColumn} IS NULL OR ${handoffIdColumn} <> ${db.quote(POOLED_QUEUED_HANDOFF_ID)}))`)
1583
- } else {
1584
- conditions.push(`${executionModeColumn} = ${db.quote(mode)}`)
1585
- }
1586
- }
1601
+ const conditions = executionModes.map((mode) => `${executionModeColumn} = ${db.quote(mode)}`)
1587
1602
 
1588
1603
  return query.where(`(${conditions.join(" OR ")})`)
1589
1604
  }
@@ -10,8 +10,7 @@
10
10
  */
11
11
  /**
12
12
  * @typedef {object} BackgroundJobOptions
13
- * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Defaults to `"pooled"` (a warm, reused local runner process). `"forked"` runs the job in a fresh `child_process.fork()` child, `"spawned"` in a detached CLI runner, and `"inline"` inside the worker process.
14
- * @property {boolean} [forked] - Compatibility alias: `false` maps to `"inline"` and `true` maps to `"forked"`. Omitting both `forked` and `executionMode` uses the default `"pooled"` mode.
13
+ * @property {BackgroundJobExecutionMode} [executionMode] - How the job should run. Defaults to `"pooled"` (a warm, reused local runner process). `"forked"` runs the job in a fresh `child_process.fork()` child, `"spawned"` in a detached CLI runner, and `"inline"` inside the worker process. Omit to use the default `"pooled"` mode.
15
14
  * @property {number} [maxRetries] - Max retries for a failed job before it is marked failed.
16
15
  * @property {string} [queue] - Queue name. Defaults to `"default"`. When the queue has a configured cap in `backgroundJobs.queues`, that cap is enforced cluster-wide.
17
16
  * @property {string} [concurrencyKey] - Opaque non-empty key used to share a concurrency cap. Overrides any queue-derived cap.
@@ -35,7 +34,6 @@
35
34
  * @property {string} jobName - Job class name.
36
35
  * @property {Array<?>} args - Serialized job arguments.
37
36
  * @property {BackgroundJobExecutionMode} executionMode - How the job should run.
38
- * @property {boolean} forked - Compatibility flag; true for non-inline execution.
39
37
  * @property {string} queue - Queue name (defaults to `"default"`).
40
38
  * @property {string} status - Current job status.
41
39
  * @property {number | null} attempts - Failure attempts count.
@@ -196,7 +196,6 @@ export default class VelociousBackgroundJobsWebController extends Controller {
196
196
  createdAtMs: job.createdAtMs,
197
197
  executionMode: job.executionMode,
198
198
  failedAtMs: job.failedAtMs,
199
- forked: job.forked,
200
199
  handedOffAtMs: job.handedOffAtMs,
201
200
  id: job.id,
202
201
  jobName: job.jobName,
@@ -72,7 +72,7 @@ export default class BackgroundJobsWorker {
72
72
  * @param {number} [args.pooledRunnerMaxLifetimeMs] - Override the per-runner recycle lifetime.
73
73
  * @param {number} [args.forkedChildSigkillGraceMs] - Override the grace period between SIGTERM and SIGKILL when reaping lingering process runners on stop.
74
74
  * @param {number} [args.heartbeatIntervalMs] - Override the liveness heartbeat interval (default 15000ms).
75
- * @param {number} [args.jobTimeoutMs] - Override the forked-job wall-clock timeout from `configuration.getBackgroundJobsConfig()`. `0` disables it.
75
+ * @param {number} [args.jobTimeoutMs] - Override the wall-clock timeout for forked and pooled jobs from `configuration.getBackgroundJobsConfig()`. `0` disables it.
76
76
  */
77
77
  constructor({configuration, host, port, maxConcurrentForkedJobs, maxConcurrentInlineJobs, pooledRunnerCount, pooledRunnerConcurrency, pooledRunnerMaxJobs, pooledRunnerMaxRssBytes, pooledRunnerMaxLifetimeMs, forkedChildSigkillGraceMs, heartbeatIntervalMs, jobTimeoutMs} = {}) {
78
78
  /**
@@ -129,7 +129,7 @@ export default class BackgroundJobsWorker {
129
129
  ? forkedChildSigkillGraceMs
130
130
  : FORKED_CHILD_SIGKILL_GRACE_MS
131
131
  /**
132
- * Constructor override for the forked-job wall-clock timeout. When unset the
132
+ * Constructor override for the forked and pooled wall-clock job timeout. When unset the
133
133
  * timeout is read from `configuration.getBackgroundJobsConfig().jobTimeoutMs`
134
134
  * at fork time (default: disabled).
135
135
  * @type {number | undefined}
@@ -186,7 +186,7 @@ export default class BackgroundJobsWorker {
186
186
  this.inflightPooledJobs = new Set()
187
187
  /** @type {Set<import("node:child_process").ChildProcess>} */
188
188
  this.pooledChildren = new Set()
189
- /** @type {Map<import("node:child_process").ChildProcess, {createdAtMs: number, jobsRun: number, inflight: Map<string, {payload: import("./types.js").BackgroundJobPayload & {id: string}, resolve?: (value: void) => void}>, retiring: boolean, settling?: boolean}>} */
189
+ /** @type {Map<import("node:child_process").ChildProcess, {createdAtMs: number, jobsRun: number, inflight: Map<string, {payload: import("./types.js").BackgroundJobPayload & {id: string}, resolve?: (value: void) => void, timeoutTimer?: ReturnType<typeof setTimeout> | null}>, retiring: boolean, settling?: boolean, timeoutSigkillTimer?: ReturnType<typeof setTimeout> | null}>} */
190
190
  this.pooledChildStates = new Map()
191
191
  }
192
192
 
@@ -480,14 +480,9 @@ export default class BackgroundJobsWorker {
480
480
  * @returns {import("./types.js").BackgroundJobExecutionMode} - Execution mode.
481
481
  */
482
482
  _executionModeForPayload(payload) {
483
- const options = payload.options || {}
484
- const executionMode = options.executionMode
483
+ const executionMode = payload.options?.executionMode
485
484
 
486
- if (executionMode) return this._normalizeExecutionMode(executionMode)
487
-
488
- if (options.forked === false) return "inline"
489
- if (options.forked === true) return "forked"
490
- return "pooled"
485
+ return executionMode ? this._normalizeExecutionMode(executionMode) : "pooled"
491
486
  }
492
487
 
493
488
  /**
@@ -659,7 +654,9 @@ export default class BackgroundJobsWorker {
659
654
  if (!state) throw new Error("Pooled runner state missing")
660
655
 
661
656
  return new Promise((resolve) => {
662
- state.inflight.set(payload.id, {payload, resolve})
657
+ const timeoutTimer = this._armPooledJobTimeout({child, jobId: payload.id})
658
+
659
+ state.inflight.set(payload.id, {payload, resolve, timeoutTimer})
663
660
  try {
664
661
  child.send({type: "job", payload})
665
662
  } catch (error) {
@@ -668,6 +665,58 @@ export default class BackgroundJobsWorker {
668
665
  })
669
666
  }
670
667
 
668
+ /**
669
+ * Arms a per-job wall-clock backstop for a pooled job. A pooled child hosts many
670
+ * concurrent jobs, so a single genuinely-hung job would otherwise pin its
671
+ * runner's concurrency slot forever — the lifetime recycle only retires a child
672
+ * once its in-flight set drains, which a hung job never does. On overrun the
673
+ * whole child is terminated so the hung job (and its siblings) requeue. Returns
674
+ * the timer, or null when no timeout is configured.
675
+ * @param {object} args - Options.
676
+ * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
677
+ * @param {string} args.jobId - Job id whose overrun is guarded.
678
+ * @returns {ReturnType<typeof setTimeout> | null} - The armed timer, or null.
679
+ */
680
+ _armPooledJobTimeout({child, jobId}) {
681
+ const timeoutMs = this._resolveJobTimeoutMs()
682
+
683
+ if (!(typeof timeoutMs === "number" && timeoutMs > 0)) return null
684
+
685
+ return setTimeout(() => this._onPooledJobTimeout({child, jobId}), timeoutMs)
686
+ }
687
+
688
+ /**
689
+ * Fired when a pooled job overruns its timeout. Terminates the child running it
690
+ * (SIGTERM, then SIGKILL after the grace) — a hung JS job cannot be cancelled
691
+ * any other way. The non-clean exit flows through `_handlePooledChildFailure`,
692
+ * which reports every in-flight job on the child failed (so they requeue) and
693
+ * drops it from tracking; capacity is refilled on the next dispatch.
694
+ * @param {object} args - Options.
695
+ * @param {import("node:child_process").ChildProcess} args.child - Pooled child.
696
+ * @param {string} args.jobId - Job id that overran.
697
+ * @returns {void}
698
+ */
699
+ _onPooledJobTimeout({child, jobId}) {
700
+ const state = this.pooledChildStates.get(child)
701
+
702
+ // Already settling/gone, or the job finished in the race with this timer.
703
+ if (!state || state.settling || !state.inflight.has(jobId)) return
704
+
705
+ try {
706
+ child.kill("SIGTERM")
707
+ } catch {
708
+ // Child already exited; nothing to do.
709
+ }
710
+
711
+ state.timeoutSigkillTimer = setTimeout(() => {
712
+ try {
713
+ child.kill("SIGKILL")
714
+ } catch {
715
+ // Child already exited; nothing to do.
716
+ }
717
+ }, this.forkedChildSigkillGraceMs)
718
+ }
719
+
671
720
  /**
672
721
  * Creates a reusable pooled child.
673
722
  * @returns {import("node:child_process").ChildProcess} - New pooled child.
@@ -705,6 +754,7 @@ export default class BackgroundJobsWorker {
705
754
  const entry = state.inflight.get(record.jobId)
706
755
  if (!entry) return
707
756
 
757
+ if (entry.timeoutTimer) clearTimeout(entry.timeoutTimer)
708
758
  state.inflight.delete(record.jobId)
709
759
  state.jobsRun += 1
710
760
  const resolve = entry.resolve
@@ -789,7 +839,15 @@ export default class BackgroundJobsWorker {
789
839
  async _handlePooledChildFailure({child, error}) {
790
840
  const state = this.pooledChildStates.get(child)
791
841
  if (state?.settling) return
792
- if (state) state.settling = true
842
+ if (state) {
843
+ state.settling = true
844
+ // Cancel this child's pending timers before its in-flight set is reported —
845
+ // the SIGKILL grace from a timeout kill, and every armed per-job backstop.
846
+ if (state.timeoutSigkillTimer) clearTimeout(state.timeoutSigkillTimer)
847
+ for (const inflightEntry of state.inflight.values()) {
848
+ if (inflightEntry.timeoutTimer) clearTimeout(inflightEntry.timeoutTimer)
849
+ }
850
+ }
793
851
  this.pooledChildren.delete(child)
794
852
  this.inflightProcessChildren.delete(child)
795
853
 
@@ -908,7 +966,7 @@ export default class BackgroundJobsWorker {
908
966
  * @returns {ForkedJobTimeoutState} - Timeout state.
909
967
  */
910
968
  _armForkedJobTimeout({child}) {
911
- const timeoutMs = this._resolveForkedJobTimeoutMs()
969
+ const timeoutMs = this._resolveJobTimeoutMs()
912
970
  /** @type {ForkedJobTimeoutState} */
913
971
  const state = {timedOut: false, timeoutMs, timer: null, sigkillTimer: null}
914
972
 
@@ -920,12 +978,12 @@ export default class BackgroundJobsWorker {
920
978
  }
921
979
 
922
980
  /**
923
- * Resolves the effective forked-job timeout in ms, or null when disabled. The
981
+ * Resolves the effective wall-clock job timeout in ms (shared by forked and pooled jobs), or null when disabled. The
924
982
  * constructor override wins; otherwise the value comes from the background-jobs
925
983
  * configuration. A non-positive value disables the backstop.
926
984
  * @returns {number | null} - Timeout in ms, or null when disabled.
927
985
  */
928
- _resolveForkedJobTimeoutMs() {
986
+ _resolveJobTimeoutMs() {
929
987
  const raw = typeof this.jobTimeoutMsOverride === "number"
930
988
  ? this.jobTimeoutMsOverride
931
989
  : (this.configuration ? this.configuration.getBackgroundJobsConfig().jobTimeoutMs : null)
@@ -1,6 +1,48 @@
1
1
  // @ts-check
2
2
 
3
3
  import AlterTableBase from "../../../query/alter-table-base.js"
4
+ import TableColumn from "../../../table-data/table-column.js"
4
5
 
5
6
  export default class VelociousDatabaseConnectionDriversMysqlSqlAlterTable extends AlterTableBase {
7
+ /**
8
+ * Builds MySQL ALTER TABLE statements, adding indexes atomically with columns.
9
+ * @returns {Promise<string[]>} - Resolves with SQL statements.
10
+ */
11
+ async toSQLs() {
12
+ const sqls = await super.toSQLs()
13
+ const indexes = this.tableData.getIndexes()
14
+
15
+ if (indexes.length === 0) return sqls
16
+ if (sqls.length !== 1) throw new Error("Expected one MySQL ALTER TABLE statement when adding indexes")
17
+
18
+ const options = this.getOptions()
19
+ let sql = sqls[0]
20
+
21
+ for (const index of indexes) {
22
+ sql += ", ADD"
23
+
24
+ if (index.getUnique()) sql += " UNIQUE"
25
+
26
+ sql += " INDEX"
27
+
28
+ const indexName = index.getName()
29
+
30
+ if (typeof indexName === "string") {
31
+ sql += ` ${options.quoteIndexName(indexName)}`
32
+ }
33
+
34
+ sql += " ("
35
+ sql += index
36
+ .getColumns()
37
+ .map((column) => {
38
+ const columnName = column instanceof TableColumn ? column.getName() : column
39
+
40
+ return options.quoteColumnName(columnName)
41
+ })
42
+ .join(", ")
43
+ sql += ")"
44
+ }
45
+
46
+ return [sql]
47
+ }
6
48
  }