@dbx-tools/core 0.6.64 → 0.6.66
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 +81 -1
- package/index.ts +3 -0
- package/lib/index.d.ts +3 -0
- package/lib/index.js +3 -1
- package/lib/src/bin.d.ts +31 -0
- package/lib/src/bin.js +130 -0
- package/lib/src/process-lock.d.ts +88 -0
- package/lib/src/process-lock.js +355 -0
- package/lib/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -2
- package/src/bin.ts +186 -0
- package/src/process-lock.ts +414 -0
|
@@ -0,0 +1,414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyed mutual exclusion across the main thread and its worker threads.
|
|
3
|
+
*
|
|
4
|
+
* {@link withProcessLock} serializes callbacks that share a key: one runs at a
|
|
5
|
+
* time, the rest queue in arrival order, and different keys proceed
|
|
6
|
+
* concurrently. Unlike a plain in-module `Promise` chain, the queue is shared by
|
|
7
|
+
* every thread wired up through {@link processLockWorkerOptions}, so a worker
|
|
8
|
+
* pool cannot run two callbacks for the same key at once.
|
|
9
|
+
*
|
|
10
|
+
* The name says `process`: this coordinates the THREADS of one Node process. It
|
|
11
|
+
* is not cross-process and not cross-host - a second `node` invocation, or a
|
|
12
|
+
* second app replica, has its own coordinator and shares nothing. When the scope
|
|
13
|
+
* is a deployment rather than a process, use
|
|
14
|
+
* `@dbx-tools/postgres`'s `withAdvisoryLock`, which puts the arbiter in
|
|
15
|
+
* PostgreSQL where every replica can see it.
|
|
16
|
+
*
|
|
17
|
+
* The main thread owns the only coordinator. Each participating thread gets one
|
|
18
|
+
* `MessagePort` to it, and a lock is granted by a message back over that port.
|
|
19
|
+
* Consequently:
|
|
20
|
+
*
|
|
21
|
+
* - the lock is ADVISORY, like Postgres advisory locks - it protects a
|
|
22
|
+
* critical section only insofar as every writer takes the same key;
|
|
23
|
+
* - fairness is FIFO per key, since the coordinator queues waiters in the
|
|
24
|
+
* order their requests arrive;
|
|
25
|
+
* - a thread that dies while holding a lock releases it, because its port
|
|
26
|
+
* closing is what hands the key to the next waiter (see
|
|
27
|
+
* {@link LockCoordinator.removePort}).
|
|
28
|
+
*
|
|
29
|
+
* @module
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
isMainThread,
|
|
34
|
+
MessageChannel,
|
|
35
|
+
parentPort,
|
|
36
|
+
workerData,
|
|
37
|
+
type MessagePort,
|
|
38
|
+
type Worker,
|
|
39
|
+
type WorkerOptions,
|
|
40
|
+
} from "node:worker_threads";
|
|
41
|
+
import { error, hash, object } from "@dbx-tools/shared-core";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* `workerData` slot carrying the coordinator port into a worker, and the
|
|
45
|
+
* `parentPort` message `type` for the late {@link attachProcessLock} handshake.
|
|
46
|
+
* Namespaced because both travel through channels an application also uses:
|
|
47
|
+
* `workerData` is the caller's own object, and `parentPort` carries the caller's
|
|
48
|
+
* own messages.
|
|
49
|
+
*/
|
|
50
|
+
const LOCK_PORT_KEY = "__dbxToolsProcessLockPort";
|
|
51
|
+
const ATTACH_MESSAGE_TYPE = "__dbxToolsProcessLockAttach";
|
|
52
|
+
|
|
53
|
+
/** Sent by a client to the coordinator. */
|
|
54
|
+
type LockRequest =
|
|
55
|
+
| { type: "acquire"; requestId: string; key: string }
|
|
56
|
+
| { type: "release"; requestId: string; key: string };
|
|
57
|
+
|
|
58
|
+
/** Sent by the coordinator to the client that now owns the key. */
|
|
59
|
+
type LockResponse = { type: "granted"; requestId: string };
|
|
60
|
+
|
|
61
|
+
type LockMessage = LockRequest | LockResponse;
|
|
62
|
+
|
|
63
|
+
/** The `parentPort` envelope that hands a late-attached worker its port. */
|
|
64
|
+
interface AttachMessage {
|
|
65
|
+
type: typeof ATTACH_MESSAGE_TYPE;
|
|
66
|
+
port: MessagePort;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** One outstanding or granted acquisition. */
|
|
70
|
+
interface LockWaiter {
|
|
71
|
+
requestId: string;
|
|
72
|
+
port: MessagePort;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Shape of the `workerData` slice this module reads. */
|
|
76
|
+
interface LockWorkerData {
|
|
77
|
+
[LOCK_PORT_KEY]?: MessagePort;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A key's owner plus its FIFO queue of waiters.
|
|
82
|
+
*
|
|
83
|
+
* Owner and queue live in ONE map entry rather than two parallel maps so a key
|
|
84
|
+
* cannot end up half-present - a queue whose owner map entry was already deleted
|
|
85
|
+
* would strand its waiters forever, and the entry is dropped only when the key
|
|
86
|
+
* is both unowned and unwanted.
|
|
87
|
+
*/
|
|
88
|
+
interface LockState {
|
|
89
|
+
owner: LockWaiter;
|
|
90
|
+
queue: LockWaiter[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Arbiter for every key, living on the main thread.
|
|
95
|
+
*
|
|
96
|
+
* Holds no timers and no handles of its own: a port is unref'd by its client
|
|
97
|
+
* while idle (see {@link LockClient}), so an idle coordinator never keeps the
|
|
98
|
+
* process alive.
|
|
99
|
+
*/
|
|
100
|
+
class LockCoordinator {
|
|
101
|
+
private readonly states = new Map<string, LockState>();
|
|
102
|
+
|
|
103
|
+
/** Serve one client. Called once per participating thread. */
|
|
104
|
+
addPort(port: MessagePort): void {
|
|
105
|
+
port.on("message", (message: LockMessage) => {
|
|
106
|
+
switch (message.type) {
|
|
107
|
+
case "acquire":
|
|
108
|
+
this.acquire(message.key, message.requestId, port);
|
|
109
|
+
break;
|
|
110
|
+
case "release":
|
|
111
|
+
this.release(message.key, message.requestId);
|
|
112
|
+
break;
|
|
113
|
+
default:
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
// A closed port means its thread exited or was terminated. Releasing here is
|
|
118
|
+
// what keeps one crashed worker from wedging a key for the process lifetime.
|
|
119
|
+
port.on("close", () => this.removePort(port));
|
|
120
|
+
// The coordinator side stays unref'd for its whole life. It is a pure
|
|
121
|
+
// responder - it never has business of its own pending - so keeping the
|
|
122
|
+
// event loop alive for it would mean a process that used a lock once could
|
|
123
|
+
// never exit. A request always arrives from a client that has ref'd ITS
|
|
124
|
+
// port, so the loop is awake whenever there is actually something to serve.
|
|
125
|
+
port.unref();
|
|
126
|
+
port.start();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
private acquire(key: string, requestId: string, port: MessagePort): void {
|
|
130
|
+
const waiter: LockWaiter = { requestId, port };
|
|
131
|
+
const state = this.states.get(key);
|
|
132
|
+
if (!state) {
|
|
133
|
+
this.states.set(key, { owner: waiter, queue: [] });
|
|
134
|
+
grant(waiter);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
state.queue.push(waiter);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Hand the key on, ignoring a release from anyone but the current owner.
|
|
142
|
+
*
|
|
143
|
+
* A stale release is expected, not defensive coding: an aborted acquisition
|
|
144
|
+
* sends one for a request that never owned the key, and honouring it would
|
|
145
|
+
* revoke the lock from whoever holds it now.
|
|
146
|
+
*/
|
|
147
|
+
private release(key: string, requestId: string): void {
|
|
148
|
+
const state = this.states.get(key);
|
|
149
|
+
if (state?.owner.requestId !== requestId) return;
|
|
150
|
+
this.grantNext(key, state);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Promote the next waiter, or drop the key when nobody wants it. */
|
|
154
|
+
private grantNext(key: string, state: LockState): void {
|
|
155
|
+
const next = state.queue.shift();
|
|
156
|
+
if (!next) {
|
|
157
|
+
this.states.delete(key);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
state.owner = next;
|
|
161
|
+
grant(next);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Drop a dead thread from every key: release the ones it owned and remove it
|
|
166
|
+
* from the queues it was waiting in.
|
|
167
|
+
*
|
|
168
|
+
* Queue removal must happen FIRST. A thread can appear as both the owner of
|
|
169
|
+
* one key and a waiter for another, and promoting it out of a queue it can no
|
|
170
|
+
* longer answer for would grant a lock to a closed port - stalling that key
|
|
171
|
+
* until the process ends.
|
|
172
|
+
*/
|
|
173
|
+
private removePort(port: MessagePort): void {
|
|
174
|
+
for (const state of this.states.values()) {
|
|
175
|
+
if (state.queue.length > 0) {
|
|
176
|
+
state.queue = state.queue.filter((waiter) => waiter.port !== port);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
for (const [key, state] of [...this.states]) {
|
|
180
|
+
if (state.owner.port === port) this.grantNext(key, state);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Notify a waiter that it now owns its key. */
|
|
186
|
+
function grant(waiter: LockWaiter): void {
|
|
187
|
+
waiter.port.postMessage({ type: "granted", requestId: waiter.requestId } satisfies LockResponse);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A thread's end of the conversation: sends requests, awaits grants, and runs
|
|
192
|
+
* callbacks.
|
|
193
|
+
*
|
|
194
|
+
* Owns the event-loop bookkeeping. The port is unref'd while idle so holding a
|
|
195
|
+
* lock module never blocks process exit, and ref'd exactly while this thread has
|
|
196
|
+
* an outstanding request or an unreleased lock - otherwise Node would consider
|
|
197
|
+
* itself out of work and exit mid-critical-section, dropping the grant that was
|
|
198
|
+
* already on its way.
|
|
199
|
+
*/
|
|
200
|
+
class LockClient {
|
|
201
|
+
private readonly pending = new Map<string, (error?: Error) => void>();
|
|
202
|
+
/** Requests + held locks. The port is ref'd while this is above zero. */
|
|
203
|
+
private active = 0;
|
|
204
|
+
private closed = false;
|
|
205
|
+
|
|
206
|
+
constructor(private readonly port: MessagePort) {
|
|
207
|
+
port.on("message", (message: LockMessage) => {
|
|
208
|
+
if (message.type !== "granted") return;
|
|
209
|
+
this.settle(message.requestId);
|
|
210
|
+
});
|
|
211
|
+
port.on("close", () => {
|
|
212
|
+
this.closed = true;
|
|
213
|
+
// The coordinator is gone (main thread exiting, or this worker being torn
|
|
214
|
+
// down). Fail the waiters rather than hang them: a caller blocked on a
|
|
215
|
+
// lock that can never be granted is indistinguishable from a deadlock.
|
|
216
|
+
const closeError = new Error("Process lock port closed before the lock was granted");
|
|
217
|
+
for (const settle of [...this.pending.values()]) settle(closeError);
|
|
218
|
+
});
|
|
219
|
+
port.unref();
|
|
220
|
+
port.start();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Resolve or reject one pending request and drop its ref. */
|
|
224
|
+
private settle(requestId: string, failure?: Error): void {
|
|
225
|
+
const settle = this.pending.get(requestId);
|
|
226
|
+
if (!settle) return;
|
|
227
|
+
this.pending.delete(requestId);
|
|
228
|
+
settle(failure);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Ref the port for the first unit of outstanding work. */
|
|
232
|
+
private retain(): void {
|
|
233
|
+
if (this.active++ === 0) this.port.ref();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Unref once nothing is outstanding, so the thread can exit. */
|
|
237
|
+
private release(): void {
|
|
238
|
+
if (--this.active === 0) this.port.unref();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async run<T>(key: string, fn: () => T | Promise<T>): Promise<T> {
|
|
242
|
+
if (this.closed) {
|
|
243
|
+
throw new Error("Process lock port is closed");
|
|
244
|
+
}
|
|
245
|
+
const requestId = hash.id();
|
|
246
|
+
this.retain();
|
|
247
|
+
try {
|
|
248
|
+
await new Promise<void>((resolve, reject) => {
|
|
249
|
+
this.pending.set(requestId, (failure) => (failure ? reject(failure) : resolve()));
|
|
250
|
+
try {
|
|
251
|
+
this.port.postMessage({ type: "acquire", requestId, key } satisfies LockRequest);
|
|
252
|
+
} catch (cause) {
|
|
253
|
+
this.pending.delete(requestId);
|
|
254
|
+
reject(error.toError(cause));
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
} catch (cause) {
|
|
258
|
+
// Never granted, so there is nothing to release - just drop the ref.
|
|
259
|
+
this.release();
|
|
260
|
+
throw cause;
|
|
261
|
+
}
|
|
262
|
+
try {
|
|
263
|
+
return await fn();
|
|
264
|
+
} finally {
|
|
265
|
+
// Post before unref'ing: the release must be in flight while the port is
|
|
266
|
+
// still holding the loop open, or the next waiter is stranded.
|
|
267
|
+
if (!this.closed) {
|
|
268
|
+
this.port.postMessage({ type: "release", requestId, key } satisfies LockRequest);
|
|
269
|
+
}
|
|
270
|
+
this.release();
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** The coordinator, on the main thread only. */
|
|
276
|
+
const coordinator = isMainThread ? new LockCoordinator() : undefined;
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* This thread's client, created on first use.
|
|
280
|
+
*
|
|
281
|
+
* Lazy so importing this module costs nothing and, more importantly, so a worker
|
|
282
|
+
* that never locks anything is never forced to have been started through
|
|
283
|
+
* {@link processLockWorkerOptions}. The main thread wires a channel to its own
|
|
284
|
+
* coordinator; a worker adopts the port it was handed.
|
|
285
|
+
*/
|
|
286
|
+
let client: LockClient | undefined;
|
|
287
|
+
|
|
288
|
+
function lockClient(): LockClient {
|
|
289
|
+
if (client) return client;
|
|
290
|
+
if (coordinator) {
|
|
291
|
+
const { port1, port2 } = new MessageChannel();
|
|
292
|
+
coordinator.addPort(port1);
|
|
293
|
+
client = new LockClient(port2);
|
|
294
|
+
return client;
|
|
295
|
+
}
|
|
296
|
+
const port = (workerData as LockWorkerData | undefined)?.[LOCK_PORT_KEY];
|
|
297
|
+
if (!port) {
|
|
298
|
+
throw new Error(
|
|
299
|
+
"This worker has no process-lock port. Start it with processLockWorkerOptions() " +
|
|
300
|
+
"(or call attachProcessLock(worker) and await processLockAttached()).",
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
client = new LockClient(port);
|
|
304
|
+
return client;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Run `fn` while holding the lock named by `key`, releasing it when `fn`
|
|
309
|
+
* settles.
|
|
310
|
+
*
|
|
311
|
+
* Callers sharing a key are serialized across the main thread and every worker
|
|
312
|
+
* started through {@link processLockWorkerOptions}; distinct keys never block
|
|
313
|
+
* each other. Returns whatever `fn` returns and propagates what it throws, so it
|
|
314
|
+
* drops into an existing expression without restructuring.
|
|
315
|
+
*
|
|
316
|
+
* `key` is any value with a stable identity - a string, a `["invoice", id]`
|
|
317
|
+
* tuple, a config object - canonicalized by `object.toStableKey`, the same rule
|
|
318
|
+
* `@dbx-tools/postgres` uses for advisory-lock ids and channel names. Structure
|
|
319
|
+
* is part of the identity: `["invoice", 7]` and `"invoice_7"` are different
|
|
320
|
+
* locks.
|
|
321
|
+
*
|
|
322
|
+
* @example
|
|
323
|
+
* await withProcessLock(["cache", name], async () => {
|
|
324
|
+
* if (!(await exists(name))) await build(name);
|
|
325
|
+
* });
|
|
326
|
+
*/
|
|
327
|
+
export function withProcessLock<T>(key: unknown, fn: () => T | Promise<T>): Promise<T> {
|
|
328
|
+
return lockClient().run(lockKey(key), fn);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** Canonical string identity for a lock key (see `object.toStableKey`). */
|
|
332
|
+
function lockKey(key: unknown): string {
|
|
333
|
+
return object
|
|
334
|
+
.toOneOrMany(key)
|
|
335
|
+
.map((part) => object.toStableKey(part))
|
|
336
|
+
.join("\u0000");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Add the coordinator port to a `Worker`'s options so the worker can lock
|
|
341
|
+
* immediately - during module initialization, before any message is handled.
|
|
342
|
+
*
|
|
343
|
+
* Preserves the caller's `workerData` and `transferList`; the port is
|
|
344
|
+
* transferred, as `MessagePort` cannot be cloned.
|
|
345
|
+
*
|
|
346
|
+
* @example
|
|
347
|
+
* new Worker(url, processLockWorkerOptions({ workerData: { tenant } }));
|
|
348
|
+
*/
|
|
349
|
+
export function processLockWorkerOptions(options: WorkerOptions = {}): WorkerOptions {
|
|
350
|
+
const port = createCoordinatorPort("processLockWorkerOptions");
|
|
351
|
+
const existing = object.isRecord(options.workerData) ? options.workerData : {};
|
|
352
|
+
return {
|
|
353
|
+
...options,
|
|
354
|
+
workerData: { ...existing, [LOCK_PORT_KEY]: port },
|
|
355
|
+
transferList: [...(options.transferList ?? []), port],
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Wire an ALREADY-RUNNING worker into the lock, for a worker this code did not
|
|
361
|
+
* construct (a pool from a library, say).
|
|
362
|
+
*
|
|
363
|
+
* Prefer {@link processLockWorkerOptions}: the port arrives with a message, so
|
|
364
|
+
* the worker cannot lock during module initialization and must await
|
|
365
|
+
* {@link processLockAttached} first. The worker side needs no other change -
|
|
366
|
+
* `withProcessLock` works normally once the port lands.
|
|
367
|
+
*/
|
|
368
|
+
export function attachProcessLock(worker: Worker): void {
|
|
369
|
+
const port = createCoordinatorPort("attachProcessLock");
|
|
370
|
+
worker.postMessage({ type: ATTACH_MESSAGE_TYPE, port } satisfies AttachMessage, [port]);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** A fresh coordinator-side channel, or a clear error off the main thread. */
|
|
374
|
+
function createCoordinatorPort(caller: string): MessagePort {
|
|
375
|
+
if (!coordinator) {
|
|
376
|
+
throw new Error(`${caller}() must be called from the main thread`);
|
|
377
|
+
}
|
|
378
|
+
const { port1, port2 } = new MessageChannel();
|
|
379
|
+
coordinator.addPort(port1);
|
|
380
|
+
return port2;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* In a worker, resolve once the {@link attachProcessLock} port has arrived.
|
|
385
|
+
*
|
|
386
|
+
* Only needed on the attach path, and safe to await regardless: it returns
|
|
387
|
+
* immediately when the worker already has a port (the
|
|
388
|
+
* {@link processLockWorkerOptions} case) or when called on the main thread, so
|
|
389
|
+
* shared worker code does not branch on how it was started.
|
|
390
|
+
*
|
|
391
|
+
* A `parentPort` listener keeps the worker alive, which is CORRECT here and
|
|
392
|
+
* deliberately not unref'd: this promise is pending work, and a worker allowed to
|
|
393
|
+
* exit while awaiting its port would die silently instead of locking. The
|
|
394
|
+
* listener is removed as soon as the port lands, so the worker is free to exit
|
|
395
|
+
* again the moment the wait is over.
|
|
396
|
+
*/
|
|
397
|
+
export function processLockAttached(): Promise<void> {
|
|
398
|
+
if (isMainThread || client || (workerData as LockWorkerData | undefined)?.[LOCK_PORT_KEY]) {
|
|
399
|
+
return Promise.resolve();
|
|
400
|
+
}
|
|
401
|
+
const port = parentPort;
|
|
402
|
+
if (!port) {
|
|
403
|
+
return Promise.reject(new Error("processLockAttached() must be called from a worker thread"));
|
|
404
|
+
}
|
|
405
|
+
return new Promise<void>((resolve) => {
|
|
406
|
+
const onMessage = (message: unknown): void => {
|
|
407
|
+
if (!object.isRecord(message) || message.type !== ATTACH_MESSAGE_TYPE) return;
|
|
408
|
+
port.off("message", onMessage);
|
|
409
|
+
client = new LockClient((message as unknown as AttachMessage).port);
|
|
410
|
+
resolve();
|
|
411
|
+
};
|
|
412
|
+
port.on("message", onMessage);
|
|
413
|
+
});
|
|
414
|
+
}
|