velocious 1.0.661 → 1.0.663

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 (93) hide show
  1. package/README.md +12 -1
  2. package/build/application.js +1 -1
  3. package/build/background-jobs/adapter.js +9 -0
  4. package/build/background-jobs/job-runner.js +3 -1
  5. package/build/background-jobs/local-adapter.js +7 -0
  6. package/build/background-jobs/local-store.js +113 -3
  7. package/build/background-jobs/main.js +35 -1
  8. package/build/background-jobs/pooled-runner-child.js +38 -0
  9. package/build/background-jobs/status-reporter.js +97 -1
  10. package/build/background-jobs/store.js +130 -4
  11. package/build/background-jobs/types.js +6 -1
  12. package/build/background-jobs/web/controller.js +4 -0
  13. package/build/background-jobs/worker.js +59 -0
  14. package/build/configuration-types.js +3 -1
  15. package/build/configuration.js +33 -0
  16. package/build/http-server/client/errors.js +27 -0
  17. package/build/http-server/client/index.js +26 -1
  18. package/build/http-server/client/request-buffer/index.js +38 -6
  19. package/build/http-server/client/request-runner.js +8 -1
  20. package/build/http-server/client/response.js +11 -0
  21. package/build/routes/resolver.js +1 -1
  22. package/build/src/application.js +2 -2
  23. package/build/src/background-jobs/adapter.d.ts +17 -0
  24. package/build/src/background-jobs/adapter.d.ts.map +1 -1
  25. package/build/src/background-jobs/adapter.js +9 -1
  26. package/build/src/background-jobs/job-runner.d.ts +3 -1
  27. package/build/src/background-jobs/job-runner.d.ts.map +1 -1
  28. package/build/src/background-jobs/job-runner.js +5 -2
  29. package/build/src/background-jobs/local-adapter.d.ts +13 -0
  30. package/build/src/background-jobs/local-adapter.d.ts.map +1 -1
  31. package/build/src/background-jobs/local-adapter.js +7 -1
  32. package/build/src/background-jobs/local-store.d.ts +38 -0
  33. package/build/src/background-jobs/local-store.d.ts.map +1 -1
  34. package/build/src/background-jobs/local-store.js +117 -4
  35. package/build/src/background-jobs/main.d.ts +14 -0
  36. package/build/src/background-jobs/main.d.ts.map +1 -1
  37. package/build/src/background-jobs/main.js +35 -2
  38. package/build/src/background-jobs/pooled-runner-child.js +38 -1
  39. package/build/src/background-jobs/status-reporter.d.ts +51 -1
  40. package/build/src/background-jobs/status-reporter.d.ts.map +1 -1
  41. package/build/src/background-jobs/status-reporter.js +89 -2
  42. package/build/src/background-jobs/store.d.ts +48 -0
  43. package/build/src/background-jobs/store.d.ts.map +1 -1
  44. package/build/src/background-jobs/store.js +128 -5
  45. package/build/src/background-jobs/types.d.ts +34 -2
  46. package/build/src/background-jobs/types.d.ts.map +1 -1
  47. package/build/src/background-jobs/types.js +7 -2
  48. package/build/src/background-jobs/web/controller.d.ts.map +1 -1
  49. package/build/src/background-jobs/web/controller.js +5 -1
  50. package/build/src/background-jobs/worker.d.ts +20 -0
  51. package/build/src/background-jobs/worker.d.ts.map +1 -1
  52. package/build/src/background-jobs/worker.js +58 -1
  53. package/build/src/configuration-types.d.ts +13 -3
  54. package/build/src/configuration-types.d.ts.map +1 -1
  55. package/build/src/configuration-types.js +4 -2
  56. package/build/src/configuration.d.ts +14 -2
  57. package/build/src/configuration.d.ts.map +1 -1
  58. package/build/src/configuration.js +31 -1
  59. package/build/src/http-server/client/errors.d.ts +25 -0
  60. package/build/src/http-server/client/errors.d.ts.map +1 -0
  61. package/build/src/http-server/client/errors.js +26 -0
  62. package/build/src/http-server/client/index.d.ts +5 -0
  63. package/build/src/http-server/client/index.d.ts.map +1 -1
  64. package/build/src/http-server/client/index.js +26 -2
  65. package/build/src/http-server/client/request-buffer/index.d.ts +13 -0
  66. package/build/src/http-server/client/request-buffer/index.d.ts.map +1 -1
  67. package/build/src/http-server/client/request-buffer/index.js +34 -8
  68. package/build/src/http-server/client/request-runner.d.ts.map +1 -1
  69. package/build/src/http-server/client/request-runner.js +10 -2
  70. package/build/src/http-server/client/response.d.ts.map +1 -1
  71. package/build/src/http-server/client/response.js +8 -1
  72. package/build/src/routes/resolver.js +2 -2
  73. package/package.json +1 -1
  74. package/src/application.js +1 -1
  75. package/src/background-jobs/adapter.js +9 -0
  76. package/src/background-jobs/job-runner.js +3 -1
  77. package/src/background-jobs/local-adapter.js +7 -0
  78. package/src/background-jobs/local-store.js +113 -3
  79. package/src/background-jobs/main.js +35 -1
  80. package/src/background-jobs/pooled-runner-child.js +38 -0
  81. package/src/background-jobs/status-reporter.js +97 -1
  82. package/src/background-jobs/store.js +130 -4
  83. package/src/background-jobs/types.js +6 -1
  84. package/src/background-jobs/web/controller.js +4 -0
  85. package/src/background-jobs/worker.js +59 -0
  86. package/src/configuration-types.js +3 -1
  87. package/src/configuration.js +33 -0
  88. package/src/http-server/client/errors.js +27 -0
  89. package/src/http-server/client/index.js +26 -1
  90. package/src/http-server/client/request-buffer/index.js +38 -6
  91. package/src/http-server/client/request-runner.js +8 -1
  92. package/src/http-server/client/response.js +11 -0
  93. package/src/routes/resolver.js +1 -1
@@ -1255,7 +1255,8 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1255
1255
  status: "handed_off",
1256
1256
  handed_off_at_ms: handedOffAtMs,
1257
1257
  handoff_id: handoffId,
1258
- worker_id: workerId || null
1258
+ worker_id: workerId || null,
1259
+ ...this._clearedChildAcceptanceData()
1259
1260
  },
1260
1261
  conditions: {concurrency_key: queuedJob.concurrencyKey, id: jobId, status: "queued"}
1261
1262
  })
@@ -1269,6 +1270,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1269
1270
  /** @type {import("./types.js").BackgroundJobRow} */
1270
1271
  const handedOffJob = {
1271
1272
  ...queuedJob,
1273
+ ...this._clearedChildAcceptanceRow(),
1272
1274
  handedOffAtMs,
1273
1275
  handoffId,
1274
1276
  status: "handed_off",
@@ -1315,6 +1317,67 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1315
1317
  })
1316
1318
  }
1317
1319
 
1320
+ /**
1321
+ * Records pooled-child acceptance evidence for an active handoff: when the
1322
+ * executing runner child received and/or started the job, plus that child's
1323
+ * stable identity and pid. Only the fields supplied are written, so a
1324
+ * received-then-started observation lands as two fenced partial updates. The
1325
+ * update is fenced by the exact active handoff lease, so a report for a
1326
+ * reclaimed or re-handed-off job is dropped instead of stamping the wrong
1327
+ * attempt.
1328
+ * @param {object} args - Options.
1329
+ * @param {string} args.jobId - Job id.
1330
+ * @param {string} [args.handoffId] - Handoff lease id.
1331
+ * @param {string} [args.workerId] - Worker id.
1332
+ * @param {number} [args.handedOffAtMs] - Handed off timestamp.
1333
+ * @param {number} [args.receivedAtMs] - Epoch ms the runner child received the job.
1334
+ * @param {number} [args.startedAtMs] - Epoch ms the job's perform started in the child.
1335
+ * @param {string} [args.childInstanceId] - Stable pooled child identity.
1336
+ * @param {number} [args.childPid] - Pooled child OS pid.
1337
+ * @returns {Promise<boolean>} - Whether the fenced report was accepted.
1338
+ */
1339
+ async markChildAccepted({jobId, handoffId, workerId, handedOffAtMs, receivedAtMs, startedAtMs, childInstanceId, childPid}) {
1340
+ await this.ensureReady()
1341
+
1342
+ return await this._serializedConnectionMutation(async (db) => {
1343
+ const job = await this._getJobRowById(db, jobId)
1344
+
1345
+ if (!job) return false
1346
+ if (!this._shouldAcceptReport({job, handoffId, workerId, handedOffAtMs})) return false
1347
+
1348
+ const data = {}
1349
+ if (typeof receivedAtMs === "number") data.child_received_at_ms = receivedAtMs
1350
+ if (typeof startedAtMs === "number") data.child_started_at_ms = startedAtMs
1351
+ if (typeof childInstanceId === "string") data.child_instance_id = childInstanceId
1352
+ if (typeof childPid === "number") data.child_pid = childPid
1353
+ if (Object.keys(data).length === 0) return false
1354
+
1355
+ const affectedRows = await this._updateAffectedRows(db, {
1356
+ tableName: JOBS_TABLE,
1357
+ data,
1358
+ conditions: this._activeHandoffConditions(job)
1359
+ })
1360
+
1361
+ return affectedRows === 1
1362
+ })
1363
+ }
1364
+
1365
+ /**
1366
+ * Returns the database data that clears pooled-child acceptance evidence.
1367
+ * @returns {Record<string, ReturnType<typeof JSON.parse>>} - Cleared acceptance columns.
1368
+ */
1369
+ _clearedChildAcceptanceData() {
1370
+ return {child_instance_id: null, child_pid: null, child_received_at_ms: null, child_started_at_ms: null}
1371
+ }
1372
+
1373
+ /**
1374
+ * Returns the row-shape counterpart of the cleared acceptance columns.
1375
+ * @returns {Pick<import("./types.js").BackgroundJobRow, "childInstanceId" | "childPid" | "childReceivedAtMs" | "childStartedAtMs">} - Cleared acceptance fields.
1376
+ */
1377
+ _clearedChildAcceptanceRow() {
1378
+ return {childInstanceId: null, childPid: null, childReceivedAtMs: null, childStartedAtMs: null}
1379
+ }
1380
+
1318
1381
  /**
1319
1382
  * Returns an active handoff to the queue at a caller-requested future time.
1320
1383
  * This is normal job control flow: it preserves failure attempts and metadata.
@@ -1345,7 +1408,8 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1345
1408
  scheduled_at_ms: scheduledAtMs,
1346
1409
  handed_off_at_ms: null,
1347
1410
  handoff_id: null,
1348
- worker_id: null
1411
+ worker_id: null,
1412
+ ...this._clearedChildAcceptanceData()
1349
1413
  },
1350
1414
  conditions: this._activeHandoffConditions(job)
1351
1415
  })
@@ -1378,7 +1442,8 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
1378
1442
  scheduled_at_ms: this.clock.now(),
1379
1443
  handed_off_at_ms: null,
1380
1444
  handoff_id: null,
1381
- worker_id: null
1445
+ worker_id: null,
1446
+ ...this._clearedChildAcceptanceData()
1382
1447
  },
1383
1448
  conditions: {handoff_id: handoffId, id: jobId, status: "handed_off"}
1384
1449
  })
@@ -2063,6 +2128,10 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2063
2128
  table.string("concurrency_key", {null: true, index: true})
2064
2129
  table.integer("max_concurrency", {null: true})
2065
2130
  table.bigint("timeout_ms", {null: true})
2131
+ table.bigint("child_received_at_ms", {null: true})
2132
+ table.bigint("child_started_at_ms", {null: true})
2133
+ table.string("child_instance_id", {null: true})
2134
+ table.integer("child_pid", {null: true})
2066
2135
 
2067
2136
  await db.createTable(table)
2068
2137
  }
@@ -2156,9 +2225,56 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2156
2225
  await this._ensureQueueColumn(db)
2157
2226
  await this._ensureScheduleKeyColumn(db)
2158
2227
  await this._ensureJobTimeoutColumn(db)
2228
+ await this._ensureChildAcceptanceColumns(db)
2159
2229
  await this._ensureJobsTableIndexesOnce(db)
2160
2230
  }
2161
2231
 
2232
+ /**
2233
+ * Idempotently adds the pooled-child acceptance evidence columns to existing
2234
+ * job tables. They record when the executing runner child received and
2235
+ * started a job plus that child's identity, so a handed-off job can be told
2236
+ * apart from one whose runner never picked it up.
2237
+ * @param {import("../database/drivers/base.js").default} db - Database connection.
2238
+ * @returns {Promise<void>} - Resolves when ensured.
2239
+ */
2240
+ async _ensureChildAcceptanceColumns(db) {
2241
+ const lockName = `${MIGRATION_SCOPE}:child_acceptance_columns`
2242
+ const acquired = await db.acquireAdvisoryLock(lockName)
2243
+
2244
+ if (!acquired) throw new Error("Failed to acquire background jobs child-acceptance schema lock")
2245
+
2246
+ try {
2247
+ db.clearSchemaCache()
2248
+ const table = await db.getTableByNameOrFail(JOBS_TABLE)
2249
+ const tableData = new TableData(JOBS_TABLE)
2250
+ let added = false
2251
+
2252
+ if (!(await table.getColumnByName("child_received_at_ms"))) {
2253
+ tableData.bigint("child_received_at_ms", {null: true})
2254
+ added = true
2255
+ }
2256
+ if (!(await table.getColumnByName("child_started_at_ms"))) {
2257
+ tableData.bigint("child_started_at_ms", {null: true})
2258
+ added = true
2259
+ }
2260
+ if (!(await table.getColumnByName("child_instance_id"))) {
2261
+ tableData.string("child_instance_id", {null: true})
2262
+ added = true
2263
+ }
2264
+ if (!(await table.getColumnByName("child_pid"))) {
2265
+ tableData.integer("child_pid", {null: true})
2266
+ added = true
2267
+ }
2268
+
2269
+ if (added) {
2270
+ for (const sql of await db.alterTableSQLs(tableData)) await db.query(sql)
2271
+ db.clearSchemaCache()
2272
+ }
2273
+ } finally {
2274
+ await db.releaseAdvisoryLock(lockName)
2275
+ }
2276
+ }
2277
+
2162
2278
  /**
2163
2279
  * Repairs secondary indexes that older add-column upgrades declared but did
2164
2280
  * not create on every SQL driver. The migration ledger keeps routine store
@@ -2514,6 +2630,7 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2514
2630
  /** @type {import("./types.js").BackgroundJobRow} */
2515
2631
  const transitionedJob = {
2516
2632
  ...job,
2633
+ ...(shouldRetry ? this._clearedChildAcceptanceRow() : {}),
2517
2634
  attempts: nextAttempt,
2518
2635
  handedOffAtMs: null,
2519
2636
  lastError: failureMessage,
@@ -2553,6 +2670,11 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2553
2670
  last_error: failureMessage
2554
2671
  }
2555
2672
 
2673
+ // A retry starts a fresh handoff with a possibly different runner, so the
2674
+ // previous child's acceptance evidence must not leak into the next attempt.
2675
+ // Terminal failures keep it as historical evidence for the lost attempt.
2676
+ if (shouldRetry) Object.assign(update, this._clearedChildAcceptanceData())
2677
+
2556
2678
  this._applyOrphanedFailureUpdate({markOrphaned, now, update})
2557
2679
  this._applyFailureStatusUpdate({markOrphaned, now, scheduledAt, shouldRetry, update})
2558
2680
 
@@ -2630,7 +2752,11 @@ export default class BackgroundJobsStore extends BackgroundJobsAdapter {
2630
2752
  lastError: row.last_error ? String(row.last_error) : null,
2631
2753
  concurrencyKey: row.concurrency_key ? String(row.concurrency_key) : null,
2632
2754
  maxConcurrency: this._normalizeNumber(row.max_concurrency),
2633
- timeoutMs: this._normalizeNumber(row.timeout_ms)
2755
+ timeoutMs: this._normalizeNumber(row.timeout_ms),
2756
+ childReceivedAtMs: this._normalizeNumber(row.child_received_at_ms),
2757
+ childStartedAtMs: this._normalizeNumber(row.child_started_at_ms),
2758
+ childInstanceId: row.child_instance_id ? String(row.child_instance_id) : null,
2759
+ childPid: this._normalizeNumber(row.child_pid)
2634
2760
  }
2635
2761
  }
2636
2762
 
@@ -169,6 +169,10 @@
169
169
  * @property {string | null} concurrencyKey - Durable concurrency key.
170
170
  * @property {number | null} maxConcurrency - Durable per-key cap.
171
171
  * @property {number | null} timeoutMs - Per-job wall-clock timeout override, or null when omitted.
172
+ * @property {number | null} childReceivedAtMs - Epoch ms when the executing pooled child's event loop processed the job message, or null when no runner accepted it yet.
173
+ * @property {number | null} childStartedAtMs - Epoch ms when the job's perform started in the pooled child, or null when it never started.
174
+ * @property {string | null} childInstanceId - Stable identity of the pooled child process that accepted the job, or null.
175
+ * @property {number | null} childPid - OS pid of the pooled child process that accepted the job, or null.
172
176
  */
173
177
  /**
174
178
  * @typedef {"queued" | "handed_off" | null} BackgroundJobReplacementPreviousStatus
@@ -219,6 +223,7 @@
219
223
  * @typedef {{type: "schedule-cancelled", jobId: string | null, outcome: BackgroundJobCancellationOutcome}} BackgroundJobScheduleCancelledMessage
220
224
  * @typedef {{type: "cancel-scheduled-error", error?: string}} BackgroundJobCancelScheduledErrorMessage
221
225
  * @typedef {{type: "job", payload: BackgroundJobPayload}} BackgroundJobJobMessage
226
+ * @typedef {{type: "job-accepted", jobId: string, handoffId?: string, workerId?: string, handedOffAtMs?: number, receivedAtMs?: number, startedAtMs?: number, childInstanceId?: string, childPid?: number}} BackgroundJobAcceptedMessage
222
227
  * @typedef {{type: "job-complete", jobId: string, handoffId?: string, workerId?: string, handedOffAtMs?: number}} BackgroundJobCompleteMessage
223
228
  * @typedef {{type: "job-failed", jobId: string, error?: ReturnType<typeof JSON.parse>, handoffId?: string, workerId?: string, handedOffAtMs?: number, runnerFailure?: PooledRunnerFailure}} BackgroundJobFailedMessage
224
229
  * @typedef {{type: "job-reschedule", jobId: string, delayMs: number, handoffId?: string, workerId?: string, handedOffAtMs?: number}} BackgroundJobRescheduleMessage
@@ -226,7 +231,7 @@
226
231
  * @typedef {{type: "job-update-error", jobId: string, error?: string}} BackgroundJobUpdateErrorMessage
227
232
  */
228
233
  /**
229
- * @typedef {BackgroundJobHelloMessage | BackgroundJobGenerationAcceptedMessage | BackgroundJobGenerationRejectedMessage | BackgroundJobReadyMessage | BackgroundJobDrainingMessage | BackgroundJobHeartbeatMessage | BackgroundJobEnqueueMessage | BackgroundJobEnqueuedMessage | BackgroundJobEnqueueErrorMessage | BackgroundJobReplaceScheduledMessage | BackgroundJobScheduleReplacedMessage | BackgroundJobReplaceScheduledErrorMessage | BackgroundJobCancelScheduledMessage | BackgroundJobScheduleCancelledMessage | BackgroundJobCancelScheduledErrorMessage | BackgroundJobJobMessage | BackgroundJobCompleteMessage | BackgroundJobFailedMessage | BackgroundJobRescheduleMessage | BackgroundJobUpdatedMessage | BackgroundJobUpdateErrorMessage} BackgroundJobSocketMessage
234
+ * @typedef {BackgroundJobHelloMessage | BackgroundJobGenerationAcceptedMessage | BackgroundJobGenerationRejectedMessage | BackgroundJobReadyMessage | BackgroundJobDrainingMessage | BackgroundJobHeartbeatMessage | BackgroundJobEnqueueMessage | BackgroundJobEnqueuedMessage | BackgroundJobEnqueueErrorMessage | BackgroundJobReplaceScheduledMessage | BackgroundJobScheduleReplacedMessage | BackgroundJobReplaceScheduledErrorMessage | BackgroundJobCancelScheduledMessage | BackgroundJobScheduleCancelledMessage | BackgroundJobCancelScheduledErrorMessage | BackgroundJobJobMessage | BackgroundJobAcceptedMessage | BackgroundJobCompleteMessage | BackgroundJobFailedMessage | BackgroundJobRescheduleMessage | BackgroundJobUpdatedMessage | BackgroundJobUpdateErrorMessage} BackgroundJobSocketMessage
230
235
  */
231
236
 
232
237
  export const nothing = {}
@@ -192,6 +192,10 @@ export default class VelociousBackgroundJobsWebController extends Controller {
192
192
  args: redactArgs ? undefined : job.args,
193
193
  argsRedacted: redactArgs,
194
194
  attempts: job.attempts,
195
+ childInstanceId: job.childInstanceId,
196
+ childPid: job.childPid,
197
+ childReceivedAtMs: job.childReceivedAtMs,
198
+ childStartedAtMs: job.childStartedAtMs,
195
199
  completedAtMs: job.completedAtMs,
196
200
  createdAtMs: job.createdAtMs,
197
201
  executionMode: job.executionMode,
@@ -56,6 +56,12 @@ const FORKED_RUNNER_ENTRY_PATH = fileURLToPath(new URL("./forked-runner-child.js
56
56
  const POOLED_RUNNER_ENTRY_PATH = fileURLToPath(new URL("./pooled-runner-child.js", import.meta.url))
57
57
  /** How often the worker sends a liveness heartbeat to the main. */
58
58
  const HEARTBEAT_INTERVAL_MS = 15000
59
+ /**
60
+ * Max time the worker spends retrying one pooled child's acceptance report
61
+ * before dropping it. Acceptance evidence is diagnostic — a persistent
62
+ * main/DB outage must not hold runner capacity hostage.
63
+ */
64
+ const CHILD_ACCEPTANCE_REPORT_MAX_DURATION_MS = 10000
59
65
  /** TCP keepalive so a half-open connection to the main surfaces as a close. */
60
66
  const SOCKET_KEEPALIVE_MS = 10000
61
67
  /**
@@ -72,6 +78,28 @@ function positiveInteger(value) {
72
78
  return typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined
73
79
  }
74
80
 
81
+ /**
82
+ * Checks whether an IPC value is a pooled child's acceptance observation for
83
+ * one job. The child carries its exact handoff lease so the worker can forward
84
+ * the report without depending on its in-flight entry still existing.
85
+ * @param {ReturnType<typeof JSON.parse>} message - IPC message.
86
+ * @returns {message is {type: "job-received" | "job-started", jobId: string, handoffId?: string, workerId?: string, handedOffAtMs?: number, receivedAtMs?: number, startedAtMs?: number, childInstanceId?: string, childPid?: number}} - Whether this is a valid acceptance message.
87
+ */
88
+ function isChildAcceptanceMessage(message) {
89
+ if (!message || typeof message !== "object") return false
90
+ const record = /** @type {Record<string, ReturnType<typeof JSON.parse>>} */ (message)
91
+
92
+ return (record.type === "job-received" || record.type === "job-started")
93
+ && typeof record.jobId === "string"
94
+ && (record.handoffId === undefined || typeof record.handoffId === "string")
95
+ && (record.workerId === undefined || typeof record.workerId === "string")
96
+ && (record.handedOffAtMs === undefined || typeof record.handedOffAtMs === "number")
97
+ && (record.receivedAtMs === undefined || typeof record.receivedAtMs === "number")
98
+ && (record.startedAtMs === undefined || typeof record.startedAtMs === "number")
99
+ && (record.childInstanceId === undefined || typeof record.childInstanceId === "string")
100
+ && (record.childPid === undefined || Number.isInteger(record.childPid))
101
+ }
102
+
75
103
  /**
76
104
  * Normalizes a candidate pooled-runner resource limit.
77
105
  * @param {number | undefined} value - Candidate positive number.
@@ -1225,6 +1253,10 @@ export default class BackgroundJobsWorker {
1225
1253
  if (state) state.started = true
1226
1254
  return
1227
1255
  }
1256
+ if (isChildAcceptanceMessage(message)) {
1257
+ this._reportChildAccepted(message)
1258
+ return
1259
+ }
1228
1260
  if (record.type !== "job-outcome" || !state || state.settling || typeof record.jobId !== "string") return
1229
1261
  state.started = true
1230
1262
  const entry = state.inflight.get(record.jobId)
@@ -1258,6 +1290,33 @@ export default class BackgroundJobsWorker {
1258
1290
  this._terminateIfDrained(child)
1259
1291
  }
1260
1292
 
1293
+ /**
1294
+ * Forwards one pooled child's acceptance observation to main as a bounded
1295
+ * diagnostic report. The child carries its exact handoff lease, so a timeout
1296
+ * or outcome that already settled the worker's in-flight entry cannot lose
1297
+ * the fencing. A report that never lands degrades phase diagnostics for that
1298
+ * job only — it must never block or fail the job itself.
1299
+ * @param {{type: "job-received" | "job-started", jobId: string, handoffId?: string, workerId?: string, handedOffAtMs?: number, receivedAtMs?: number, startedAtMs?: number, childInstanceId?: string, childPid?: number}} message - Validated child acceptance message.
1300
+ * @returns {void}
1301
+ */
1302
+ _reportChildAccepted(message) {
1303
+ if (!this.statusReporter) return
1304
+
1305
+ void this.statusReporter.reportChildAcceptedWithRetry({
1306
+ jobId: message.jobId,
1307
+ handoffId: message.handoffId,
1308
+ workerId: message.workerId,
1309
+ handedOffAtMs: message.handedOffAtMs,
1310
+ receivedAtMs: message.receivedAtMs,
1311
+ startedAtMs: message.startedAtMs,
1312
+ childInstanceId: message.childInstanceId,
1313
+ childPid: message.childPid,
1314
+ maxDurationMs: CHILD_ACCEPTANCE_REPORT_MAX_DURATION_MS
1315
+ }).catch((error) => {
1316
+ console.error("Background job child-acceptance reporting failed:", error)
1317
+ })
1318
+ }
1319
+
1261
1320
  /**
1262
1321
  * Marks a pooled child for retirement and eagerly spawns a single replacement
1263
1322
  * (1-for-1) so its capacity is restored immediately without waiting for it to
@@ -293,6 +293,8 @@
293
293
  * @property {boolean | HttpCompressionConfiguration} [compression] - Buffered response compression. Enabled with documented defaults when absent; false or {enabled: false} disables it globally.
294
294
  * @property {string} [host] - Hostname to bind the HTTP server to.
295
295
  * @property {boolean} [inProcess] - Run HTTP handlers in the main thread instead of worker threads.
296
+ * @property {number} [maxBufferedResponseBodyBytes] - Maximum UTF-8/binary byte length accepted by buffered response bodies. Unbounded when absent; streamed file responses are unaffected.
297
+ * @property {number} [maxRequestBodyBytes] - Maximum decoded request-body bytes accepted before routing. Unbounded when absent.
296
298
  * @property {number} [maxWorkers] - Backward-compatible alias for workers.
297
299
  * @property {number} [port] - Port to bind the HTTP server to.
298
300
  * @property {{maxPendingBytes?: number, maxPendingMessages?: number}} [websocketInboundQueue] - Per-session retained inbound WebSocket message limits.
@@ -735,7 +737,7 @@
735
737
  * @property {AttachmentsConfiguration} [attachments] - Attachment storage configuration.
736
738
  * @property {BackendProjectConfiguration[]} [backendProjects] - Backend project definitions used for frontend model generation.
737
739
  * @property {VelociousPackageConfiguration[]} [packages] - External Velocious packages that contribute models, frontend-model resources and migrations.
738
- * @property {{[key: string]: {[key: string]: DatabaseConfigurationType}}} database - Database configurations keyed by environment and identifier.
740
+ * @property {false | {[key: string]: {[key: string]: DatabaseConfigurationType}}} [database] - Database configurations keyed by environment and identifier, or false for an explicitly database-free application.
739
741
  * @property {boolean} [debug] - Enable debug logging.
740
742
  * @property {boolean | DebugEndpointConfiguration} [debugEndpoint] - Enable the built-in debug endpoint. Defaults to false.
741
743
  * @property {boolean | ApiManifestConfiguration} [apiManifest] - Enable the built-in API manifest endpoint. Defaults to false.
@@ -155,6 +155,21 @@ function positiveSafeInteger(value, name, defaultValue) {
155
155
  return value
156
156
  }
157
157
 
158
+ /**
159
+ * Validates an optional positive safe integer configuration value.
160
+ * @param {ReturnType<typeof JSON.parse>} value - Configured positive safe integer.
161
+ * @param {string} name - Configuration key.
162
+ * @returns {number | undefined} - Validated configured value.
163
+ */
164
+ function optionalPositiveSafeInteger(value, name) {
165
+ if (value === undefined) return undefined
166
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
167
+ throw new TypeError(`${name} must be a positive safe integer`)
168
+ }
169
+
170
+ return value
171
+ }
172
+
158
173
  /**
159
174
  * Validates an integer configuration value inside an inclusive range.
160
175
  * @param {ReturnType<typeof JSON.parse>} value - Configured integer.
@@ -347,6 +362,8 @@ export default class VelociousConfiguration {
347
362
  this.httpServer = {
348
363
  ...(httpServer || {}),
349
364
  compression: normalizeHttpCompression(httpServer?.compression),
365
+ maxBufferedResponseBodyBytes: optionalPositiveSafeInteger(httpServer?.maxBufferedResponseBodyBytes, "httpServer.maxBufferedResponseBodyBytes"),
366
+ maxRequestBodyBytes: optionalPositiveSafeInteger(httpServer?.maxRequestBodyBytes, "httpServer.maxRequestBodyBytes"),
350
367
  websocketInboundQueue: {
351
368
  maxPendingBytes: positiveSafeInteger(websocketInboundQueue?.maxPendingBytes, "httpServer.websocketInboundQueue.maxPendingBytes", DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_BYTES),
352
369
  maxPendingMessages: positiveSafeInteger(websocketInboundQueue?.maxPendingMessages, "httpServer.websocketInboundQueue.maxPendingMessages", DEFAULT_WEBSOCKET_INBOUND_MAX_PENDING_MESSAGES)
@@ -644,6 +661,22 @@ export default class VelociousConfiguration {
644
661
  return this.httpServer.compression
645
662
  }
646
663
 
664
+ /**
665
+ * Runs get maximum buffered response body bytes.
666
+ * @returns {number | undefined} - Configured byte limit, or undefined when unbounded.
667
+ */
668
+ getHttpServerMaxBufferedResponseBodyBytes() {
669
+ return this.httpServer.maxBufferedResponseBodyBytes
670
+ }
671
+
672
+ /**
673
+ * Runs get maximum request body bytes.
674
+ * @returns {number | undefined} - Configured byte limit, or undefined when unbounded.
675
+ */
676
+ getHttpServerMaxRequestBodyBytes() {
677
+ return this.httpServer.maxRequestBodyBytes
678
+ }
679
+
647
680
  /**
648
681
  * Runs get cookie secret.
649
682
  * @returns {string | undefined} - Cookie secret.
@@ -0,0 +1,27 @@
1
+ // @ts-check
2
+
3
+ export class HttpRequestBodyTooLargeError extends Error {
4
+ /**
5
+ * Creates a request-body limit error.
6
+ * @param {object} args - Size details.
7
+ * @param {number} args.actualBytes - Declared or accumulated body size.
8
+ * @param {number} args.maxBytes - Configured maximum body size.
9
+ */
10
+ constructor({actualBytes, maxBytes}) {
11
+ super(`HTTP request body exceeds ${maxBytes} bytes (received or declared ${actualBytes})`)
12
+ this.name = "HttpRequestBodyTooLargeError"
13
+ }
14
+ }
15
+
16
+ export class HttpResponseBodyTooLargeError extends Error {
17
+ /**
18
+ * Creates a buffered-response limit error.
19
+ * @param {object} args - Size details.
20
+ * @param {number} args.actualBytes - Buffered response body size.
21
+ * @param {number} args.maxBytes - Configured maximum body size.
22
+ */
23
+ constructor({actualBytes, maxBytes}) {
24
+ super(`Buffered HTTP response body exceeds ${maxBytes} bytes (received ${actualBytes})`)
25
+ this.name = "HttpResponseBodyTooLargeError"
26
+ }
27
+ }
@@ -5,6 +5,7 @@ import fs from "node:fs/promises"
5
5
  import {digg} from "diggerize"
6
6
  import {ensureError} from "typanic"
7
7
  import EventEmitter from "../../utils/event-emitter.js"
8
+ import {HttpRequestBodyTooLargeError} from "./errors.js"
8
9
  import Logger from "../../logger.js"
9
10
  import Request from "./request.js"
10
11
  import RequestRunner from "./request-runner.js"
@@ -104,6 +105,26 @@ export default class VeoliciousHttpServerClient {
104
105
  this.events.emit("close")
105
106
  }
106
107
 
108
+ /**
109
+ * Sends a deterministic request-body limit response and closes the connection.
110
+ * @returns {void} - No return value.
111
+ */
112
+ _sendPayloadTooLargeResponse() {
113
+ const httpVersion = this.currentRequest?.httpVersion() || "1.1"
114
+ const body = "Payload Too Large\n"
115
+ const headers = [
116
+ `HTTP/${httpVersion} 413 Payload Too Large`,
117
+ "Connection: Close",
118
+ "Content-Type: text/plain; charset=UTF-8",
119
+ `Content-Length: ${Buffer.byteLength(body, "utf8")}`,
120
+ "",
121
+ body
122
+ ].join("\r\n")
123
+
124
+ this.events.emit("output", headers)
125
+ this.events.emit("close")
126
+ }
127
+
107
128
  /**
108
129
  * Runs handle bad request.
109
130
  * @param {Error} error - Error instance.
@@ -121,7 +142,11 @@ export default class VeoliciousHttpServerClient {
121
142
  this.currentRequest = undefined
122
143
  this.state = "initial"
123
144
 
124
- this._sendBadRequestResponse("Bad Request")
145
+ if (error instanceof HttpRequestBodyTooLargeError) {
146
+ this._sendPayloadTooLargeResponse()
147
+ } else {
148
+ this._sendBadRequestResponse("Bad Request")
149
+ }
125
150
  }
126
151
 
127
152
  executeCurrentRequest = () => {
@@ -3,6 +3,7 @@
3
3
  import EventEmitter from "../../../utils/event-emitter.js"
4
4
  import FormDataPart from "./form-data-part.js"
5
5
  import Header from "./header.js"
6
+ import {HttpRequestBodyTooLargeError} from "../errors.js"
6
7
  import {incorporate} from "incorporator"
7
8
  import Logger from "../../../logger.js"
8
9
  import ParamsToObject from "../params-to-object.js"
@@ -66,6 +67,31 @@ export default class RequestBuffer {
66
67
  this.logger = new Logger(this, {debug: false})
67
68
  }
68
69
 
70
+ /**
71
+ * Raises before buffering a request body beyond the configured bound.
72
+ * @param {number} actualBytes - Declared or accumulated decoded body bytes.
73
+ * @returns {void}
74
+ */
75
+ assertRequestBodySize(actualBytes) {
76
+ const maxBytes = this.configuration.getHttpServerMaxRequestBodyBytes()
77
+
78
+ if (maxBytes !== undefined && actualBytes > maxBytes) {
79
+ throw new HttpRequestBodyTooLargeError({actualBytes, maxBytes})
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Records bytes consumed after request headers. Multipart parsing can accept
85
+ * an unframed body, so enforce its configured bound during accumulation.
86
+ * @param {number} bytes - Newly consumed body bytes.
87
+ * @returns {void}
88
+ */
89
+ recordBodyBytes(bytes) {
90
+ this.bodyLength += bytes
91
+
92
+ if (this.multiPartyFormData) this.assertRequestBodySize(this.bodyLength)
93
+ }
94
+
69
95
  destroy() {
70
96
  // Do nothing for now...
71
97
  }
@@ -113,7 +139,7 @@ export default class RequestBuffer {
113
139
  const newlineIndex = data.indexOf(10, index)
114
140
 
115
141
  if (newlineIndex === -1) {
116
- if (this.readingBody) this.bodyLength += data.length - index
142
+ if (this.readingBody) this.recordBodyBytes(data.length - index)
117
143
 
118
144
  for (let dataIndex = index; dataIndex < data.length; dataIndex += 1) {
119
145
  this.data.push(data[dataIndex])
@@ -122,7 +148,7 @@ export default class RequestBuffer {
122
148
  return data.length
123
149
  }
124
150
 
125
- if (this.readingBody) this.bodyLength += newlineIndex + 1 - index
151
+ if (this.readingBody) this.recordBodyBytes(newlineIndex + 1 - index)
126
152
 
127
153
  let line
128
154
 
@@ -157,7 +183,7 @@ export default class RequestBuffer {
157
183
  const endIndex = Math.min(data.length, index + remainingBodyBytes)
158
184
 
159
185
  this.postBodyBuffers.push(data.subarray(index, endIndex))
160
- this.bodyLength += endIndex - index
186
+ this.recordBodyBytes(endIndex - index)
161
187
 
162
188
  if (this.contentLength && this.bodyLength >= this.contentLength) {
163
189
  this.postRequestDone()
@@ -175,7 +201,7 @@ export default class RequestBuffer {
175
201
  feedByte(data, index) {
176
202
  const char = data[index]
177
203
 
178
- if (this.readingBody) this.bodyLength += 1
204
+ if (this.readingBody) this.recordBodyBytes(1)
179
205
 
180
206
  switch(this.state) {
181
207
  case "chunked-data": {
@@ -414,6 +440,12 @@ export default class RequestBuffer {
414
440
  this.readingBody = true
415
441
  this.bodyLength = 0
416
442
 
443
+ if (this.contentLength !== undefined) {
444
+ if (Number.isNaN(this.contentLength)) throw new Error("Content length is invalid")
445
+
446
+ this.assertRequestBodySize(this.contentLength)
447
+ }
448
+
417
449
  const match = this.getHeader("content-type")?.value?.match(/^multipart\/form-data;\s*boundary=(.+)$/i)
418
450
 
419
451
  if (match) {
@@ -425,8 +457,6 @@ export default class RequestBuffer {
425
457
  this.setState("multi-part-form-data")
426
458
  } else if (this.contentLength === 0 || this.contentLength === undefined) {
427
459
  this.completeRequest()
428
- } else if (Number.isNaN(this.contentLength)) {
429
- throw new Error("Content length is invalid")
430
460
  } else {
431
461
  /**
432
462
  * Narrows the runtime value to the documented type.
@@ -521,6 +551,8 @@ export default class RequestBuffer {
521
551
  return
522
552
  }
523
553
 
554
+ this.assertRequestBodySize((this.chunkedBodyChars?.length || 0) + size)
555
+
524
556
  this.currentChunkSize = size
525
557
  this.currentChunkBytesRead = 0
526
558
  this.setState("chunked-data")
@@ -3,6 +3,7 @@
3
3
  import {ensureError} from "typanic"
4
4
  import BacktraceCleaner from "../../utils/backtrace-cleaner-node.js"
5
5
  import EventEmitter from "../../utils/event-emitter.js"
6
+ import {HttpResponseBodyTooLargeError} from "./errors.js"
6
7
  import Logger from "../../logger.js"
7
8
  import RequestTiming from "./request-timing.js"
8
9
  import Response from "./response.js"
@@ -304,7 +305,13 @@ export default class VelociousHttpServerClientRequestRunner {
304
305
  })
305
306
 
306
307
  response.setStatus(500)
307
- response.setErrorBody(error)
308
+ try {
309
+ response.setErrorBody(error)
310
+ } catch (responseError) {
311
+ if (!(responseError instanceof HttpResponseBodyTooLargeError)) throw responseError
312
+
313
+ response.setBody("")
314
+ }
308
315
  }
309
316
 
310
317
  await this.logger.debug(() => ["Request runner done", {
@@ -1,5 +1,7 @@
1
1
  // @ts-check
2
2
 
3
+ import {HttpResponseBodyTooLargeError} from "./errors.js"
4
+
3
5
  /**
4
6
  * Named status aliases.
5
7
  * @type {Record<string, number>} */
@@ -226,6 +228,15 @@ export default class VelociousHttpServerClientResponse {
226
228
  setBody(value) {
227
229
  this.filePath = null
228
230
  this.fileOnFinished = null
231
+
232
+ const actualBytes = typeof value === "string" ? Buffer.byteLength(value, "utf8") : value.byteLength
233
+ const maxBytes = this.configuration.getHttpServerMaxBufferedResponseBodyBytes()
234
+
235
+ if (maxBytes !== undefined && actualBytes > maxBytes) {
236
+ this.body = ""
237
+ throw new HttpResponseBodyTooLargeError({actualBytes, maxBytes})
238
+ }
239
+
229
240
  this.body = value
230
241
  }
231
242
 
@@ -92,7 +92,7 @@ export default class VelociousRoutesResolver {
92
92
  }
93
93
 
94
94
  const routeResolverHookMatch = await this.resolveRouteResolverHooks(currentPath, {hasMatchingCustomRoute})
95
- let skipControllerConnections = routeResolverHookMatch?.skipControllerConnections === true
95
+ let skipControllerConnections = this.configuration.database === false || routeResolverHookMatch?.skipControllerConnections === true
96
96
  let skipAbilityResolution = routeResolverHookMatch?.skipAbilityResolution === true
97
97
  let skipTenantResolution = routeResolverHookMatch?.skipTenantResolution === true
98
98
  const matchResult = routeResolverHookMatch || !currentRoute ? undefined : this.matchPathWithRoutes(currentRoute, currentPath)