@dbx-tools/core 0.6.65 → 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.
@@ -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
+ }