velocious 1.0.630 → 1.0.631

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 (115) hide show
  1. package/README.md +36 -3
  2. package/build/background-jobs/adapter.js +8 -0
  3. package/build/background-jobs/client.js +11 -2
  4. package/build/background-jobs/generation-handshake-timeout-error.js +32 -0
  5. package/build/background-jobs/generation-identity.js +160 -0
  6. package/build/background-jobs/lifecycle-client.js +127 -0
  7. package/build/background-jobs/lifecycle-control-server.js +310 -0
  8. package/build/background-jobs/main.js +591 -103
  9. package/build/background-jobs/socket-request.js +44 -3
  10. package/build/background-jobs/sql-adapter.js +6 -0
  11. package/build/background-jobs/status-reporter.js +11 -2
  12. package/build/background-jobs/types.js +7 -2
  13. package/build/background-jobs/worker.js +313 -42
  14. package/build/cli/commands/background-jobs/activate.js +7 -0
  15. package/build/cli/commands/background-jobs/retire.js +7 -0
  16. package/build/cli/index.js +1 -1
  17. package/build/configuration-types.js +4 -0
  18. package/build/configuration.js +47 -11
  19. package/build/environment-handlers/base.js +19 -1
  20. package/build/environment-handlers/node/cli/commands/background-jobs-activate.js +19 -0
  21. package/build/environment-handlers/node/cli/commands/background-jobs-main.js +71 -11
  22. package/build/environment-handlers/node/cli/commands/background-jobs-retire.js +19 -0
  23. package/build/environment-handlers/node/cli/commands/background-jobs-worker.js +14 -4
  24. package/build/environment-handlers/node.js +26 -6
  25. package/build/src/background-jobs/adapter.d.ts +7 -0
  26. package/build/src/background-jobs/adapter.d.ts.map +1 -1
  27. package/build/src/background-jobs/adapter.js +8 -1
  28. package/build/src/background-jobs/client.d.ts +7 -1
  29. package/build/src/background-jobs/client.d.ts.map +1 -1
  30. package/build/src/background-jobs/client.js +12 -3
  31. package/build/src/background-jobs/generation-handshake-timeout-error.d.ts +25 -0
  32. package/build/src/background-jobs/generation-handshake-timeout-error.d.ts.map +1 -0
  33. package/build/src/background-jobs/generation-handshake-timeout-error.js +29 -0
  34. package/build/src/background-jobs/generation-identity.d.ts +73 -0
  35. package/build/src/background-jobs/generation-identity.d.ts.map +1 -0
  36. package/build/src/background-jobs/generation-identity.js +135 -0
  37. package/build/src/background-jobs/lifecycle-client.d.ts +48 -0
  38. package/build/src/background-jobs/lifecycle-client.d.ts.map +1 -0
  39. package/build/src/background-jobs/lifecycle-client.js +126 -0
  40. package/build/src/background-jobs/lifecycle-control-server.d.ts +104 -0
  41. package/build/src/background-jobs/lifecycle-control-server.d.ts.map +1 -0
  42. package/build/src/background-jobs/lifecycle-control-server.js +307 -0
  43. package/build/src/background-jobs/main.d.ts +199 -14
  44. package/build/src/background-jobs/main.d.ts.map +1 -1
  45. package/build/src/background-jobs/main.js +587 -105
  46. package/build/src/background-jobs/socket-request.d.ts +7 -1
  47. package/build/src/background-jobs/socket-request.d.ts.map +1 -1
  48. package/build/src/background-jobs/socket-request.js +42 -4
  49. package/build/src/background-jobs/sql-adapter.d.ts +5 -0
  50. package/build/src/background-jobs/sql-adapter.d.ts.map +1 -1
  51. package/build/src/background-jobs/sql-adapter.js +6 -1
  52. package/build/src/background-jobs/status-reporter.d.ts +7 -1
  53. package/build/src/background-jobs/status-reporter.d.ts.map +1 -1
  54. package/build/src/background-jobs/status-reporter.js +12 -3
  55. package/build/src/background-jobs/types.d.ts +21 -3
  56. package/build/src/background-jobs/types.d.ts.map +1 -1
  57. package/build/src/background-jobs/types.js +8 -3
  58. package/build/src/background-jobs/worker.d.ts +77 -3
  59. package/build/src/background-jobs/worker.d.ts.map +1 -1
  60. package/build/src/background-jobs/worker.js +308 -41
  61. package/build/src/cli/commands/background-jobs/activate.d.ts +5 -0
  62. package/build/src/cli/commands/background-jobs/activate.d.ts.map +1 -0
  63. package/build/src/cli/commands/background-jobs/activate.js +7 -0
  64. package/build/src/cli/commands/background-jobs/retire.d.ts +5 -0
  65. package/build/src/cli/commands/background-jobs/retire.d.ts.map +1 -0
  66. package/build/src/cli/commands/background-jobs/retire.js +7 -0
  67. package/build/src/cli/index.js +2 -2
  68. package/build/src/configuration-types.d.ts +17 -0
  69. package/build/src/configuration-types.d.ts.map +1 -1
  70. package/build/src/configuration-types.js +5 -1
  71. package/build/src/configuration.d.ts +26 -2
  72. package/build/src/configuration.d.ts.map +1 -1
  73. package/build/src/configuration.js +37 -3
  74. package/build/src/environment-handlers/base.d.ts +12 -0
  75. package/build/src/environment-handlers/base.d.ts.map +1 -1
  76. package/build/src/environment-handlers/base.js +17 -1
  77. package/build/src/environment-handlers/node/cli/commands/background-jobs-activate.d.ts +5 -0
  78. package/build/src/environment-handlers/node/cli/commands/background-jobs-activate.d.ts.map +1 -0
  79. package/build/src/environment-handlers/node/cli/commands/background-jobs-activate.js +17 -0
  80. package/build/src/environment-handlers/node/cli/commands/background-jobs-main.d.ts +45 -0
  81. package/build/src/environment-handlers/node/cli/commands/background-jobs-main.d.ts.map +1 -1
  82. package/build/src/environment-handlers/node/cli/commands/background-jobs-main.js +67 -10
  83. package/build/src/environment-handlers/node/cli/commands/background-jobs-retire.d.ts +5 -0
  84. package/build/src/environment-handlers/node/cli/commands/background-jobs-retire.d.ts.map +1 -0
  85. package/build/src/environment-handlers/node/cli/commands/background-jobs-retire.js +17 -0
  86. package/build/src/environment-handlers/node/cli/commands/background-jobs-worker.d.ts.map +1 -1
  87. package/build/src/environment-handlers/node/cli/commands/background-jobs-worker.js +16 -5
  88. package/build/src/environment-handlers/node.d.ts +12 -0
  89. package/build/src/environment-handlers/node.d.ts.map +1 -1
  90. package/build/src/environment-handlers/node.js +19 -1
  91. package/build/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +1 -1
  93. package/src/background-jobs/adapter.js +8 -0
  94. package/src/background-jobs/client.js +11 -2
  95. package/src/background-jobs/generation-handshake-timeout-error.js +32 -0
  96. package/src/background-jobs/generation-identity.js +160 -0
  97. package/src/background-jobs/lifecycle-client.js +127 -0
  98. package/src/background-jobs/lifecycle-control-server.js +310 -0
  99. package/src/background-jobs/main.js +591 -103
  100. package/src/background-jobs/socket-request.js +44 -3
  101. package/src/background-jobs/sql-adapter.js +6 -0
  102. package/src/background-jobs/status-reporter.js +11 -2
  103. package/src/background-jobs/types.js +7 -2
  104. package/src/background-jobs/worker.js +313 -42
  105. package/src/cli/commands/background-jobs/activate.js +7 -0
  106. package/src/cli/commands/background-jobs/retire.js +7 -0
  107. package/src/cli/index.js +1 -1
  108. package/src/configuration-types.js +4 -0
  109. package/src/configuration.js +47 -11
  110. package/src/environment-handlers/base.js +19 -1
  111. package/src/environment-handlers/node/cli/commands/background-jobs-activate.js +19 -0
  112. package/src/environment-handlers/node/cli/commands/background-jobs-main.js +71 -11
  113. package/src/environment-handlers/node/cli/commands/background-jobs-retire.js +19 -0
  114. package/src/environment-handlers/node/cli/commands/background-jobs-worker.js +14 -4
  115. package/src/environment-handlers/node.js +26 -6
package/README.md CHANGED
@@ -34,7 +34,7 @@
34
34
  * Cross-process broadcast bus for `broadcastToChannel` via `velocious beacon`, including background job runner processes (see [docs/beacon.md](docs/beacon.md))
35
35
  * Configurable HTTP server worker handlers plus backpressured, descriptor-only file responses with completion callbacks (see [docs/http-server.md](docs/http-server.md))
36
36
  * Default-on buffered HTTP response compression with Brotli/gzip content negotiation, global and per-response opt-outs, and HEAD-correct representation headers (see [docs/http-server.md](docs/http-server.md#response-compression))
37
- * Background jobs with Node SQL/TCP workers plus a Browser/Expo local SQLite store and in-process dispatcher, including failure events and authorized database-scoped dashboard count snapshots/deltas. Release-directory integrations have a required release-scoped jobs-main/worker generation and asynchronous retirement compliance target that current startup adoption does not implement end to end (see [docs/background-jobs.md](docs/background-jobs.md), [docs/local-background-jobs.md](docs/local-background-jobs.md), and [docs/background-jobs-dashboard.md](docs/background-jobs-dashboard.md))
37
+ * Background jobs with Node SQL/TCP workers plus a Browser/Expo local SQLite store and in-process dispatcher, including failure events, authorized database-scoped dashboard counts, and an opt-in release-scoped main/worker generation protocol with acknowledged activation, asynchronous retirement, and retired-main recovery. Production compliance additionally requires downstream supervisor retention/activation ordering and release pins (see [docs/background-jobs.md](docs/background-jobs.md), [docs/local-background-jobs.md](docs/local-background-jobs.md), and [docs/background-jobs-dashboard.md](docs/background-jobs-dashboard.md))
38
38
  * Durable one-off background-job scheduling with exact epoch timestamps (see [docs/scheduled-background-job-enqueue.md](docs/scheduled-background-job-enqueue.md))
39
39
  * Rails-style request and database query logging (see [docs/logging.md](docs/logging.md))
40
40
  * EJS-backed mailers with delivery, queueing, and payload rendering support (see [docs/mailers.md](docs/mailers.md))
@@ -2321,6 +2321,11 @@ the new main. Deploy and HTTP/WebSocket drain completion are independent of this
2321
2321
  potentially hours-long lifecycle. See [release-generation
2322
2322
  draining](docs/background-jobs.md#release-generation-draining).
2323
2323
 
2324
+ Velocious provides the opt-in generation protocol; production still requires a
2325
+ supervisor that preserves old generation units and release pins, and a deploy
2326
+ coordinator that retires the old generation before activating the healthy
2327
+ candidate without waiting for retired work to finish.
2328
+
2324
2329
  Jobs can opt into cross-worker durable concurrency limits by pairing a non-empty `concurrencyKey` with a positive-integer `maxConcurrency` in their background-job options, or by deriving the key in a hydrated job instance's non-static `concurrencyKey()` method. Explicit enqueue options win. The first cap registered for a key is stable; conflicting caps are rejected. See [durable concurrency limits](docs/background-jobs.md#durable-concurrency-limits).
2325
2330
 
2326
2331
  Production apps can listen for `background-job-failed` (or its `all-error` mirror) to report accepted failed attempts, including retry and terminal-state metadata, and for `background-job-orphaned` to react to a specific job the main process reclaimed after its worker died mid-run — e.g. enqueue a targeted recovery for the work it left behind, instead of only polling for the aftermath. Orphan handlers run before the sweep waits for reclaimed jobs to be dispatched, so a stalled dispatcher does not delay application recovery. See [docs/background-jobs.md](docs/background-jobs.md#failure-events).
@@ -2365,7 +2370,11 @@ export default new Configuration({
2365
2370
  pooledRunnerMaxRssBytes: 536870912,
2366
2371
  pooledRunnerMaxLifetimeMs: 3600000,
2367
2372
  dispatchStrategy: "beacon",
2368
- jobTimeoutMs: null
2373
+ jobTimeoutMs: null,
2374
+ // Release-directory deployments opt in with one exact id and local socket:
2375
+ generationId: "release-20260828.1",
2376
+ initialGenerationState: "candidate",
2377
+ lifecycleSocketPath: "/srv/app/releases/20260828.1/run/background-jobs.sock"
2369
2378
  }
2370
2379
  })
2371
2380
  ```
@@ -2428,8 +2437,32 @@ VELOCIOUS_BACKGROUND_JOBS_DISPATCH_STRATEGY=beacon
2428
2437
  VELOCIOUS_BACKGROUND_JOBS_POLL_INTERVAL_MS=1000
2429
2438
  VELOCIOUS_BACKGROUND_JOBS_WORKER_SHUTDOWN_TIMEOUT_MS=indefinite
2430
2439
  VELOCIOUS_BACKGROUND_JOBS_JOB_TIMEOUT_MS=5400000
2440
+ # Opt-in release generation values (omit all three for exact legacy behavior):
2441
+ VELOCIOUS_BACKGROUND_JOBS_GENERATION_ID=release-20260828.1
2442
+ VELOCIOUS_BACKGROUND_JOBS_INITIAL_GENERATION_STATE=candidate
2443
+ VELOCIOUS_BACKGROUND_JOBS_LIFECYCLE_SOCKET_PATH=/srv/app/releases/20260828.1/run/background-jobs.sock
2431
2444
  ```
2432
2445
 
2446
+ Activate or retire that exact generation with one acknowledged local request:
2447
+
2448
+ ```sh
2449
+ npx velocious background-jobs:activate --generation release-20260828.1 --socket /srv/app/releases/20260828.1/run/background-jobs.sock
2450
+ npx velocious background-jobs:retire --generation release-20260828.1 --socket /srv/app/releases/20260828.1/run/background-jobs.sock
2451
+ ```
2452
+
2453
+ Each lifecycle command sends one request with no retry and has a hard 10000ms
2454
+ deadline; `--timeout-ms` accepts 1 through 25000ms. Generation-aware workers,
2455
+ clients, and reporters require their hello acknowledgement before readiness or
2456
+ mutation and bound it to 4000ms by default.
2457
+
2458
+ Generation ids supplied through config, environment, API, or CLI must be
2459
+ identical and match `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`; invalid or conflicting
2460
+ identity fails before listening. Omit generation settings to preserve legacy
2461
+ worker ids, protocol, disconnect recovery, and custom-adapter compatibility.
2462
+ An ID-only configuration derives `candidate`; that default does not conflict
2463
+ with an explicit API/CLI `active` or `retired` recovery state, while multiple
2464
+ actual state sources must still agree.
2465
+
2433
2466
  `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. The default waits for jobs to finish and never interrupts a running job; a positive finite cap is a per-worker shutdown control for an explicitly requested process stop, not the normal deploy-completion mechanism. During release retirement, the old jobs-main and workers may drain for hours after deploy returns. See [docs/background-jobs.md](docs/background-jobs.md#worker-shutdown-and-process-job-draining).
2434
2467
 
2435
2468
  `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"`.
@@ -2637,7 +2670,7 @@ Each job must define exactly one of `every` or `cron`. Cron times are evaluated
2637
2670
 
2638
2671
  ## Persistence and retries
2639
2672
 
2640
- Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. If a worker socket disconnects unexpectedly, only the leases handed to that exact socket are immediately returned to the queue; late reports are fenced by lease id so they cannot mutate a newer attempt. This recovery is at-least-once and may repeat application side effects if the disconnected attempt had already started them. Gracefully draining workers keep their leases while they finish. Startup reconnection/adoption is an abnormal crash/legacy-recovery facility, not the normal deploy topology: during ordinary release retirement the old main remains alive and owns its old workers, and they must not reconnect to the new main. A production integration that restarts jobs-main every deploy and depends on worker adoption is not compliant with the release-generation contract. See [release-generation draining](docs/background-jobs.md#release-generation-draining) and [worker disconnect recovery](docs/background-jobs.md#worker-disconnect-recovery).
2673
+ Jobs are persisted in the configured database (`backgroundJobs.databaseIdentifier`) in an internal `background_jobs` table. When a worker picks a job, the main generates a unique lease id before asking the adapter to mark the job handed off, and the worker reports completion or failure back to the main process. If that persistence call has an ambiguous result, only the exact caller-generated lease is conditionally returned; failed recovery is retained for the dispatch error-retry path, so worker admission and concurrency do not remain stranded and a newer lease is never reclaimed. Custom adapters must persist a supplied `markHandedOff({handoffId})` exactly; built-in adapters continue generating one for legacy direct callers that omit it. A legacy worker disconnect returns only that socket's leases immediately. Generation mode instead preserves the exact leases through reconnect grace for the same qualified worker, then returns them to the global queue on expiry. Late reports are fenced by generation-qualified worker id, lease id, and handoff time so they cannot mutate a newer attempt. This recovery is at-least-once and may repeat application side effects if the disconnected attempt had already started them. A release-retiring worker revokes readiness but retains heartbeat, its unchanged old endpoint, exact-generation reconnect, accepted work, child execution, durable reports, and acknowledgements until its drain settles; retiring/retired mains reject new identities and never grant reconnecting workers readiness. Startup reconnection/adoption is an abnormal crash/legacy-recovery facility, not the normal deploy topology: during ordinary release retirement the old main remains alive and owns its old workers, and they must not reconnect to the new main. A production integration that restarts jobs-main on every deploy and depends on worker adoption is not compliant with the release-generation contract. See [release-generation draining](docs/background-jobs.md#release-generation-draining) and [worker disconnect recovery](docs/background-jobs.md#worker-disconnect-recovery).
2641
2674
 
2642
2675
  Failed jobs are re-queued with backoff and retried up to 10 times by default (10s, 1m, 10m, 1h, then +1h per retry). You can override the retry limit per job:
2643
2676
 
@@ -6,6 +6,14 @@
6
6
  * separate concerns.
7
7
  */
8
8
  export default class BackgroundJobsAdapter {
9
+ /**
10
+ * Declares exact durable fencing support for release-scoped generations.
11
+ * Third-party adapters must override this only after implementing the full
12
+ * snapshot, owner, report, and recovery contract.
13
+ * @returns {boolean} - Whether generation mode is supported.
14
+ */
15
+ supportsReleaseScopedGenerations() { return false }
16
+
9
17
  /**
10
18
  * Ensures the adapter can accept work.
11
19
  * @returns {Promise<void>} - Resolves when ready.
@@ -3,6 +3,7 @@
3
3
  import timeout from "awaitery/build/timeout.js"
4
4
  import configurationResolver from "../configuration-resolver.js"
5
5
  import BackgroundJobsSocketRequest from "./socket-request.js"
6
+ import { DEFAULT_GENERATION_HANDSHAKE_TIMEOUT_MS, validateGenerationHandshakeTimeoutMs } from "./generation-handshake-timeout-error.js"
6
7
 
7
8
  const DEFAULT_ENQUEUE_TIMEOUT_MS = 5000
8
9
 
@@ -12,10 +13,14 @@ export default class BackgroundJobsClient {
12
13
  * @param {object} [args] - Options.
13
14
  * @param {import("../configuration.js").default} [args.configuration] - Configuration.
14
15
  * @param {number} [args.enqueueTimeoutMs] - Maximum time to wait for an enqueue acknowledgement in milliseconds (default: 5000).
16
+ * @param {number} [args.generationHandshakeTimeoutMs] - Maximum time to wait for generation acknowledgement (default: 4000).
17
+ * @param {string} [args.generationId] - Explicit release generation identity.
15
18
  */
16
- constructor({configuration, enqueueTimeoutMs = DEFAULT_ENQUEUE_TIMEOUT_MS} = {}) {
19
+ constructor({configuration, enqueueTimeoutMs = DEFAULT_ENQUEUE_TIMEOUT_MS, generationHandshakeTimeoutMs = DEFAULT_GENERATION_HANDSHAKE_TIMEOUT_MS, generationId} = {}) {
17
20
  this.configurationPromise = configuration ? Promise.resolve(configuration) : configurationResolver()
18
21
  this.enqueueTimeoutMs = enqueueTimeoutMs
22
+ this.generationHandshakeTimeoutMs = validateGenerationHandshakeTimeoutMs(generationHandshakeTimeoutMs)
23
+ this.explicitGenerationId = generationId
19
24
  }
20
25
 
21
26
  /**
@@ -25,8 +30,12 @@ export default class BackgroundJobsClient {
25
30
  async _request() {
26
31
  const configuration = await this.configurationPromise
27
32
  const {host, port} = configuration.getBackgroundJobsConfig()
33
+ const {generationId} = configuration.resolveBackgroundJobsGenerationConfig({
34
+ generationId: this.explicitGenerationId,
35
+ sourceName: "BackgroundJobsClient"
36
+ })
28
37
 
29
- return new BackgroundJobsSocketRequest({host, port, role: "client"})
38
+ return new BackgroundJobsSocketRequest({host, port, role: "client", generationHandshakeTimeoutMs: this.generationHandshakeTimeoutMs, generationId})
30
39
  }
31
40
 
32
41
  /**
@@ -0,0 +1,32 @@
1
+ // @ts-check
2
+
3
+ export const DEFAULT_GENERATION_HANDSHAKE_TIMEOUT_MS = 4000
4
+
5
+ /** Actionable failure for an unacknowledged generation hello. */
6
+ export default class BackgroundJobsGenerationHandshakeTimeoutError extends Error {
7
+ /**
8
+ * Creates an actionable generation-handshake deadline failure.
9
+ * @param {object} args - Timeout context.
10
+ * @param {string} args.endpoint - Main endpoint.
11
+ * @param {string} args.generationId - Expected generation.
12
+ * @param {"worker" | "client" | "reporter"} args.role - Initiating peer role.
13
+ * @param {number} args.timeoutMs - Handshake deadline.
14
+ */
15
+ constructor({endpoint, generationId, role, timeoutMs}) {
16
+ super(`Background jobs ${role} generation handshake for ${generationId} timed out after ${timeoutMs}ms at ${endpoint}`)
17
+ this.name = "BackgroundJobsGenerationHandshakeTimeoutError"
18
+ }
19
+ }
20
+
21
+ /**
22
+ * Validates a generation handshake deadline.
23
+ * @param {number} timeoutMs - Candidate deadline.
24
+ * @returns {number} - Valid deadline.
25
+ */
26
+ export function validateGenerationHandshakeTimeoutMs(timeoutMs) {
27
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 2_147_483_647) {
28
+ throw new TypeError("generationHandshakeTimeoutMs must be an integer between 1 and 2147483647")
29
+ }
30
+
31
+ return timeoutMs
32
+ }
@@ -0,0 +1,160 @@
1
+ // @ts-check
2
+
3
+ import { utf8ByteLength } from "../utils/utf8-byte-length.js"
4
+
5
+ /**
6
+ * @typedef {object} GenerationValueSource
7
+ * @property {string} name - Human-readable source name.
8
+ * @property {boolean} present - Whether the source was explicitly supplied.
9
+ * @property {ReturnType<typeof JSON.parse> | undefined} value - Supplied value.
10
+ */
11
+
12
+ const GENERATION_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/
13
+ const WORKER_INSTANCE_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
14
+ const INITIAL_GENERATION_STATES = new Set(["candidate", "active", "retired"])
15
+
16
+ /**
17
+ * Validates one release generation identifier.
18
+ * @param {ReturnType<typeof JSON.parse> | undefined} value - Candidate value.
19
+ * @param {string} [sourceName] - Source label for failures.
20
+ * @returns {string} - Valid generation id.
21
+ */
22
+ export function validateGenerationId(value, sourceName = "background jobs generationId") {
23
+ if (typeof value !== "string" || !GENERATION_ID_PATTERN.test(value)) {
24
+ throw new TypeError(`${sourceName} must match ${GENERATION_ID_PATTERN}`)
25
+ }
26
+
27
+ return value
28
+ }
29
+
30
+ /**
31
+ * Resolves explicitly present generation identity sources without precedence.
32
+ * @param {GenerationValueSource[]} sources - Identity sources.
33
+ * @returns {string | undefined} - Identical resolved identity or legacy unset.
34
+ */
35
+ export function resolveGenerationId(sources) {
36
+ const presentSources = sources.filter((source) => source.present)
37
+
38
+ if (presentSources.length === 0) return undefined
39
+
40
+ const values = presentSources.map((source) => validateGenerationId(source.value, source.name))
41
+ const generationId = values[0]
42
+
43
+ if (values.some((value) => value !== generationId)) {
44
+ const names = presentSources.map((source) => source.name).join(", ")
45
+
46
+ throw new Error(`Conflicting background jobs generation identities from: ${names}`)
47
+ }
48
+
49
+ return generationId
50
+ }
51
+
52
+ /**
53
+ * Resolves the boot lifecycle state.
54
+ * @param {GenerationValueSource[]} sources - State sources.
55
+ * @param {string | undefined} generationId - Resolved generation identity.
56
+ * @returns {import("./types.js").BackgroundJobsGenerationInitialState | "active"} - Boot state.
57
+ */
58
+ export function resolveInitialGenerationState(sources, generationId) {
59
+ const presentSources = sources.filter((source) => source.present)
60
+
61
+ if (presentSources.length === 0) return generationId ? "candidate" : "active"
62
+ if (!generationId) throw new Error("backgroundJobs.initialGenerationState requires backgroundJobs.generationId")
63
+
64
+ const values = presentSources.map((source) => {
65
+ if (typeof source.value !== "string" || !INITIAL_GENERATION_STATES.has(source.value)) {
66
+ throw new TypeError(`${source.name} must be "candidate", "active", or "retired"`)
67
+ }
68
+
69
+ return /** @type {import("./types.js").BackgroundJobsGenerationInitialState} */ (source.value)
70
+ })
71
+ const state = values[0]
72
+
73
+ if (values.some((value) => value !== state)) {
74
+ const names = presentSources.map((source) => source.name).join(", ")
75
+
76
+ throw new Error(`Conflicting background jobs initialGenerationState values from: ${names}`)
77
+ }
78
+
79
+ return state
80
+ }
81
+
82
+ /**
83
+ * Resolves the optional release-local lifecycle socket path.
84
+ * @param {GenerationValueSource[]} sources - Path sources.
85
+ * @param {string | undefined} generationId - Resolved generation identity.
86
+ * @returns {string | undefined} - Absolute Unix socket path.
87
+ */
88
+ export function resolveLifecycleSocketPath(sources, generationId) {
89
+ const presentSources = sources.filter((source) => source.present)
90
+
91
+ if (presentSources.length === 0) return undefined
92
+ if (!generationId) throw new Error("backgroundJobs.lifecycleSocketPath requires backgroundJobs.generationId")
93
+
94
+ const values = presentSources.map((source) => {
95
+ if (typeof source.value !== "string" || !source.value.startsWith("/") || source.value.includes("\0")) {
96
+ throw new TypeError(`${source.name} must be an absolute Unix socket path`)
97
+ }
98
+
99
+ if (utf8ByteLength(source.value) > 103) {
100
+ throw new TypeError(`${source.name} must be at most 103 UTF-8 bytes for portable Unix socket support`)
101
+ }
102
+
103
+ return source.value
104
+ })
105
+ const socketPath = values[0]
106
+
107
+ if (values.some((value) => value !== socketPath)) {
108
+ const names = presentSources.map((source) => source.name).join(", ")
109
+
110
+ throw new Error(`Conflicting background jobs lifecycle socket paths from: ${names}`)
111
+ }
112
+
113
+ return socketPath
114
+ }
115
+
116
+ /**
117
+ * Creates the exact durable worker owner token.
118
+ * @param {object} args - Owner parts.
119
+ * @param {string} args.generationId - Release generation.
120
+ * @param {string} args.workerInstanceId - Worker process UUID.
121
+ * @returns {string} - Generation-qualified durable worker id.
122
+ */
123
+ export function createGenerationWorkerId({generationId, workerInstanceId}) {
124
+ validateGenerationId(generationId)
125
+ if (!WORKER_INSTANCE_ID_PATTERN.test(workerInstanceId)) throw new TypeError("workerInstanceId must be a UUID")
126
+
127
+ return `${generationId}:${workerInstanceId}`
128
+ }
129
+
130
+ /**
131
+ * Parses a generation-qualified durable worker id.
132
+ * @param {ReturnType<typeof JSON.parse>} workerId - Durable worker id.
133
+ * @returns {{generationId: string, workerInstanceId: string} | null} - Parsed owner or null.
134
+ */
135
+ export function parseGenerationWorkerId(workerId) {
136
+ if (typeof workerId !== "string") return null
137
+
138
+ const separatorIndex = workerId.indexOf(":")
139
+ if (separatorIndex < 1 || separatorIndex !== workerId.lastIndexOf(":")) return null
140
+
141
+ const generationId = workerId.slice(0, separatorIndex)
142
+ const workerInstanceId = workerId.slice(separatorIndex + 1)
143
+
144
+ if (!GENERATION_ID_PATTERN.test(generationId) || !WORKER_INSTANCE_ID_PATTERN.test(workerInstanceId)) return null
145
+
146
+ return {generationId, workerInstanceId}
147
+ }
148
+
149
+ /**
150
+ * Checks exact parsed generation ownership.
151
+ * @param {object} args - Ownership query.
152
+ * @param {string} args.generationId - Expected generation.
153
+ * @param {ReturnType<typeof JSON.parse>} args.workerId - Durable worker id.
154
+ * @returns {boolean} - Whether the parsed owner belongs to the generation.
155
+ */
156
+ export function workerIdBelongsToGeneration({generationId, workerId}) {
157
+ const parsed = parseGenerationWorkerId(workerId)
158
+
159
+ return parsed?.generationId === generationId
160
+ }
@@ -0,0 +1,127 @@
1
+ // @ts-check
2
+
3
+ import { randomUUID } from "node:crypto"
4
+ import net from "node:net"
5
+ import timeout from "awaitery/build/timeout.js"
6
+ import JsonSocket from "./json-socket.js"
7
+
8
+ const DEFAULT_REQUEST_TIMEOUT_MS = 10000
9
+ const MAX_REQUEST_TIMEOUT_MS = 25000
10
+
11
+ /** One-request acknowledged lifecycle client. */
12
+ export default class BackgroundJobsLifecycleClient {
13
+ /**
14
+ * Creates a lifecycle client.
15
+ * @param {object} args - Client options.
16
+ * @param {import("../configuration.js").default} args.configuration - Configuration.
17
+ * @param {string} [args.generationId] - Explicit generation identity.
18
+ * @param {string} [args.socketPath] - Explicit control socket path.
19
+ * @param {number} [args.requestTimeoutMs] - Request deadline below the supervisor hook timeout (default: 10000).
20
+ */
21
+ constructor({configuration, generationId, socketPath, requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS}) {
22
+ const generationConfig = configuration.resolveBackgroundJobsGenerationConfig({
23
+ generationId,
24
+ lifecycleSocketPath: socketPath,
25
+ sourceName: "BackgroundJobsLifecycleClient"
26
+ })
27
+ this.generationId = generationConfig.generationId
28
+ this.socketPath = generationConfig.lifecycleSocketPath
29
+ if (!Number.isInteger(requestTimeoutMs) || requestTimeoutMs < 1 || requestTimeoutMs > MAX_REQUEST_TIMEOUT_MS) {
30
+ throw new TypeError(`requestTimeoutMs must be an integer between 1 and ${MAX_REQUEST_TIMEOUT_MS}`)
31
+ }
32
+ this.requestTimeoutMs = requestTimeoutMs
33
+ if (!this.generationId) throw new Error("Background jobs lifecycle client requires generationId")
34
+ if (!this.socketPath) throw new Error("Background jobs lifecycle client requires lifecycleSocketPath")
35
+ }
36
+
37
+ /**
38
+ * Activates the generation.
39
+ * @returns {Promise<import("./types.js").BackgroundJobsGenerationLifecycleState>} - Resulting state.
40
+ */
41
+ async activate() { return await this._request("activate") }
42
+
43
+ /**
44
+ * Retires the generation.
45
+ * @returns {Promise<import("./types.js").BackgroundJobsGenerationLifecycleState>} - Resulting state.
46
+ */
47
+ async retire() { return await this._request("retire") }
48
+
49
+ /**
50
+ * Sends exactly one lifecycle request.
51
+ * @param {"activate" | "retire"} action - Lifecycle action.
52
+ * @returns {Promise<import("./types.js").BackgroundJobsGenerationLifecycleState>} - Resulting state.
53
+ */
54
+ async _request(action) {
55
+ return await timeout({
56
+ errorMessage: `Background jobs ${action} request for ${this.generationId} timed out after ${this.requestTimeoutMs}ms at ${this.socketPath}`,
57
+ timeout: this.requestTimeoutMs
58
+ }, async ({control}) => await this._runRequest({action, signal: control.signal}))
59
+ }
60
+
61
+ /**
62
+ * Sends the lifecycle request under its caller-owned deadline.
63
+ * @param {object} args - Request details.
64
+ * @param {"activate" | "retire"} args.action - Lifecycle action.
65
+ * @param {AbortSignal} args.signal - Request deadline signal.
66
+ * @returns {Promise<import("./types.js").BackgroundJobsGenerationLifecycleState>} - Resulting state.
67
+ */
68
+ async _runRequest({action, signal}) {
69
+ const requestId = randomUUID()
70
+ const socket = net.createConnection(this.socketPath)
71
+ const jsonSocket = new JsonSocket(socket)
72
+
73
+ return await new Promise((resolve, reject) => {
74
+ let finished = false
75
+ /**
76
+ * Settles the request once.
77
+ * @param {object} options - Teardown options.
78
+ * @param {boolean} [options.destroy] - Destroy instead of closing.
79
+ * @param {() => void} callback - Settlement callback.
80
+ */
81
+ const finish = ({destroy = false}, callback) => {
82
+ if (finished) return
83
+ finished = true
84
+ signal.removeEventListener("abort", onAbort)
85
+ socket.removeListener("connect", onConnect)
86
+ jsonSocket.removeAllListeners()
87
+ if (destroy) jsonSocket.destroy()
88
+ else jsonSocket.close()
89
+ callback()
90
+ }
91
+
92
+ const onAbort = () => finish({destroy: true}, () => reject(signal.reason instanceof Error ? signal.reason : new Error("Background jobs lifecycle request aborted")))
93
+ const onConnect = () => {
94
+ jsonSocket.send({
95
+ type: "background-jobs-lifecycle",
96
+ action,
97
+ generationId: this.generationId,
98
+ requestId
99
+ })
100
+ }
101
+
102
+ signal.addEventListener("abort", onAbort)
103
+ jsonSocket.on("error", (error) => finish({}, () => reject(error)))
104
+ jsonSocket.on("close", () => finish({destroy: true}, () => reject(new Error("Background jobs lifecycle socket closed before acknowledgement"))))
105
+ jsonSocket.on("message", (message) => {
106
+ if (message?.requestId !== requestId || message.action !== action) {
107
+ finish({}, () => reject(new Error("Background jobs lifecycle response did not match its request")))
108
+ return
109
+ }
110
+ if (message.type === "background-jobs-lifecycle-error") {
111
+ const error = new Error(message.error?.message || "Background jobs lifecycle request failed")
112
+ if (typeof message.error?.name === "string") error.name = message.error.name
113
+ if (typeof message.error?.stack === "string") error.stack = message.error.stack
114
+ finish({}, () => reject(error))
115
+ return
116
+ }
117
+ if (message.type !== "background-jobs-lifecycle-ack" || message.generationId !== this.generationId) {
118
+ finish({}, () => reject(new Error("Invalid background jobs lifecycle acknowledgement")))
119
+ return
120
+ }
121
+ finish({}, () => resolve(message.lifecycleState))
122
+ })
123
+ socket.once("connect", onConnect)
124
+ if (signal.aborted) onAbort()
125
+ })
126
+ }
127
+ }