velocious 1.0.618 → 1.0.620
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 +4 -2
- package/build/configuration.js +1 -1
- package/build/database/pool/async-tracked-multi-connection.js +25 -5
- package/build/database/pool/base.js +20 -10
- package/build/environment-handlers/base.js +9 -1
- package/build/environment-handlers/node.js +22 -4
- package/build/src/configuration.js +2 -2
- package/build/src/database/pool/async-tracked-multi-connection.d.ts +19 -1
- package/build/src/database/pool/async-tracked-multi-connection.d.ts.map +1 -1
- package/build/src/database/pool/async-tracked-multi-connection.js +26 -4
- package/build/src/database/pool/base.d.ts +12 -1
- package/build/src/database/pool/base.d.ts.map +1 -1
- package/build/src/database/pool/base.js +20 -11
- package/build/src/environment-handlers/base.d.ts +9 -1
- package/build/src/environment-handlers/base.d.ts.map +1 -1
- package/build/src/environment-handlers/base.js +9 -2
- package/build/src/environment-handlers/node.d.ts +10 -2
- package/build/src/environment-handlers/node.d.ts.map +1 -1
- package/build/src/environment-handlers/node.js +24 -5
- package/build/src/testing/shared-transaction-broker-client.d.ts +4 -2
- package/build/src/testing/shared-transaction-broker-client.d.ts.map +1 -1
- package/build/src/testing/shared-transaction-broker-client.js +5 -3
- package/build/src/testing/shared-transaction-broker.d.ts +21 -0
- package/build/src/testing/shared-transaction-broker.d.ts.map +1 -1
- package/build/src/testing/shared-transaction-broker.js +61 -18
- package/build/src/testing/shared-transaction-connection-coordinator.d.ts +2 -0
- package/build/src/testing/shared-transaction-connection-coordinator.d.ts.map +1 -1
- package/build/src/testing/shared-transaction-connection-coordinator.js +79 -12
- package/build/src/testing/shared-transaction-proxy-driver.d.ts +22 -3
- package/build/src/testing/shared-transaction-proxy-driver.d.ts.map +1 -1
- package/build/src/testing/shared-transaction-proxy-driver.js +51 -14
- package/build/src/testing/test-transaction-session.d.ts +129 -0
- package/build/src/testing/test-transaction-session.d.ts.map +1 -0
- package/build/src/testing/test-transaction-session.js +255 -0
- package/build/src/testing/test.d.ts +1 -1
- package/build/src/testing/test.d.ts.map +1 -1
- package/build/src/testing/test.js +2 -2
- package/build/testing/shared-transaction-broker-client.js +4 -2
- package/build/testing/shared-transaction-broker.js +58 -16
- package/build/testing/shared-transaction-connection-coordinator.js +83 -10
- package/build/testing/shared-transaction-proxy-driver.js +52 -13
- package/build/testing/test-transaction-session.js +240 -0
- package/build/testing/test.js +1 -1
- package/build/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -1
- package/src/configuration.js +1 -1
- package/src/database/pool/async-tracked-multi-connection.js +25 -5
- package/src/database/pool/base.js +20 -10
- package/src/environment-handlers/base.js +9 -1
- package/src/environment-handlers/node.js +22 -4
- package/src/testing/shared-transaction-broker-client.js +4 -2
- package/src/testing/shared-transaction-broker.js +58 -16
- package/src/testing/shared-transaction-connection-coordinator.js +83 -10
- package/src/testing/shared-transaction-proxy-driver.js +52 -13
- package/src/testing/test-transaction-session.js +240 -0
- package/src/testing/test.js +1 -1
- package/build/src/testing/wait-for-event.d.ts +0 -46
- package/build/src/testing/wait-for-event.d.ts.map +0 -1
- package/build/src/testing/wait-for-event.js +0 -64
- package/build/testing/wait-for-event.js +0 -72
- package/src/testing/wait-for-event.js +0 -72
|
@@ -80,15 +80,9 @@ export default class SharedTransactionBroker extends EventEmitter {
|
|
|
80
80
|
/** @type {Map<object, symbol>} */
|
|
81
81
|
this.connectionCoordinatorOwners = new Map()
|
|
82
82
|
for (const connection of new Set(Object.values(connections))) {
|
|
83
|
-
|
|
84
|
-
* Serializes parent operations with child broker traffic.
|
|
85
|
-
* @param {() => Promise<unknown>} callback - Parent operation.
|
|
86
|
-
* @returns {Promise<unknown>} - Operation result.
|
|
87
|
-
*/
|
|
88
|
-
const coordinator = async (callback) => await this.serialize(this.connectionState(connection), callback)
|
|
89
|
-
this.connectionCoordinators.set(connection, coordinator)
|
|
90
|
-
this.connectionCoordinatorOwners.set(connection, setSharedTransactionCoordinator(connection, coordinator))
|
|
83
|
+
this.installConnectionCoordinator(connection)
|
|
91
84
|
}
|
|
85
|
+
this.physicalConnections = new Map()
|
|
92
86
|
this.httpServer = createServer()
|
|
93
87
|
this.websocketServer = new WebSocketServer({server: this.httpServer, maxPayload: 16 * 1024 * 1024})
|
|
94
88
|
this.websocketServer.on("connection", (socket) => {
|
|
@@ -101,6 +95,37 @@ export default class SharedTransactionBroker extends EventEmitter {
|
|
|
101
95
|
})
|
|
102
96
|
}
|
|
103
97
|
|
|
98
|
+
/**
|
|
99
|
+
* Installs serialization ownership for a newly enrolled physical connection.
|
|
100
|
+
* @param {object} connection - Parent-owned physical connection.
|
|
101
|
+
* @returns {void}
|
|
102
|
+
*/
|
|
103
|
+
installConnectionCoordinator(connection) {
|
|
104
|
+
if (this.connectionCoordinators.has(connection)) return
|
|
105
|
+
/**
|
|
106
|
+
* Serializes parent operations with child broker traffic.
|
|
107
|
+
* @param {() => Promise<unknown>} callback - Parent operation.
|
|
108
|
+
* @returns {Promise<unknown>} - Operation result.
|
|
109
|
+
*/
|
|
110
|
+
const coordinator = async (callback) => await this.serialize(this.connectionState(connection), callback)
|
|
111
|
+
this.connectionCoordinators.set(connection, coordinator)
|
|
112
|
+
this.connectionCoordinatorOwners.set(connection, setSharedTransactionCoordinator(connection, coordinator))
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Enrolls one exact physical database identity in this capability's rollback set.
|
|
117
|
+
* @param {{connection: object, databaseIdentifier: string, reuseKey: string}} args - Physical connection identity.
|
|
118
|
+
* @returns {void}
|
|
119
|
+
*/
|
|
120
|
+
enrollConnection({connection, databaseIdentifier, reuseKey}) {
|
|
121
|
+
if (!this.accepting) throw new Error("Shared transaction broker capability has been revoked")
|
|
122
|
+
const identity = `${databaseIdentifier}\0${reuseKey}`
|
|
123
|
+
const existing = this.physicalConnections.get(identity)
|
|
124
|
+
if (existing && existing !== connection) throw new Error(`Shared transaction physical connection identity is already enrolled: ${databaseIdentifier}`)
|
|
125
|
+
this.installConnectionCoordinator(connection)
|
|
126
|
+
this.physicalConnections.set(identity, connection)
|
|
127
|
+
}
|
|
128
|
+
|
|
104
129
|
/**
|
|
105
130
|
* Starts a broker on an ephemeral loopback port.
|
|
106
131
|
* @param {{connections: Record<string, object>}} args - Parent-owned physical connections.
|
|
@@ -140,18 +165,22 @@ export default class SharedTransactionBroker extends EventEmitter {
|
|
|
140
165
|
async handleRequest(socket, serialized) {
|
|
141
166
|
let requestId = 0
|
|
142
167
|
try {
|
|
143
|
-
const request = /** @type {{requestId: number, capability: string, databaseIdentifier: string, method: string, args: import("./shared-transaction-codec.js").EncodedBrokerValue}} */ (JSON.parse(serialized))
|
|
168
|
+
const request = /** @type {{requestId: number, capability: string, databaseIdentifier: string, reuseKey?: string, method: string, args: import("./shared-transaction-codec.js").EncodedBrokerValue}} */ (JSON.parse(serialized))
|
|
144
169
|
requestId = request.requestId
|
|
145
170
|
if (!this.accepting) throw new Error("Shared transaction broker capability has been revoked")
|
|
146
171
|
if (!capabilityMatches(request.capability, this.secret)) throw new Error("Unknown shared transaction broker capability")
|
|
147
|
-
const connection =
|
|
148
|
-
|
|
172
|
+
const connection = request.reuseKey
|
|
173
|
+
? this.physicalConnections.get(`${request.databaseIdentifier}\0${request.reuseKey}`)
|
|
174
|
+
: this.connections[request.databaseIdentifier]
|
|
175
|
+
if (!connection) {
|
|
176
|
+
if (request.reuseKey) throw new Error(`Unenrolled physical connection identity: ${request.databaseIdentifier}`)
|
|
177
|
+
throw new Error(`Unknown shared transaction database identifier: ${request.databaseIdentifier}`)
|
|
178
|
+
}
|
|
149
179
|
if (!ALLOWED_METHODS.has(request.method)) throw new Error(`Unsupported shared transaction broker method: ${request.method}`)
|
|
150
180
|
const args = decodeBrokerValue(request.args)
|
|
151
181
|
if (!Array.isArray(args)) throw new TypeError("Shared transaction broker arguments must be an array")
|
|
152
182
|
this.emit("work-queued", {connection, databaseIdentifier: request.databaseIdentifier, method: request.method})
|
|
153
183
|
const result = await this.runConnectionRequest({connection, method: request.method, savePointName: typeof args[0] === "string" ? args[0] : undefined, socket}, async () => {
|
|
154
|
-
if (!this.accepting) throw new Error("Shared transaction broker capability has been revoked")
|
|
155
184
|
if (request.method === "rootTransactionRollback") {
|
|
156
185
|
await this.rollbackRootSavePoint(connection, /** @type {string} */ (args[0]))
|
|
157
186
|
return undefined
|
|
@@ -258,10 +287,11 @@ export default class SharedTransactionBroker extends EventEmitter {
|
|
|
258
287
|
|
|
259
288
|
state.queue = previous.then(async () => {
|
|
260
289
|
try {
|
|
261
|
-
if (
|
|
290
|
+
if (socket.readyState !== socket.OPEN) throw new Error("Shared transaction broker root transaction session closed before lease acquisition")
|
|
262
291
|
const result = await callback()
|
|
263
292
|
state.lease = {operations: Promise.resolve(), release, savePointName, socket}
|
|
264
293
|
resolveStarted(result)
|
|
294
|
+
if (!this.accepting) await this.scheduleSessionCleanup(socket)
|
|
265
295
|
await held
|
|
266
296
|
} catch (error) {
|
|
267
297
|
state.rootSessions.delete(socket)
|
|
@@ -418,17 +448,29 @@ export default class SharedTransactionBroker extends EventEmitter {
|
|
|
418
448
|
return await this.closePromise
|
|
419
449
|
}
|
|
420
450
|
|
|
451
|
+
/** Revokes admission without interrupting already accepted work. */
|
|
452
|
+
revoke() {
|
|
453
|
+
if (!this.accepting) return
|
|
454
|
+
this.accepting = false
|
|
455
|
+
this.secret = randomBytes(32).toString("base64url")
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** Drains all work accepted before capability revocation. */
|
|
459
|
+
async drain() {
|
|
460
|
+
if (this.accepting) throw new Error("Shared transaction broker must be revoked before drain")
|
|
461
|
+
await Promise.all(Array.from(this.connectionStates.values()).map((state) => state.queue))
|
|
462
|
+
}
|
|
463
|
+
|
|
421
464
|
/**
|
|
422
465
|
* Performs deterministic transport shutdown and reports cleanup failures last.
|
|
423
466
|
* @returns {Promise<void>} - Resolves after shutdown or rejects with cleanup errors.
|
|
424
467
|
*/
|
|
425
468
|
async closeTransport() {
|
|
426
|
-
this.
|
|
427
|
-
this.secret = randomBytes(32).toString("base64url")
|
|
469
|
+
this.revoke()
|
|
428
470
|
const closingSessions = Array.from(this.sessions)
|
|
429
471
|
await Promise.all(closingSessions.map(async (socket) => await this.scheduleSessionCleanup(socket)))
|
|
472
|
+
await this.drain()
|
|
430
473
|
for (const socket of closingSessions) socket.close(1001, "Shared transaction broker closed")
|
|
431
|
-
await Promise.allSettled(Array.from(this.connectionStates.values()).map((state) => state.queue))
|
|
432
474
|
await new Promise((resolve) => this.websocketServer.close(() => resolve(undefined)))
|
|
433
475
|
await new Promise((resolve) => this.httpServer.close(() => resolve(undefined)))
|
|
434
476
|
await Promise.all(Array.from(this.sessionCleanup.values()))
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
// @ts-check
|
|
2
2
|
|
|
3
|
-
/** @typedef {{coordinator: (callback: () => Promise<unknown>) => Promise<unknown>, ownedQueue: Promise<void>, owner: symbol}} CoordinatorRegistration */
|
|
3
|
+
/** @typedef {{coordinator: (callback: () => Promise<unknown>) => Promise<unknown>, ownedQueue: Promise<void>, owner: symbol, reentrantOwners: Set<symbol>, rootOwners: Set<symbol>}} CoordinatorRegistration */
|
|
4
4
|
|
|
5
5
|
/** @type {WeakMap<object, CoordinatorRegistration>} */
|
|
6
6
|
const coordinators = new WeakMap()
|
|
7
|
+
/** @type {WeakMap<object, CoordinatorRegistration>} */
|
|
8
|
+
const connectionRegistrations = new WeakMap()
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Runs work directly when only the connection-local queue remains registered.
|
|
12
|
+
* @param {() => Promise<unknown>} callback - Serialized operation.
|
|
13
|
+
* @returns {Promise<unknown>} - Operation result.
|
|
14
|
+
*/
|
|
15
|
+
async function inactiveCoordinator(callback) {
|
|
16
|
+
return await callback()
|
|
17
|
+
}
|
|
7
18
|
|
|
8
19
|
/**
|
|
9
20
|
* Serializes sibling work that inherited one coordinator owner without re-entering the broker queue.
|
|
10
21
|
* @template T
|
|
22
|
+
* @param {import("../database/drivers/base.js").default} connection - Parent physical connection.
|
|
11
23
|
* @param {CoordinatorRegistration} registration - Physical connection registration.
|
|
12
24
|
* @param {() => Promise<T>} callback - Owned operation.
|
|
13
25
|
* @returns {Promise<T>} - Operation result.
|
|
14
26
|
*/
|
|
15
|
-
async function coordinateOwnedSharedTransactionConnection(registration, callback) {
|
|
27
|
+
async function coordinateOwnedSharedTransactionConnection(connection, registration, callback) {
|
|
16
28
|
const previous = registration.ownedQueue
|
|
17
29
|
/**
|
|
18
30
|
* Releases the next owned sibling operation.
|
|
@@ -22,13 +34,54 @@ async function coordinateOwnedSharedTransactionConnection(registration, callback
|
|
|
22
34
|
|
|
23
35
|
registration.ownedQueue = new Promise((resolve) => { release = resolve })
|
|
24
36
|
await previous
|
|
37
|
+
const operationOwner = Symbol("shared-transaction-owned-operation")
|
|
38
|
+
const environmentHandler = connection.configuration.getEnvironmentHandler()
|
|
39
|
+
|
|
40
|
+
registration.reentrantOwners.add(operationOwner)
|
|
25
41
|
try {
|
|
26
|
-
return await callback
|
|
42
|
+
return await environmentHandler.runWithSharedTransactionCoordinatorOwner(connection, operationOwner, callback)
|
|
27
43
|
} finally {
|
|
44
|
+
registration.reentrantOwners.delete(operationOwner)
|
|
28
45
|
release()
|
|
29
46
|
}
|
|
30
47
|
}
|
|
31
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Drains all inherited operations admitted before the root owner is revoked.
|
|
51
|
+
* @param {CoordinatorRegistration} registration - Physical connection registration.
|
|
52
|
+
* @returns {Promise<void>} - Resolves when the owned queue stops advancing.
|
|
53
|
+
*/
|
|
54
|
+
async function drainOwnedSharedTransactionConnections(registration) {
|
|
55
|
+
let tail
|
|
56
|
+
|
|
57
|
+
do {
|
|
58
|
+
tail = registration.ownedQueue
|
|
59
|
+
await tail
|
|
60
|
+
} while (tail !== registration.ownedQueue)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Runs one broker-serialized root while tracking and draining its inherited work.
|
|
65
|
+
* @template T
|
|
66
|
+
* @param {import("../database/drivers/base.js").default} connection - Parent physical connection.
|
|
67
|
+
* @param {CoordinatorRegistration} registration - Physical connection registration.
|
|
68
|
+
* @param {() => Promise<T>} callback - Root operation.
|
|
69
|
+
* @returns {Promise<T>} - Operation result.
|
|
70
|
+
*/
|
|
71
|
+
async function coordinateRootSharedTransactionConnection(connection, registration, callback) {
|
|
72
|
+
await drainOwnedSharedTransactionConnections(registration)
|
|
73
|
+
const rootOwner = Symbol("shared-transaction-root-operation")
|
|
74
|
+
const environmentHandler = connection.configuration.getEnvironmentHandler()
|
|
75
|
+
|
|
76
|
+
registration.rootOwners.add(rootOwner)
|
|
77
|
+
try {
|
|
78
|
+
return await environmentHandler.runWithSharedTransactionCoordinatorOwner(connection, rootOwner, callback)
|
|
79
|
+
} finally {
|
|
80
|
+
await drainOwnedSharedTransactionConnections(registration)
|
|
81
|
+
registration.rootOwners.delete(rootOwner)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
32
85
|
/**
|
|
33
86
|
* Registers test-only serialization owned by the active broker.
|
|
34
87
|
* @param {object} connection - Parent physical connection.
|
|
@@ -37,8 +90,17 @@ async function coordinateOwnedSharedTransactionConnection(registration, callback
|
|
|
37
90
|
*/
|
|
38
91
|
export function setSharedTransactionCoordinator(connection, coordinator) {
|
|
39
92
|
const owner = Symbol("shared-transaction-coordinator")
|
|
93
|
+
let registration = connectionRegistrations.get(connection)
|
|
94
|
+
|
|
95
|
+
if (registration) {
|
|
96
|
+
registration.coordinator = coordinator
|
|
97
|
+
registration.owner = owner
|
|
98
|
+
} else {
|
|
99
|
+
registration = {coordinator, ownedQueue: Promise.resolve(), owner, reentrantOwners: new Set(), rootOwners: new Set()}
|
|
100
|
+
connectionRegistrations.set(connection, registration)
|
|
101
|
+
}
|
|
40
102
|
|
|
41
|
-
coordinators.set(connection,
|
|
103
|
+
coordinators.set(connection, registration)
|
|
42
104
|
return owner
|
|
43
105
|
}
|
|
44
106
|
|
|
@@ -49,7 +111,12 @@ export function setSharedTransactionCoordinator(connection, coordinator) {
|
|
|
49
111
|
* @returns {void}
|
|
50
112
|
*/
|
|
51
113
|
export function clearSharedTransactionCoordinator(connection, coordinator) {
|
|
52
|
-
|
|
114
|
+
const registration = coordinators.get(connection)
|
|
115
|
+
|
|
116
|
+
if (registration?.coordinator === coordinator) {
|
|
117
|
+
coordinators.delete(connection)
|
|
118
|
+
registration.coordinator = inactiveCoordinator
|
|
119
|
+
}
|
|
53
120
|
}
|
|
54
121
|
|
|
55
122
|
/**
|
|
@@ -61,18 +128,24 @@ export function clearSharedTransactionCoordinator(connection, coordinator) {
|
|
|
61
128
|
* @returns {Promise<T>} - Operation result.
|
|
62
129
|
*/
|
|
63
130
|
export async function coordinateSharedTransactionConnection(connection, callback, operationOwner) {
|
|
64
|
-
const
|
|
131
|
+
const activeRegistration = coordinators.get(connection)
|
|
132
|
+
const registration = activeRegistration || connectionRegistrations.get(connection)
|
|
65
133
|
|
|
66
134
|
if (!registration) return await callback()
|
|
67
|
-
if (operationOwner === registration.owner) return await callback()
|
|
68
135
|
|
|
69
136
|
const environmentHandler = connection.configuration.getEnvironmentHandler()
|
|
137
|
+
const currentOwner = environmentHandler.getSharedTransactionCoordinatorOwner(connection)
|
|
70
138
|
|
|
71
|
-
if (
|
|
72
|
-
|
|
139
|
+
if (currentOwner && registration.reentrantOwners.has(currentOwner)) return await callback()
|
|
140
|
+
if (currentOwner && registration.rootOwners.has(currentOwner)) {
|
|
141
|
+
return await coordinateOwnedSharedTransactionConnection(connection, registration, callback)
|
|
142
|
+
}
|
|
143
|
+
if (!activeRegistration) return await coordinateOwnedSharedTransactionConnection(connection, registration, callback)
|
|
144
|
+
if (operationOwner === registration.owner) {
|
|
145
|
+
return await coordinateRootSharedTransactionConnection(connection, registration, callback)
|
|
73
146
|
}
|
|
74
147
|
|
|
75
148
|
return /** @type {T} */ (await registration.coordinator(async () => {
|
|
76
|
-
return await
|
|
149
|
+
return await coordinateRootSharedTransactionConnection(connection, registration, callback)
|
|
77
150
|
}))
|
|
78
151
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
/** @typedef {{address?: string, capability?: string, databaseIdentifiers?: string[], expected: boolean}} SharedTransactionBrokerJobConfig */
|
|
1
|
+
/** @typedef {{address?: string, allowDynamicIdentities?: boolean, capability?: string, databaseIdentifiers?: string[], expected: boolean}} SharedTransactionBrokerJobConfig */
|
|
2
2
|
|
|
3
3
|
// @ts-check
|
|
4
4
|
|
|
@@ -11,6 +11,19 @@ export const BACKGROUND_JOB_CHILD_ENV = "VELOCIOUS_BACKGROUND_JOB_CHILD"
|
|
|
11
11
|
/** @type {AsyncLocalStorage<SharedTransactionBrokerJobConfig>} */
|
|
12
12
|
const pooledJobBrokerConfig = new AsyncLocalStorage()
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Returns the active live broker configuration without validating one database route.
|
|
16
|
+
* @returns {SharedTransactionBrokerJobConfig | undefined} - Active live or child configuration.
|
|
17
|
+
*/
|
|
18
|
+
function activeSharedTransactionBrokerConfig() {
|
|
19
|
+
const contextualConfig = pooledJobBrokerConfig.getStore()
|
|
20
|
+
if (contextualConfig) return contextualConfig
|
|
21
|
+
if (process.env[BACKGROUND_JOB_CHILD_ENV] !== "1") return undefined
|
|
22
|
+
const serialized = process.env[SHARED_TRANSACTION_BROKER_ENV]
|
|
23
|
+
if (!serialized) return undefined
|
|
24
|
+
return JSON.parse(Buffer.from(serialized, "base64url").toString("utf8"))
|
|
25
|
+
}
|
|
26
|
+
|
|
14
27
|
/**
|
|
15
28
|
* Runs one pooled job with dispatch-time broker configuration.
|
|
16
29
|
* @template T
|
|
@@ -22,6 +35,34 @@ export function runWithSharedTransactionBrokerConfig(config, callback) {
|
|
|
22
35
|
return pooledJobBrokerConfig.run(config, callback)
|
|
23
36
|
}
|
|
24
37
|
|
|
38
|
+
/**
|
|
39
|
+
* Checks whether the current live join selects one exact session capability.
|
|
40
|
+
* @param {{address: string, capability: string}} identity - Session control-message identity.
|
|
41
|
+
* @returns {boolean} - Whether this async context belongs to that session.
|
|
42
|
+
*/
|
|
43
|
+
export function sharedTransactionBrokerContextMatches(identity) {
|
|
44
|
+
const config = pooledJobBrokerConfig.getStore()
|
|
45
|
+
return config?.expected === true && config.address === identity.address && config.capability === identity.capability
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Preserves legacy real tenant connections omitted by automatic TestRunner mode.
|
|
50
|
+
* Explicit dynamic sessions never permit this fallback.
|
|
51
|
+
* @param {string} databaseIdentifier - Logical database identifier.
|
|
52
|
+
* @returns {boolean} - Whether an omitted automatic route stays independent.
|
|
53
|
+
*/
|
|
54
|
+
export function automaticSharedTransactionBrokerOmits(databaseIdentifier) {
|
|
55
|
+
const config = activeSharedTransactionBrokerConfig()
|
|
56
|
+
return Boolean(
|
|
57
|
+
config?.expected === true &&
|
|
58
|
+
!config.allowDynamicIdentities &&
|
|
59
|
+
typeof config.address === "string" &&
|
|
60
|
+
typeof config.capability === "string" &&
|
|
61
|
+
Array.isArray(config.databaseIdentifiers) &&
|
|
62
|
+
!config.databaseIdentifiers.includes(databaseIdentifier)
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
25
66
|
/**
|
|
26
67
|
* Escapes a PostgreSQL literal without requiring a live child connection.
|
|
27
68
|
* @param {ReturnType<typeof JSON.parse>} value - PostgreSQL literal value.
|
|
@@ -49,24 +90,19 @@ function pgEscapeLiteral(value) {
|
|
|
49
90
|
* Parses the test-runner-owned child transport configuration when this logical
|
|
50
91
|
* database is registered for the active attempt.
|
|
51
92
|
* @param {string} databaseIdentifier - Logical database identifier.
|
|
52
|
-
* @returns {{address: string, capability: string} | undefined} - Broker coordinates.
|
|
93
|
+
* @returns {{address: string, allowDynamicIdentities?: boolean, capability: string} | undefined} - Broker coordinates.
|
|
53
94
|
*/
|
|
54
95
|
export function sharedTransactionBrokerConfig(databaseIdentifier) {
|
|
55
|
-
const
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
const serialized = process.env[SHARED_TRANSACTION_BROKER_ENV]
|
|
60
|
-
if (!serialized) return undefined
|
|
61
|
-
|
|
62
|
-
return validatedBrokerConfig(JSON.parse(Buffer.from(serialized, "base64url").toString("utf8")), databaseIdentifier)
|
|
96
|
+
const config = activeSharedTransactionBrokerConfig()
|
|
97
|
+
if (!config) return undefined
|
|
98
|
+
return validatedBrokerConfig(config, databaseIdentifier)
|
|
63
99
|
}
|
|
64
100
|
|
|
65
101
|
/**
|
|
66
102
|
* Validates dispatch-time broker configuration and fails closed when expected.
|
|
67
103
|
* @param {SharedTransactionBrokerJobConfig} config - Candidate configuration.
|
|
68
104
|
* @param {string} databaseIdentifier - Logical database identifier.
|
|
69
|
-
* @returns {{address: string, capability: string} | undefined} - Broker coordinates.
|
|
105
|
+
* @returns {{address: string, allowDynamicIdentities?: boolean, capability: string} | undefined} - Broker coordinates.
|
|
70
106
|
*/
|
|
71
107
|
function validatedBrokerConfig(config, databaseIdentifier) {
|
|
72
108
|
if (config.expected && (!config.address || !config.capability || !config.databaseIdentifiers)) {
|
|
@@ -76,10 +112,13 @@ function validatedBrokerConfig(config, databaseIdentifier) {
|
|
|
76
112
|
if (typeof config.address !== "string" || typeof config.capability !== "string" || !Array.isArray(config.databaseIdentifiers)) {
|
|
77
113
|
throw new Error("Invalid shared transaction broker child configuration")
|
|
78
114
|
}
|
|
79
|
-
if (!config.databaseIdentifiers.includes(databaseIdentifier)) {
|
|
115
|
+
if (!config.allowDynamicIdentities && !config.databaseIdentifiers.includes(databaseIdentifier)) {
|
|
80
116
|
throw new Error(`Transactional pooled job expected broker database identifier: ${databaseIdentifier}`)
|
|
81
117
|
}
|
|
82
118
|
|
|
119
|
+
if (config.allowDynamicIdentities) {
|
|
120
|
+
return {address: config.address, allowDynamicIdentities: true, capability: config.capability}
|
|
121
|
+
}
|
|
83
122
|
return {address: config.address, capability: config.capability}
|
|
84
123
|
}
|
|
85
124
|
|
|
@@ -90,7 +129,7 @@ function validatedBrokerConfig(config, databaseIdentifier) {
|
|
|
90
129
|
* @param {import("../configuration-types.js").DatabaseConfigurationType} config - Database configuration.
|
|
91
130
|
* @param {import("../configuration.js").default} configuration - Child configuration.
|
|
92
131
|
* @param {string} databaseIdentifier - Logical identifier.
|
|
93
|
-
* @param {{address: string, capability: string}} brokerConfig - Broker coordinates.
|
|
132
|
+
* @param {{address: string, capability: string, reuseKey?: string}} brokerConfig - Broker coordinates.
|
|
94
133
|
* @returns {import("../database/drivers/base.js").default} - Unconnected physical proxy.
|
|
95
134
|
*/
|
|
96
135
|
export function createSharedTransactionProxyDriver(DriverClass, config, configuration, databaseIdentifier, brokerConfig) {
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
|
|
3
|
+
import SharedTransactionBroker from "./shared-transaction-broker.js"
|
|
4
|
+
import { runWithSharedTransactionBrokerConfig, sharedTransactionBrokerContextMatches } from "./shared-transaction-proxy-driver.js"
|
|
5
|
+
|
|
6
|
+
/** @typedef {{connection: import("../database/drivers/base.js").default, databaseIdentifier: string, release: () => Promise<void>, reuseKey: string}} Enrollment */
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Backend-owned, capability-scoped transaction set for long-lived test services.
|
|
10
|
+
* Join coordinates are intentionally obtainable only as a live control message.
|
|
11
|
+
*/
|
|
12
|
+
export default class TestTransactionSession {
|
|
13
|
+
/**
|
|
14
|
+
* Creates an unstarted transaction session.
|
|
15
|
+
* @param {import("../configuration.js").default} [configuration] - Backend configuration owning enrolled pools.
|
|
16
|
+
*/
|
|
17
|
+
constructor(configuration) {
|
|
18
|
+
this.configuration = configuration
|
|
19
|
+
/** @type {SharedTransactionBroker | undefined} */
|
|
20
|
+
this.broker = undefined
|
|
21
|
+
/** @type {Map<string, Enrollment>} */
|
|
22
|
+
this.enrollments = new Map()
|
|
23
|
+
/** @type {Map<string, {pool: import("../database/pool/base.js").default, registration: import("../database/pool/base.js").TestSharedConnectionRegistration}>} */
|
|
24
|
+
this.sharedConnectionRegistrations = new Map()
|
|
25
|
+
/** @type {Promise<void> | undefined} */
|
|
26
|
+
this.cleanupPromise = undefined
|
|
27
|
+
/** @type {Promise<void> | undefined} */
|
|
28
|
+
this.rollbackPromise = undefined
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Begins a test transaction session.
|
|
33
|
+
* @param {{configuration?: import("../configuration.js").default}} [args] - Backend owner.
|
|
34
|
+
* @returns {Promise<TestTransactionSession>} - Begun session.
|
|
35
|
+
*/
|
|
36
|
+
static async begin({configuration} = {}) {
|
|
37
|
+
const session = new TestTransactionSession(configuration)
|
|
38
|
+
session.broker = await SharedTransactionBroker.start({connections: {}})
|
|
39
|
+
return session
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Joins one request/job callback from a live backend control message.
|
|
44
|
+
* @template T
|
|
45
|
+
* @param {{address: string, capability: string}} message - Ephemeral coordinates received over live IPC.
|
|
46
|
+
* @param {() => T} callback - Backend request or worker work.
|
|
47
|
+
* @returns {T} - Callback result.
|
|
48
|
+
*/
|
|
49
|
+
static join(message, callback) {
|
|
50
|
+
return runWithSharedTransactionBrokerConfig({...message, allowDynamicIdentities: true, databaseIdentifiers: [], expected: true}, callback)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Lazily adds an exact physical connection to the common rollback set.
|
|
55
|
+
* @param {Enrollment} enrollment - Checked-out physical connection and owner release hook.
|
|
56
|
+
*/
|
|
57
|
+
async enroll(enrollment) {
|
|
58
|
+
const broker = this.requiredBroker()
|
|
59
|
+
const identity = `${enrollment.databaseIdentifier}\0${enrollment.reuseKey}`
|
|
60
|
+
const existing = this.enrollments.get(identity)
|
|
61
|
+
if (existing) {
|
|
62
|
+
if (existing.connection !== enrollment.connection) await enrollment.release()
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
try {
|
|
66
|
+
await enrollment.connection.startTransaction()
|
|
67
|
+
} catch (error) {
|
|
68
|
+
/** @type {Error | undefined} */
|
|
69
|
+
let releaseFailure
|
|
70
|
+
try {
|
|
71
|
+
await enrollment.release()
|
|
72
|
+
} catch (releaseError) {
|
|
73
|
+
releaseFailure = this.normalizeError(releaseError)
|
|
74
|
+
}
|
|
75
|
+
if (releaseFailure) {
|
|
76
|
+
throw new AggregateError(
|
|
77
|
+
[this.normalizeError(error), releaseFailure],
|
|
78
|
+
"Test transaction enrollment start and release failed",
|
|
79
|
+
{cause: error}
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
broker.enrollConnection(enrollment)
|
|
86
|
+
this.enrollments.set(identity, enrollment)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
await this.rollbackAndRelease(enrollment)
|
|
89
|
+
throw error
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Lazily checks out and enrolls the physical database selected by a tenant descriptor.
|
|
95
|
+
* @param {{databaseIdentifier: string, tenant?: object}} args - Logical and tenant identity.
|
|
96
|
+
*/
|
|
97
|
+
async enrollDatabase({databaseIdentifier, tenant}) {
|
|
98
|
+
if (!this.configuration) throw new Error("Test transaction session requires a configuration to enroll a database")
|
|
99
|
+
const pool = this.configuration.getDatabasePool(databaseIdentifier)
|
|
100
|
+
const databaseConfiguration = this.configuration.resolveDatabaseConfiguration(databaseIdentifier, tenant)
|
|
101
|
+
const reuseKey = pool.getConfigurationReuseKey(databaseConfiguration)
|
|
102
|
+
const identity = `${databaseIdentifier}\0${reuseKey}`
|
|
103
|
+
if (this.enrollments.has(identity)) return
|
|
104
|
+
const connection = await this.configuration.runWithTenant(tenant, async () => {
|
|
105
|
+
return await pool.checkout({name: "Test transaction session"})
|
|
106
|
+
})
|
|
107
|
+
await this.enroll({
|
|
108
|
+
connection,
|
|
109
|
+
databaseIdentifier,
|
|
110
|
+
release: async () => { await pool.checkin(connection) },
|
|
111
|
+
reuseKey
|
|
112
|
+
})
|
|
113
|
+
this.installSharedConnectionProvider(databaseIdentifier, pool)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Makes in-process request/Scoundrel checkouts resolve by current physical identity.
|
|
118
|
+
* @param {string} databaseIdentifier - Logical database identifier.
|
|
119
|
+
* @param {import("../database/pool/base.js").default} pool - Owning pool.
|
|
120
|
+
*/
|
|
121
|
+
installSharedConnectionProvider(databaseIdentifier, pool) {
|
|
122
|
+
if (this.sharedConnectionRegistrations.has(databaseIdentifier)) return
|
|
123
|
+
const broker = this.requiredBroker()
|
|
124
|
+
const sessionIdentity = {address: broker.address(), capability: broker.capability()}
|
|
125
|
+
const registration = pool.registerTestSharedConnectionProvider({
|
|
126
|
+
matches: () => sharedTransactionBrokerContextMatches(sessionIdentity),
|
|
127
|
+
provider: () => {
|
|
128
|
+
if (!broker.accepting) throw new Error("Test transaction session capability has been revoked")
|
|
129
|
+
const reuseKey = pool.getConfigurationReuseKey()
|
|
130
|
+
const connection = this.enrollments.get(`${databaseIdentifier}\0${reuseKey}`)?.connection
|
|
131
|
+
if (!connection) throw new Error(`Test transaction physical identity is not enrolled: ${databaseIdentifier}`)
|
|
132
|
+
return connection
|
|
133
|
+
}
|
|
134
|
+
})
|
|
135
|
+
if (registration) this.sharedConnectionRegistrations.set(databaseIdentifier, {pool, registration})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Returns ephemeral coordinates for one live IPC/control message.
|
|
140
|
+
* @returns {{address: string, capability: string}} - Non-durable join coordinates.
|
|
141
|
+
*/
|
|
142
|
+
joinMessage() {
|
|
143
|
+
const broker = this.requiredBroker()
|
|
144
|
+
if (!broker.accepting) throw new Error("Test transaction session capability has been revoked")
|
|
145
|
+
return {address: broker.address(), capability: broker.capability()}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Stops admission to the capability. */
|
|
149
|
+
revoke() { this.requiredBroker().revoke() }
|
|
150
|
+
|
|
151
|
+
/** Drains work accepted before revocation. */
|
|
152
|
+
async drain() { await this.requiredBroker().drain() }
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Rolls back and releases the complete enrolled set after admission stops.
|
|
156
|
+
* @returns {Promise<void>} - Resolves after rollback and release.
|
|
157
|
+
*/
|
|
158
|
+
async rollback() {
|
|
159
|
+
if (this.rollbackPromise) return await this.rollbackPromise
|
|
160
|
+
this.rollbackPromise = this.rollbackActual()
|
|
161
|
+
return await this.rollbackPromise
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Performs rollback and release once.
|
|
166
|
+
* @returns {Promise<void>} - Resolves after actual rollback and release.
|
|
167
|
+
*/
|
|
168
|
+
async rollbackActual() {
|
|
169
|
+
const broker = this.requiredBroker()
|
|
170
|
+
if (broker.accepting) throw new Error("Test transaction session must be revoked before rollback")
|
|
171
|
+
/** @type {Array<Error>} */
|
|
172
|
+
const errors = []
|
|
173
|
+
try { await broker.close() } catch (error) { errors.push(this.normalizeError(error)) }
|
|
174
|
+
for (const {pool, registration} of this.sharedConnectionRegistrations.values()) {
|
|
175
|
+
pool.clearTestSharedConnection(registration)
|
|
176
|
+
}
|
|
177
|
+
this.sharedConnectionRegistrations.clear()
|
|
178
|
+
for (const enrollment of this.enrollments.values()) {
|
|
179
|
+
try { await this.rollbackAndRelease(enrollment) } catch (error) { errors.push(this.normalizeError(error)) }
|
|
180
|
+
}
|
|
181
|
+
this.enrollments.clear()
|
|
182
|
+
if (errors.length > 0) throw new AggregateError(errors, "Test transaction session rollback failed")
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Revokes, drains, rolls back, and releases every enrolled physical connection exactly once.
|
|
187
|
+
* @returns {Promise<void>} - Resolves after idempotent cleanup.
|
|
188
|
+
*/
|
|
189
|
+
async cleanup() {
|
|
190
|
+
if (this.cleanupPromise) return await this.cleanupPromise
|
|
191
|
+
this.cleanupPromise = this.cleanupActual()
|
|
192
|
+
return await this.cleanupPromise
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Performs idempotent cleanup once.
|
|
197
|
+
* @returns {Promise<void>} - Resolves after actual cleanup.
|
|
198
|
+
*/
|
|
199
|
+
async cleanupActual() {
|
|
200
|
+
this.revoke()
|
|
201
|
+
await this.rollback()
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Returns capability-free session diagnostics.
|
|
206
|
+
* @returns {{accepting: boolean, enrollmentCount: number}} - Capability-free diagnostics.
|
|
207
|
+
*/
|
|
208
|
+
debugSnapshot() {
|
|
209
|
+
return {accepting: this.broker?.accepting === true, enrollmentCount: this.enrollments.size}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Returns the begun broker.
|
|
214
|
+
* @returns {SharedTransactionBroker} - Begun broker.
|
|
215
|
+
*/
|
|
216
|
+
requiredBroker() {
|
|
217
|
+
if (!this.broker) throw new Error("Test transaction session has not begun")
|
|
218
|
+
return this.broker
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Rolls back and releases one owned physical connection.
|
|
223
|
+
* @param {Enrollment} enrollment - Owned physical connection.
|
|
224
|
+
* @returns {Promise<void>} - Resolves after rollback and release.
|
|
225
|
+
*/
|
|
226
|
+
async rollbackAndRelease(enrollment) {
|
|
227
|
+
/** @type {Array<Error>} */
|
|
228
|
+
const errors = []
|
|
229
|
+
try { await enrollment.connection.rollbackTransaction() } catch (error) { errors.push(this.normalizeError(error)) }
|
|
230
|
+
try { await enrollment.release() } catch (error) { errors.push(this.normalizeError(error)) }
|
|
231
|
+
if (errors.length > 0) throw new AggregateError(errors, "Test transaction enrollment cleanup failed")
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Normalizes a thrown cleanup value.
|
|
236
|
+
* @param {unknown} error - Opaque thrown cleanup value narrowed at this boundary.
|
|
237
|
+
* @returns {Error} - Error instance.
|
|
238
|
+
*/
|
|
239
|
+
normalizeError(error) { return error instanceof Error ? error : new Error(String(error)) }
|
|
240
|
+
}
|
package/src/testing/test.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import path from "path"
|
|
4
4
|
import {fileURLToPath} from "url"
|
|
5
|
+
import { waitForEvent } from "@velocious/testing"
|
|
5
6
|
import EventEmitter from "../utils/event-emitter.js"
|
|
6
7
|
import Expect from "./expect.js"
|
|
7
|
-
import waitForEvent from "./wait-for-event.js"
|
|
8
8
|
import {arrayContaining, objectContaining} from "./expect-utils.js"
|
|
9
9
|
|
|
10
10
|
/**
|