@lsdsoftware/utils 2.1.0 → 2.1.1

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 CHANGED
@@ -30,22 +30,24 @@ Observable wrapper for net.connect (see connect-socket.test.ts for usage)
30
30
 
31
31
 
32
32
  ### Worker Rotator
33
- Rotate worker instances over a request stream and emit worker lifecycle events.
33
+ Rotate worker instances over a request stream.
34
34
 
35
35
  ```typescript
36
36
  import { makeWorkerRotator } from "@lsdsoftware/utils"
37
37
 
38
- const events$ = makeWorkerRotator({
38
+ const subscription = makeWorkerRotator({
39
39
  makeWorker,
40
40
  workerTtlMs: 60_000,
41
41
  request$,
42
- maxPendingRequests: 100
43
- })
42
+ maxPendingRequests: 100,
43
+ onEvent: event => console.debug('[worker-rotator]', event)
44
+ }).subscribe()
44
45
  ```
45
46
 
46
47
  Contract:
47
48
 
48
- - The returned observable is cold. Each subscription creates its own rotator engine and subscribes to `request$`; share the returned observable if you want one engine with multiple observers.
49
+ - The returned observable is cold and does not emit values. Each subscription creates its own rotator engine and subscribes to `request$`; share the returned observable if you want one engine with multiple observers.
50
+ - `onEvent` receives optional lifecycle events for logging, tracing, or diagnostics.
49
51
  - Requests emitted before the first worker is ready, or between workers, are buffered and handed to the next worker.
50
52
  - `maxPendingRequests` caps the no-worker buffer and errors the rotator when exceeded. The default is `Infinity`.
51
53
  - Pending requests are considered handed off once they are passed to `worker.process`. Delivery retries, acknowledgements, and exactly-once guarantees belong in the worker or an upstream queue.
@@ -60,14 +62,15 @@ Rotate child processes that communicate over stdin/stdout using a line-oriented
60
62
  import { spawn } from "node:child_process"
61
63
  import { makeCLIWorkerRotator } from "@lsdsoftware/utils"
62
64
 
63
- const events$ = makeCLIWorkerRotator({
65
+ const subscription = makeCLIWorkerRotator({
64
66
  spawnWorkerProcess: () => spawn("my-jsonl-worker", {
65
67
  stdio: ["pipe", "pipe", "inherit"]
66
68
  }),
67
69
  workerTtlMs: 60_000,
68
70
  request$,
69
- maxPendingRequests: 100
70
- })
71
+ maxPendingRequests: 100,
72
+ onEvent: event => console.debug('[cli-worker-rotator]', event)
73
+ }).subscribe()
71
74
  ```
72
75
 
73
76
  Each request writes exactly one stdin line. Each stdout line is paired with the next pending request in order. Child processes should write logs to stderr.
@@ -13,10 +13,11 @@ export interface CLIWorkerRotatorOptions {
13
13
  workerTtlMs: number;
14
14
  request$: rxjs.Observable<CLIRequest>;
15
15
  maxPendingRequests?: number;
16
+ onEvent?: (event: CLIWorkerRotatorEvent) => void;
16
17
  }
17
18
  /**
18
19
  * Rotates child processes that communicate over stdin/stdout using a
19
20
  * line-oriented request/response protocol, such as JSONL.
20
21
  * See the README for the protocol and lifecycle contract.
21
22
  */
22
- export declare function makeCLIWorkerRotator({ spawnWorkerProcess, workerTtlMs, request$, maxPendingRequests }: CLIWorkerRotatorOptions): rxjs.Observable<CLIWorkerRotatorEvent>;
23
+ export declare function makeCLIWorkerRotator({ spawnWorkerProcess, workerTtlMs, request$, maxPendingRequests, onEvent }: CLIWorkerRotatorOptions): rxjs.Observable<never>;
@@ -6,13 +6,14 @@ import { makeWorkerRotator } from "./worker-rotator.js";
6
6
  * line-oriented request/response protocol, such as JSONL.
7
7
  * See the README for the protocol and lifecycle contract.
8
8
  */
9
- export function makeCLIWorkerRotator({ spawnWorkerProcess, workerTtlMs, request$, maxPendingRequests }) {
9
+ export function makeCLIWorkerRotator({ spawnWorkerProcess, workerTtlMs, request$, maxPendingRequests, onEvent }) {
10
10
  return makeWorkerRotator({
11
11
  makeWorker: () => makeWorker(spawnWorkerProcess),
12
12
  workerTtlMs,
13
13
  request$,
14
- maxPendingRequests
15
- }).pipe(rxjs.map(event => ({ ...event, worker: event.worker.child })));
14
+ maxPendingRequests,
15
+ onEvent: event => onEvent?.({ ...event, worker: event.worker.child })
16
+ });
16
17
  }
17
18
  async function makeWorker(spawn) {
18
19
  const child = spawn();
@@ -5,7 +5,7 @@ export interface Worker<R> {
5
5
  quit$: rxjs.Observable<unknown>;
6
6
  }
7
7
  export type WorkerRotatorEvent<W> = {
8
- type: 'hired' | 'relieved';
8
+ type: 'hired' | 'expired' | 'relieved';
9
9
  worker: W;
10
10
  } | {
11
11
  type: 'quit';
@@ -17,9 +17,10 @@ export interface WorkerRotatorOptions<R, W extends Worker<R>> {
17
17
  workerTtlMs: number;
18
18
  request$: rxjs.Observable<R>;
19
19
  maxPendingRequests?: number;
20
+ onEvent?: (event: WorkerRotatorEvent<W>) => void;
20
21
  }
21
22
  /**
22
- * Rotates workers over a request stream and emits worker lifecycle events.
23
+ * Rotates workers over a request stream.
23
24
  * See the README for the lifecycle and buffering contract.
24
25
  */
25
- export declare function makeWorkerRotator<R, W extends Worker<R>>({ makeWorker, workerTtlMs, request$, maxPendingRequests }: WorkerRotatorOptions<R, W>): rxjs.Observable<WorkerRotatorEvent<W>>;
26
+ export declare function makeWorkerRotator<R, W extends Worker<R>>({ makeWorker, workerTtlMs, request$, maxPendingRequests, onEvent }: WorkerRotatorOptions<R, W>): rxjs.Observable<never>;
@@ -1,20 +1,19 @@
1
1
  import * as rxjs from "rxjs";
2
2
  /**
3
- * Rotates workers over a request stream and emits worker lifecycle events.
3
+ * Rotates workers over a request stream.
4
4
  * See the README for the lifecycle and buffering contract.
5
5
  */
6
- export function makeWorkerRotator({ makeWorker, workerTtlMs, request$, maxPendingRequests = Infinity }) {
6
+ export function makeWorkerRotator({ makeWorker, workerTtlMs, request$, maxPendingRequests = Infinity, onEvent }) {
7
7
  return rxjs.defer(() => {
8
8
  if (maxPendingRequests !== Infinity && (!Number.isInteger(maxPendingRequests) || maxPendingRequests < 0)) {
9
9
  throw new RangeError('maxPendingRequests must be a non-negative integer or Infinity');
10
10
  }
11
- const eventSubject = new rxjs.ReplaySubject(1);
12
- return rxjs.defer(() => makeWorker()).pipe(rxjs.tap(worker => eventSubject?.next({ type: 'hired', worker })), rxjs.exhaustMap(worker => rxjs.NEVER.pipe(rxjs.startWith(worker), rxjs.takeUntil(rxjs.race(worker.quit$, rxjs.timer(workerTtlMs).pipe(rxjs.map(() => 'Worker TTL expired'))).pipe(rxjs.tap(reason => eventSubject?.next({ type: 'quit', worker, reason })))), rxjs.endWith(null), rxjs.finalize(() => {
11
+ return rxjs.defer(() => makeWorker()).pipe(rxjs.tap(worker => onEvent?.({ type: 'hired', worker })), rxjs.exhaustMap(worker => rxjs.NEVER.pipe(rxjs.startWith(worker), rxjs.takeUntil(rxjs.race(worker.quit$.pipe(rxjs.tap(reason => onEvent?.({ type: 'quit', worker, reason }))), rxjs.timer(workerTtlMs).pipe(rxjs.tap(() => onEvent?.({ type: 'expired', worker }))))), rxjs.endWith(null), rxjs.finalize(() => {
13
12
  worker.relieve();
14
- eventSubject?.next({ type: 'relieved', worker });
13
+ onEvent?.({ type: 'relieved', worker });
15
14
  }))), rxjs.repeat(), rxjs.share(), worker$ => request$.pipe(rxjs.window(worker$), rxjs.zipWith(worker$.pipe(rxjs.startWith(null))), rxjs.mergeScan((pending, [window$, worker]) => rxjs.concat(pending, window$).pipe(worker
16
15
  ? request$ => worker.process(request$).pipe(rxjs.startWith([]))
17
- : request$ => request$.pipe(bufferRequests(maxPendingRequests))), []), rxjs.ignoreElements()), rxjs.mergeWith(eventSubject));
16
+ : request$ => request$.pipe(bufferRequests(maxPendingRequests))), []), rxjs.ignoreElements()));
18
17
  });
19
18
  }
20
19
  function bufferRequests(maxPendingRequests) {
@@ -73,15 +73,16 @@ describe('worker-rotator', ({ test }) => {
73
73
  assert(err instanceof Error);
74
74
  assert.equal(err.message, 'Worker rotator exceeded max pending requests (1)');
75
75
  });
76
- test('emits lifecycle events', async () => {
76
+ test('reports lifecycle events', async () => {
77
77
  const request$ = new rxjs.Subject;
78
78
  const worker = makeTestWorker();
79
79
  const events = [];
80
80
  const subscription = makeWorkerRotator({
81
81
  makeWorker: async () => worker,
82
82
  workerTtlMs: 1000,
83
- request$
84
- }).subscribe(event => events.push(event));
83
+ request$,
84
+ onEvent: event => events.push(event)
85
+ }).subscribe();
85
86
  try {
86
87
  await waitFor(() => events.length >= 1);
87
88
  worker.quit$.next('done');
@@ -93,6 +94,25 @@ describe('worker-rotator', ({ test }) => {
93
94
  subscription.unsubscribe();
94
95
  }
95
96
  });
97
+ test('reports expiration before relieving workers', async () => {
98
+ const request$ = new rxjs.Subject;
99
+ const worker = makeTestWorker();
100
+ const events = [];
101
+ const subscription = makeWorkerRotator({
102
+ makeWorker: async () => worker,
103
+ workerTtlMs: 1,
104
+ request$,
105
+ onEvent: event => events.push(event)
106
+ }).subscribe();
107
+ try {
108
+ await waitFor(() => events.some(event => event.type == 'expired'));
109
+ await waitFor(() => events.some(event => event.type == 'relieved'));
110
+ assert.deepStrictEqual(events.slice(0, 3).map(event => event.type), ['hired', 'expired', 'relieved']);
111
+ }
112
+ finally {
113
+ subscription.unsubscribe();
114
+ }
115
+ });
96
116
  });
97
117
  function makeTestWorker() {
98
118
  const processRequests$ = new rxjs.Subject;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@lsdsoftware/utils",
3
3
  "type": "module",
4
- "version": "2.1.0",
4
+ "version": "2.1.1",
5
5
  "description": "Useful JavaScript utilities",
6
6
  "main": "dist/index.js",
7
7
  "files": [