velocious 1.0.597 → 1.0.598
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/build/background-jobs/main.js +42 -32
- package/build/src/background-jobs/main.d.ts +15 -10
- package/build/src/background-jobs/main.d.ts.map +1 -1
- package/build/src/background-jobs/main.js +42 -34
- package/build/src/testing/factory/node/definition-reload-policy.d.ts +97 -0
- package/build/src/testing/factory/node/definition-reload-policy.d.ts.map +1 -0
- package/build/src/testing/factory/node/definition-reload-policy.js +133 -0
- package/build/src/testing/factory/node/load-definitions.d.ts +4 -1
- package/build/src/testing/factory/node/load-definitions.d.ts.map +1 -1
- package/build/src/testing/factory/node/load-definitions.js +27 -4
- package/build/src/testing/test-runner.js +7 -7
- package/build/testing/factory/node/definition-reload-policy.js +149 -0
- package/build/testing/factory/node/load-definitions.js +29 -5
- package/build/testing/test-runner.js +6 -6
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/src/background-jobs/main.js +42 -32
- package/src/testing/factory/node/definition-reload-policy.js +149 -0
- package/src/testing/factory/node/load-definitions.js +29 -5
- package/src/testing/test-runner.js +6 -6
|
@@ -127,6 +127,8 @@ export default class BackgroundJobsMain {
|
|
|
127
127
|
this.scheduler = undefined
|
|
128
128
|
this._draining = false
|
|
129
129
|
this._redrainQueued = false
|
|
130
|
+
/** @type {Promise<void> | undefined} */
|
|
131
|
+
this._drainPromise = undefined
|
|
130
132
|
this._stopped = false
|
|
131
133
|
/** @type {Promise<void> | undefined} */
|
|
132
134
|
this.stopPromise = undefined
|
|
@@ -645,9 +647,11 @@ export default class BackgroundJobsMain {
|
|
|
645
647
|
/**
|
|
646
648
|
* Removes a lost worker socket and releases only leases dispatched through it.
|
|
647
649
|
* @param {JsonSocket} worker - Disconnected worker socket.
|
|
650
|
+
* @param {object} [args] - Coordination options.
|
|
651
|
+
* @param {boolean} [args.queueRedrain] - Queue another pass instead of awaiting the active drain.
|
|
648
652
|
* @returns {Promise<void>} - Resolves after its active leases are released.
|
|
649
653
|
*/
|
|
650
|
-
async _handleWorkerSocketClosed(worker) {
|
|
654
|
+
async _handleWorkerSocketClosed(worker, {queueRedrain = false} = {}) {
|
|
651
655
|
this.workers.delete(worker)
|
|
652
656
|
this.readyWorkers.delete(worker)
|
|
653
657
|
|
|
@@ -657,7 +661,7 @@ export default class BackgroundJobsMain {
|
|
|
657
661
|
}
|
|
658
662
|
|
|
659
663
|
try {
|
|
660
|
-
await this._releaseWorkerHandoffs(worker)
|
|
664
|
+
await this._releaseWorkerHandoffs(worker, {queueRedrain})
|
|
661
665
|
} catch (error) {
|
|
662
666
|
this._reportHandoffReleaseError(error)
|
|
663
667
|
this._scheduleErrorRetry()
|
|
@@ -667,9 +671,11 @@ export default class BackgroundJobsMain {
|
|
|
667
671
|
/**
|
|
668
672
|
* Releases all leases still owned by one exact worker socket.
|
|
669
673
|
* @param {JsonSocket} worker - Worker socket.
|
|
674
|
+
* @param {object} [args] - Coordination options.
|
|
675
|
+
* @param {boolean} [args.queueRedrain] - Queue another pass instead of awaiting the active drain.
|
|
670
676
|
* @returns {Promise<void>} - Resolves after fenced releases and dispatch wake-up.
|
|
671
677
|
*/
|
|
672
|
-
async _releaseWorkerHandoffs(worker) {
|
|
678
|
+
async _releaseWorkerHandoffs(worker, {queueRedrain = false} = {}) {
|
|
673
679
|
const handoffs = this.workerHandoffs.get(worker)
|
|
674
680
|
|
|
675
681
|
if (!handoffs || handoffs.size === 0) {
|
|
@@ -683,7 +689,11 @@ export default class BackgroundJobsMain {
|
|
|
683
689
|
|
|
684
690
|
this.workerHandoffs.delete(worker)
|
|
685
691
|
this._notifyEnqueued()
|
|
686
|
-
|
|
692
|
+
if (queueRedrain) {
|
|
693
|
+
this._redrainQueued = true
|
|
694
|
+
} else {
|
|
695
|
+
await this._drain()
|
|
696
|
+
}
|
|
687
697
|
}
|
|
688
698
|
|
|
689
699
|
/**
|
|
@@ -1100,23 +1110,38 @@ export default class BackgroundJobsMain {
|
|
|
1100
1110
|
* @returns {Promise<void>}
|
|
1101
1111
|
*/
|
|
1102
1112
|
async _drain() {
|
|
1103
|
-
if (
|
|
1113
|
+
if (this._stopped) return
|
|
1104
1114
|
|
|
1105
|
-
|
|
1115
|
+
if (this._drainPromise) {
|
|
1116
|
+
this._redrainQueued = true
|
|
1117
|
+
await this._drainPromise
|
|
1118
|
+
return
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
const drainPromise = this._drainToCompletion()
|
|
1106
1122
|
|
|
1107
|
-
|
|
1123
|
+
this._drainPromise = drainPromise
|
|
1124
|
+
await drainPromise
|
|
1108
1125
|
}
|
|
1109
1126
|
|
|
1110
1127
|
/**
|
|
1111
|
-
* Runs
|
|
1112
|
-
* @returns {
|
|
1128
|
+
* Runs one serialized drain lifecycle, including timer re-arming.
|
|
1129
|
+
* @returns {Promise<void>} - Resolves after every coalesced request is handled.
|
|
1113
1130
|
*/
|
|
1114
|
-
|
|
1115
|
-
if (this._stopped) return false
|
|
1116
|
-
if (this._queueDrainIfAlreadyRunning()) return false
|
|
1117
|
-
|
|
1131
|
+
async _drainToCompletion() {
|
|
1118
1132
|
this._draining = true
|
|
1119
|
-
|
|
1133
|
+
|
|
1134
|
+
try {
|
|
1135
|
+
let errored
|
|
1136
|
+
|
|
1137
|
+
do {
|
|
1138
|
+
errored = await this._drainUntilIdle()
|
|
1139
|
+
await this._finishDrain({errored})
|
|
1140
|
+
} while (!errored && this._redrainQueued && !this._stopped)
|
|
1141
|
+
} finally {
|
|
1142
|
+
this._draining = false
|
|
1143
|
+
this._drainPromise = undefined
|
|
1144
|
+
}
|
|
1120
1145
|
}
|
|
1121
1146
|
|
|
1122
1147
|
/**
|
|
@@ -1162,27 +1187,12 @@ export default class BackgroundJobsMain {
|
|
|
1162
1187
|
}
|
|
1163
1188
|
}
|
|
1164
1189
|
|
|
1165
|
-
/**
|
|
1166
|
-
* Runs queue drain if already running.
|
|
1167
|
-
* @returns {boolean} - Whether another drain is already in progress.
|
|
1168
|
-
*/
|
|
1169
|
-
_queueDrainIfAlreadyRunning() {
|
|
1170
|
-
if (!this._draining) return false
|
|
1171
|
-
|
|
1172
|
-
this._redrainQueued = true
|
|
1173
|
-
return true
|
|
1174
|
-
}
|
|
1175
|
-
|
|
1176
1190
|
/**
|
|
1177
1191
|
* Runs drain until idle.
|
|
1178
1192
|
* @returns {Promise<boolean>} - Whether the drain hit an error.
|
|
1179
1193
|
*/
|
|
1180
1194
|
async _drainUntilIdle() {
|
|
1181
|
-
|
|
1182
|
-
return await this._runDrainLoop()
|
|
1183
|
-
} finally {
|
|
1184
|
-
this._draining = false
|
|
1185
|
-
}
|
|
1195
|
+
return await this._runDrainLoop()
|
|
1186
1196
|
}
|
|
1187
1197
|
|
|
1188
1198
|
/**
|
|
@@ -1279,7 +1289,7 @@ export default class BackgroundJobsMain {
|
|
|
1279
1289
|
if (!handoffs || !this.workers.has(worker)) {
|
|
1280
1290
|
await this.store.markReturnedToQueue({handoffId: handoff.handoffId, jobId: job.id})
|
|
1281
1291
|
this._notifyEnqueued()
|
|
1282
|
-
|
|
1292
|
+
this._redrainQueued = true
|
|
1283
1293
|
continue
|
|
1284
1294
|
}
|
|
1285
1295
|
|
|
@@ -1307,7 +1317,7 @@ export default class BackgroundJobsMain {
|
|
|
1307
1317
|
} catch (closeError) {
|
|
1308
1318
|
this.logger.warn(() => ["Failed to close worker after job send failure:", closeError])
|
|
1309
1319
|
}
|
|
1310
|
-
await this._handleWorkerSocketClosed(worker)
|
|
1320
|
+
await this._handleWorkerSocketClosed(worker, {queueRedrain: true})
|
|
1311
1321
|
}
|
|
1312
1322
|
}
|
|
1313
1323
|
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Default maximum number of cache-busted factory definition import attempts a
|
|
5
|
+
* single Node process may perform. Chosen conservatively from the
|
|
6
|
+
* `factory-esm-reload-retention` benchmark evidence: each cache-busted import
|
|
7
|
+
* retains roughly 6 KB of heap in Node's ESM module map, so the default bounds
|
|
8
|
+
* retained definition modules to a few tens of MB before the owning process
|
|
9
|
+
* must be recycled. This module is intentionally Node-only; browser-safe factory
|
|
10
|
+
* code must never import it.
|
|
11
|
+
*/
|
|
12
|
+
export const DEFAULT_DEFINITION_RELOAD_BUDGET = 4096
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Rejected when code tries to configure the process-global reload budget more
|
|
16
|
+
* than once or after any valid cache-busted import reservation was attempted.
|
|
17
|
+
* Retained ESM modules and their accounting live for the process lifetime, so
|
|
18
|
+
* changing the budget can never begin a new in-process policy epoch.
|
|
19
|
+
*/
|
|
20
|
+
export class DefinitionReloadConfigurationError extends Error {
|
|
21
|
+
/**
|
|
22
|
+
* Creates the error.
|
|
23
|
+
* @param {object} args - Details.
|
|
24
|
+
* @param {number} args.current - Cache-busted imports already reserved.
|
|
25
|
+
* @param {number} args.budget - Active process-global import budget.
|
|
26
|
+
* @param {number} args.requestedBudget - Rejected replacement budget.
|
|
27
|
+
* @param {boolean} args.configured - Whether an explicit budget was already configured.
|
|
28
|
+
*/
|
|
29
|
+
constructor({budget, configured, current, requestedBudget}) {
|
|
30
|
+
const reason = configured
|
|
31
|
+
? "the process-global definition reload budget was already configured"
|
|
32
|
+
: "a definition reload reservation was already attempted"
|
|
33
|
+
|
|
34
|
+
super(`Cannot configure definition reload budget to ${requestedBudget}: ${reason} (current=${current}, budget=${budget}). Configuration is allowed exactly once before the first reservation; only process exit resets retained-module accounting.`)
|
|
35
|
+
this.name = "DefinitionReloadConfigurationError"
|
|
36
|
+
this.budget = budget
|
|
37
|
+
this.configured = configured
|
|
38
|
+
this.current = current
|
|
39
|
+
this.requestedBudget = requestedBudget
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Rejected when a reload would push the process over its cache-busted import
|
|
45
|
+
* budget. The rejection happens synchronously before any registry reset or
|
|
46
|
+
* import, so the currently loaded registry stays usable. Node never evicts
|
|
47
|
+
* retained ESM module instances, so only recycling/restarting the owning Node
|
|
48
|
+
* process reclaims the memory and refreshes edited dependency modules.
|
|
49
|
+
*/
|
|
50
|
+
export class DefinitionRecycleRequiredError extends Error {
|
|
51
|
+
/**
|
|
52
|
+
* Creates the error.
|
|
53
|
+
* @param {object} args - Details.
|
|
54
|
+
* @param {number} args.current - Cache-busted import attempts already reserved in this process.
|
|
55
|
+
* @param {number} args.budget - Process-global import budget.
|
|
56
|
+
* @param {number} args.requested - Import attempts the rejected reload needed.
|
|
57
|
+
*/
|
|
58
|
+
constructor({current, budget, requested}) {
|
|
59
|
+
super(`Factory definition reload import budget exhausted (current=${current}, budget=${budget}, requested=${requested}). Recycle or restart the owning Node process: every reload imports a fresh cache-busted module instance and Node never evicts them, so process recycling is the only reclamation boundary.`)
|
|
60
|
+
this.name = "DefinitionRecycleRequiredError"
|
|
61
|
+
this.current = current
|
|
62
|
+
this.budget = budget
|
|
63
|
+
this.requested = requested
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** @type {number} - The single process-global import budget. */
|
|
68
|
+
let importBudget = DEFAULT_DEFINITION_RELOAD_BUDGET
|
|
69
|
+
|
|
70
|
+
/** @type {number} - Cache-busted import attempts reserved so far across every registry and target. */
|
|
71
|
+
let reservedImports = 0
|
|
72
|
+
|
|
73
|
+
/** @type {boolean} - Whether the process-global budget was explicitly configured. */
|
|
74
|
+
let budgetConfigured = false
|
|
75
|
+
|
|
76
|
+
/** @type {boolean} - Whether any valid complete reload batch reservation was attempted. */
|
|
77
|
+
let reservationStarted = false
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Returns the process-global cache-busted import budget.
|
|
81
|
+
* @returns {number} - The budget.
|
|
82
|
+
*/
|
|
83
|
+
export function getDefinitionReloadBudget() {
|
|
84
|
+
return importBudget
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Reads the cache-busted import attempts reserved so far in this process,
|
|
89
|
+
* across every registry and target. Combined with {@link getDefinitionReloadBudget}
|
|
90
|
+
* this is the deterministic process-global census for the recycle policy.
|
|
91
|
+
* @returns {number} - Reserved count.
|
|
92
|
+
*/
|
|
93
|
+
export function peekDefinitionReloadBudget() {
|
|
94
|
+
return reservedImports
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Configures the one process-global import budget exactly once and only before
|
|
99
|
+
* the first valid reservation attempt. There is exactly one budget for the whole
|
|
100
|
+
* process, so no combination of registries or targets can create independent
|
|
101
|
+
* budgets that defeat the global limit. Retained-import accounting is never
|
|
102
|
+
* reset in-process.
|
|
103
|
+
* @param {number} budget - New budget.
|
|
104
|
+
* @returns {void}
|
|
105
|
+
*/
|
|
106
|
+
export function setDefinitionReloadBudget(budget) {
|
|
107
|
+
if (!Number.isInteger(budget) || budget < 1) {
|
|
108
|
+
throw new TypeError(`Definition reload budget must be a positive integer, got ${JSON.stringify(budget)}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (budgetConfigured || reservationStarted) {
|
|
112
|
+
throw new DefinitionReloadConfigurationError({
|
|
113
|
+
budget: importBudget,
|
|
114
|
+
configured: budgetConfigured,
|
|
115
|
+
current: reservedImports,
|
|
116
|
+
requestedBudget: budget
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
importBudget = budget
|
|
121
|
+
budgetConfigured = true
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Preflights and reserves a whole reload batch synchronously. Malformed counts
|
|
126
|
+
* are rejected before configuration is sealed or accounting changes. Every valid
|
|
127
|
+
* request, including zero and a rejected over-budget request, seals configuration
|
|
128
|
+
* before capacity is evaluated. Throws {@link DefinitionRecycleRequiredError}
|
|
129
|
+
* when the requested batch would push the process over its budget. The check and
|
|
130
|
+
* reservation run in one synchronous step, so concurrent reloads cannot race past
|
|
131
|
+
* the budget. The reservation is deliberately conservative: it covers every
|
|
132
|
+
* import attempt in the batch, so a mid-batch import failure still counts its
|
|
133
|
+
* attempts as retained modules.
|
|
134
|
+
* @param {number} requested - Cache-busted import attempts the reload will perform.
|
|
135
|
+
* @returns {void}
|
|
136
|
+
*/
|
|
137
|
+
export function reserveDefinitionReloadBudget(requested) {
|
|
138
|
+
if (!Number.isInteger(requested) || requested < 0) {
|
|
139
|
+
throw new TypeError(`Definition reload reservation must be a non-negative integer, got ${typeof requested} ${String(requested)}`)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
reservationStarted = true
|
|
143
|
+
|
|
144
|
+
if (reservedImports + requested > importBudget) {
|
|
145
|
+
throw new DefinitionRecycleRequiredError({current: reservedImports, budget: importBudget, requested})
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
reservedImports += requested
|
|
149
|
+
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
import {pathToFileURL} from "node:url"
|
|
4
|
-
import {readdir, stat} from "node:fs/promises"
|
|
3
|
+
import { pathToFileURL } from "node:url"
|
|
4
|
+
import { readdir, stat } from "node:fs/promises"
|
|
5
5
|
import path from "node:path"
|
|
6
|
+
import { reserveDefinitionReloadBudget } from "./definition-reload-policy.js"
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Monotonic cache-busting counter shared by reloads. Kept module-local so a reload
|
|
@@ -68,6 +69,26 @@ async function resolveFiles(target) {
|
|
|
68
69
|
export async function loadDefinitions(registry, target, {reload = false} = {}) {
|
|
69
70
|
const files = await resolveFiles(target)
|
|
70
71
|
|
|
72
|
+
return await loadResolvedDefinitionFiles({files, registry, reload})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Loads definition files that have already been resolved into a deterministic
|
|
77
|
+
* sorted list. When `reload` is set, the whole batch is preflighted and reserved
|
|
78
|
+
* against the process-global import budget before any registry reset or import
|
|
79
|
+
* attempt, so a rejected reload never mutates the registry.
|
|
80
|
+
* @param {object} args - Options object.
|
|
81
|
+
* @param {string[]} args.files - Resolved, sorted definition file paths.
|
|
82
|
+
* @param {import("../factory-registry.js").default} args.registry - Registry to define into.
|
|
83
|
+
* @param {boolean} args.reload - Whether to cache-bust the imports.
|
|
84
|
+
* @param {boolean} [args.reset] - Whether to reset the registry first.
|
|
85
|
+
* @returns {Promise<string[]>} - The loaded file paths, in load order.
|
|
86
|
+
*/
|
|
87
|
+
async function loadResolvedDefinitionFiles({files, registry, reload, reset = false}) {
|
|
88
|
+
if (reload) reserveDefinitionReloadBudget(files.length)
|
|
89
|
+
|
|
90
|
+
if (reset) registry.reset()
|
|
91
|
+
|
|
71
92
|
for (const file of files) {
|
|
72
93
|
let href = pathToFileURL(file).href
|
|
73
94
|
|
|
@@ -91,13 +112,16 @@ export async function loadDefinitions(registry, target, {reload = false} = {}) {
|
|
|
91
112
|
/**
|
|
92
113
|
* Fully reloads definitions: resets the registry (dropping every factory, trait,
|
|
93
114
|
* sequence, callback and default) and re-imports the target files with cache
|
|
94
|
-
* busting so edited definitions take effect.
|
|
115
|
+
* busting so edited definitions take effect. The resolved batch is preflighted
|
|
116
|
+
* against the process-global import budget before the reset, and a
|
|
117
|
+
* `DefinitionRecycleRequiredError` is raised before any mutation when the batch
|
|
118
|
+
* would exceed the budget.
|
|
95
119
|
* @param {import("../factory-registry.js").default} registry - Registry to reload.
|
|
96
120
|
* @param {string | string[]} target - File path, directory, or list of paths.
|
|
97
121
|
* @returns {Promise<string[]>} - The reloaded file paths, in load order.
|
|
98
122
|
*/
|
|
99
123
|
export async function reloadDefinitions(registry, target) {
|
|
100
|
-
|
|
124
|
+
const files = await resolveFiles(target)
|
|
101
125
|
|
|
102
|
-
return await
|
|
126
|
+
return await loadResolvedDefinitionFiles({files, registry, reload: true, reset: true})
|
|
103
127
|
}
|
|
@@ -1058,12 +1058,12 @@ export default class TestRunner {
|
|
|
1058
1058
|
// back). Releasing the lease after each lifecycle also runs the pool's
|
|
1059
1059
|
// session cleanup before another test can reuse the connection.
|
|
1060
1060
|
await this.getConfiguration().ensureConnections({name: `Test: ${testDescription}`}, async () => {
|
|
1061
|
-
// Register dynamic candidates before
|
|
1062
|
-
//
|
|
1063
|
-
//
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1061
|
+
// Register dynamic candidates before hooks so transaction state changes
|
|
1062
|
+
// made during a hook are immediately visible to any in-process work.
|
|
1063
|
+
// Long-lived services such as a background-jobs main can dispatch DB
|
|
1064
|
+
// work between a transaction-starting hook and broker activation just
|
|
1065
|
+
// as an HTTP request can dispatch work from inside the hook itself.
|
|
1066
|
+
testSharedConnectionRegistrations = this.activateTestSharedConnections()
|
|
1067
1067
|
|
|
1068
1068
|
try {
|
|
1069
1069
|
try {
|