@lsdsoftware/utils 2.2.0 → 2.3.0
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 +17 -16
- package/dist/cli-worker-rotator.d.ts +18 -15
- package/dist/cli-worker-rotator.js +76 -22
- package/dist/cli-worker-rotator.test.js +149 -35
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -30,32 +30,33 @@ Observable wrapper for net.connect (see connect-socket.test.ts for usage)
|
|
|
30
30
|
|
|
31
31
|
|
|
32
32
|
### CLI Worker Rotator
|
|
33
|
-
Rotate child processes that communicate over stdin/stdout.
|
|
33
|
+
Rotate child processes that communicate over stdin/stdout using a line-oriented request/response protocol, such as JSONL.
|
|
34
34
|
|
|
35
35
|
```typescript
|
|
36
36
|
import { spawn } from "node:child_process"
|
|
37
37
|
import { makeCLIWorkerRotator } from "@lsdsoftware/utils"
|
|
38
38
|
|
|
39
39
|
const subscription = makeCLIWorkerRotator({
|
|
40
|
-
spawnWorkerProcess:
|
|
41
|
-
signal,
|
|
40
|
+
spawnWorkerProcess: () => spawn("my-jsonl-worker", {
|
|
42
41
|
stdio: ["pipe", "pipe", "inherit"]
|
|
43
42
|
}),
|
|
44
|
-
|
|
43
|
+
workerTtlMs: 60_000,
|
|
44
|
+
request$,
|
|
45
|
+
maxPendingRequests: 100,
|
|
45
46
|
onEvent: event => console.debug('[cli-worker-rotator]', event)
|
|
46
|
-
}).subscribe(
|
|
47
|
-
if (worker) {
|
|
48
|
-
worker.stdin.write("hello\n")
|
|
49
|
-
}
|
|
50
|
-
})
|
|
47
|
+
}).subscribe()
|
|
51
48
|
```
|
|
52
49
|
|
|
50
|
+
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.
|
|
51
|
+
|
|
53
52
|
Contract:
|
|
54
53
|
|
|
55
|
-
- The returned observable is cold. Each subscription
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
- `
|
|
59
|
-
-
|
|
60
|
-
- `
|
|
61
|
-
-
|
|
54
|
+
- 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.
|
|
55
|
+
- `onEvent` receives optional lifecycle events for logging, tracing, or diagnostics.
|
|
56
|
+
- Requests emitted before the first child process is ready, or between child processes, are buffered and handed to the next child process.
|
|
57
|
+
- `maxPendingRequests` caps the no-worker buffer and errors the rotator when exceeded. The default is `Infinity`.
|
|
58
|
+
- Pending requests are considered handed off once they are written to a worker process. Delivery retries, acknowledgements, and exactly-once guarantees belong in the worker or an upstream queue.
|
|
59
|
+
- Completing `request$` means no more input, but it does not define the rotator lifecycle. The engine runs until the returned observable is unsubscribed or errors.
|
|
60
|
+
- Worker stdin is ended during teardown. Child processes should exit cleanly when stdin closes.
|
|
61
|
+
|
|
62
|
+
If a worker exits before producing a matching stdout line for a written request, that request's `output$` is not resolved by this adapter. Apply timeout or cancellation around each request if the caller needs bounded waits.
|
|
@@ -1,26 +1,29 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ChildProcessByStdio } from "child_process";
|
|
2
2
|
import * as rxjs from "rxjs";
|
|
3
3
|
import type { Readable, Writable } from "stream";
|
|
4
|
+
export type CLIWorker = ChildProcessByStdio<Writable, Readable, null>;
|
|
4
5
|
export type CLIWorkerRotatorEvent = {
|
|
5
|
-
type: '
|
|
6
|
-
worker:
|
|
6
|
+
type: 'hired' | 'relieved';
|
|
7
|
+
worker: CLIWorker;
|
|
7
8
|
} | {
|
|
8
|
-
type: '
|
|
9
|
-
|
|
10
|
-
} | {
|
|
11
|
-
type: 'close';
|
|
12
|
-
worker: ChildProcess;
|
|
9
|
+
type: 'quit';
|
|
10
|
+
worker: CLIWorker;
|
|
13
11
|
reason: unknown;
|
|
14
12
|
};
|
|
13
|
+
export interface CLIRequest {
|
|
14
|
+
input: string;
|
|
15
|
+
output$: rxjs.SubjectLike<string>;
|
|
16
|
+
}
|
|
15
17
|
export interface CLIWorkerRotatorOptions {
|
|
16
|
-
spawnWorkerProcess: (
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
spawnWorkerProcess: () => CLIWorker;
|
|
19
|
+
workerTtlMs: number;
|
|
20
|
+
request$: rxjs.Observable<CLIRequest>;
|
|
21
|
+
maxPendingRequests?: number;
|
|
20
22
|
onEvent?: (event: CLIWorkerRotatorEvent) => void;
|
|
21
23
|
}
|
|
22
24
|
/**
|
|
23
|
-
* Rotates child processes that communicate over stdin/stdout
|
|
24
|
-
*
|
|
25
|
+
* Rotates child processes that communicate over stdin/stdout using a
|
|
26
|
+
* line-oriented request/response protocol, such as JSONL.
|
|
27
|
+
* See the README for the protocol and lifecycle contract.
|
|
25
28
|
*/
|
|
26
|
-
export declare function makeCLIWorkerRotator({ spawnWorkerProcess,
|
|
29
|
+
export declare function makeCLIWorkerRotator({ spawnWorkerProcess, workerTtlMs, request$, maxPendingRequests, onEvent }: CLIWorkerRotatorOptions): rxjs.Observable<never>;
|
|
@@ -1,28 +1,82 @@
|
|
|
1
1
|
import * as rxjs from "rxjs";
|
|
2
|
+
import { makeLineReader } from "./line-reader.js";
|
|
2
3
|
/**
|
|
3
|
-
* Rotates child processes that communicate over stdin/stdout
|
|
4
|
-
*
|
|
4
|
+
* Rotates child processes that communicate over stdin/stdout using a
|
|
5
|
+
* line-oriented request/response protocol, such as JSONL.
|
|
6
|
+
* See the README for the protocol and lifecycle contract.
|
|
5
7
|
*/
|
|
6
|
-
export function makeCLIWorkerRotator({ spawnWorkerProcess,
|
|
7
|
-
if (workerTTL != undefined && workerTTL <= 0) {
|
|
8
|
-
throw new Error('Invalid workerTTL');
|
|
9
|
-
}
|
|
8
|
+
export function makeCLIWorkerRotator({ spawnWorkerProcess, workerTtlMs, request$, maxPendingRequests = Infinity, onEvent }) {
|
|
10
9
|
return rxjs.defer(() => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
? rxjs.timer(workerTTL).pipe(rxjs.map(() => 'TTL expire'))
|
|
21
|
-
: rxjs.NEVER).pipe(rxjs.tap(reason => onEvent?.({ type: 'close', worker, reason })))), rxjs.endWith(null), rxjs.finalize(() => {
|
|
22
|
-
worker.stdin.end();
|
|
23
|
-
}));
|
|
24
|
-
}), repeatConfig ? rxjs.repeat(repeatConfig) : rxjs.identity, rxjs.finalize(() => {
|
|
25
|
-
abortCtl.abort();
|
|
26
|
-
}));
|
|
10
|
+
if (maxPendingRequests !== Infinity && (!Number.isInteger(maxPendingRequests) || maxPendingRequests < 0)) {
|
|
11
|
+
throw new RangeError('maxPendingRequests must be a non-negative integer or Infinity');
|
|
12
|
+
}
|
|
13
|
+
return rxjs.defer(() => makeWorker(spawnWorkerProcess)).pipe(rxjs.tap(worker => onEvent?.({ type: 'hired', worker: worker.child })), 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 => onEvent?.({ type: 'quit', worker: worker.child, reason })))), rxjs.endWith(null), rxjs.finalize(() => {
|
|
14
|
+
worker.relieve();
|
|
15
|
+
onEvent?.({ type: 'relieved', worker: worker.child });
|
|
16
|
+
}))), 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
|
|
17
|
+
? request$ => worker.process(request$).pipe(rxjs.startWith([]))
|
|
18
|
+
: request$ => request$.pipe(bufferRequests(maxPendingRequests))), []), rxjs.ignoreElements()));
|
|
27
19
|
});
|
|
28
20
|
}
|
|
21
|
+
async function makeWorker(spawn) {
|
|
22
|
+
const child = spawn();
|
|
23
|
+
await new Promise((f, r) => child.once('spawn', f).once('error', r));
|
|
24
|
+
return {
|
|
25
|
+
child,
|
|
26
|
+
process(request$) {
|
|
27
|
+
return request$.pipe(rxjs.mergeMap(request => writeLn(child.stdin, request.input).pipe(rxjs.map(() => request), rxjs.catchError(err => {
|
|
28
|
+
request.output$.error(err);
|
|
29
|
+
return rxjs.EMPTY;
|
|
30
|
+
}))), rxjs.zipWith(readLines(child.stdout)), rxjs.tap(([request, line]) => request.output$.next(line)), rxjs.ignoreElements());
|
|
31
|
+
},
|
|
32
|
+
relieve() {
|
|
33
|
+
child.stdin.end();
|
|
34
|
+
},
|
|
35
|
+
quit$: rxjs.race(rxjs.fromEvent(child, 'close', (code, signal) => `Worker exit ${signal || code}`), rxjs.fromEvent(child.stdin, 'error', err => new Error('Worker stdin error', { cause: err })))
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
function readLines(stream) {
|
|
39
|
+
return new rxjs.Observable(subscriber => {
|
|
40
|
+
const lineReader = makeLineReader(line => subscriber.next(line));
|
|
41
|
+
const onError = (err) => subscriber.error(err);
|
|
42
|
+
const onFinish = () => subscriber.complete();
|
|
43
|
+
lineReader.once('error', onError);
|
|
44
|
+
lineReader.once('finish', onFinish);
|
|
45
|
+
stream.once('error', onError);
|
|
46
|
+
stream.pipe(lineReader);
|
|
47
|
+
return () => {
|
|
48
|
+
lineReader.off('error', onError);
|
|
49
|
+
lineReader.off('finish', onFinish);
|
|
50
|
+
stream.off('error', onError);
|
|
51
|
+
stream.unpipe(lineReader);
|
|
52
|
+
lineReader.destroy();
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
function writeLn(stream, line) {
|
|
57
|
+
return new rxjs.Observable(subscriber => {
|
|
58
|
+
try {
|
|
59
|
+
stream.write(line + "\n", err => {
|
|
60
|
+
if (err) {
|
|
61
|
+
subscriber.error(err);
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
subscriber.next();
|
|
65
|
+
subscriber.complete();
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
subscriber.error(err);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
function bufferRequests(maxPendingRequests) {
|
|
75
|
+
return request$ => request$.pipe(rxjs.reduce((pending, request) => {
|
|
76
|
+
if (pending.length >= maxPendingRequests) {
|
|
77
|
+
throw new Error(`Worker rotator exceeded max pending requests (${maxPendingRequests})`);
|
|
78
|
+
}
|
|
79
|
+
pending.push(request);
|
|
80
|
+
return pending;
|
|
81
|
+
}, []));
|
|
82
|
+
}
|
|
@@ -2,52 +2,166 @@ import { describe } from "@service-broker/test-utils";
|
|
|
2
2
|
import assert from "assert";
|
|
3
3
|
import { EventEmitter } from "events";
|
|
4
4
|
import * as rxjs from "rxjs";
|
|
5
|
-
import { PassThrough } from "stream";
|
|
5
|
+
import { PassThrough, Writable } from "stream";
|
|
6
6
|
import { makeCLIWorkerRotator } from "./cli-worker-rotator.js";
|
|
7
7
|
describe('cli-worker-rotator', ({ test }) => {
|
|
8
|
-
test('
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
8
|
+
test('pairs stdout lines with requests in order', async () => {
|
|
9
|
+
const request$ = new rxjs.Subject;
|
|
10
|
+
const child = makeEchoChild({ autoSpawn: true });
|
|
11
|
+
const firstOutput$ = new rxjs.Subject;
|
|
12
|
+
const secondOutput$ = new rxjs.Subject;
|
|
13
|
+
const subscription = makeCLIWorkerRotator({
|
|
14
|
+
spawnWorkerProcess: () => child,
|
|
15
|
+
workerTtlMs: 1000,
|
|
16
|
+
request$
|
|
17
|
+
}).subscribe();
|
|
18
|
+
try {
|
|
19
|
+
request$.next({ input: 'one', output$: firstOutput$ });
|
|
20
|
+
request$.next({ input: 'two', output$: secondOutput$ });
|
|
21
|
+
const outputs = await Promise.all([
|
|
22
|
+
rxjs.firstValueFrom(firstOutput$),
|
|
23
|
+
rxjs.firstValueFrom(secondOutput$)
|
|
24
|
+
]);
|
|
25
|
+
assert.deepStrictEqual(outputs, ['ONE', 'TWO']);
|
|
26
|
+
}
|
|
27
|
+
finally {
|
|
28
|
+
subscription.unsubscribe();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
test('buffers requests before the first worker is ready', async () => {
|
|
32
|
+
const request$ = new rxjs.Subject;
|
|
33
|
+
const child = makeEchoChild();
|
|
34
|
+
const firstOutput$ = new rxjs.Subject;
|
|
35
|
+
const secondOutput$ = new rxjs.Subject;
|
|
36
|
+
const subscription = makeCLIWorkerRotator({
|
|
37
|
+
spawnWorkerProcess: () => child,
|
|
38
|
+
workerTtlMs: 1000,
|
|
39
|
+
request$
|
|
40
|
+
}).subscribe();
|
|
41
|
+
try {
|
|
42
|
+
request$.next({ input: 'one', output$: firstOutput$ });
|
|
43
|
+
request$.next({ input: 'two', output$: secondOutput$ });
|
|
44
|
+
child.spawn();
|
|
45
|
+
const outputs = await Promise.all([
|
|
46
|
+
rxjs.firstValueFrom(firstOutput$),
|
|
47
|
+
rxjs.firstValueFrom(secondOutput$)
|
|
48
|
+
]);
|
|
49
|
+
assert.deepStrictEqual(outputs, ['ONE', 'TWO']);
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
subscription.unsubscribe();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
test('buffers requests between workers', async () => {
|
|
56
|
+
const request$ = new rxjs.Subject;
|
|
57
|
+
const firstChild = makeEchoChild({ autoSpawn: true });
|
|
58
|
+
const secondChild = makeEchoChild();
|
|
59
|
+
const children = [firstChild, secondChild];
|
|
60
|
+
const firstOutput$ = new rxjs.Subject;
|
|
61
|
+
const secondOutput$ = new rxjs.Subject;
|
|
62
|
+
const subscription = makeCLIWorkerRotator({
|
|
12
63
|
spawnWorkerProcess: () => children.shift(),
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
64
|
+
workerTtlMs: 1000,
|
|
65
|
+
request$
|
|
66
|
+
}).subscribe();
|
|
67
|
+
try {
|
|
68
|
+
request$.next({ input: 'one', output$: firstOutput$ });
|
|
69
|
+
assert.equal(await rxjs.firstValueFrom(firstOutput$), 'ONE');
|
|
70
|
+
firstChild.emit('close', 0, null);
|
|
71
|
+
request$.next({ input: 'two', output$: secondOutput$ });
|
|
72
|
+
secondChild.spawn();
|
|
73
|
+
assert.equal(await rxjs.firstValueFrom(secondOutput$), 'TWO');
|
|
74
|
+
assert(firstChild.stdinEnded);
|
|
75
|
+
}
|
|
76
|
+
finally {
|
|
77
|
+
subscription.unsubscribe();
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
test('errors when pending request cap is exceeded', async () => {
|
|
81
|
+
const request$ = new rxjs.Subject;
|
|
82
|
+
const child = makeEchoChild();
|
|
83
|
+
const error = defer();
|
|
84
|
+
makeCLIWorkerRotator({
|
|
85
|
+
spawnWorkerProcess: () => child,
|
|
86
|
+
workerTtlMs: 1000,
|
|
87
|
+
request$,
|
|
88
|
+
maxPendingRequests: 1
|
|
89
|
+
}).subscribe({
|
|
90
|
+
error: err => error.resolve(err)
|
|
91
|
+
});
|
|
92
|
+
request$.next({ input: 'one', output$: new rxjs.Subject });
|
|
93
|
+
request$.next({ input: 'two', output$: new rxjs.Subject });
|
|
94
|
+
const err = await error.promise;
|
|
95
|
+
assert(err instanceof Error);
|
|
96
|
+
assert.equal(err.message, 'Worker rotator exceeded max pending requests (1)');
|
|
97
|
+
});
|
|
98
|
+
test('reports lifecycle events', async () => {
|
|
99
|
+
const request$ = new rxjs.Subject;
|
|
100
|
+
const child = makeEchoChild({ autoSpawn: true });
|
|
101
|
+
const events = [];
|
|
28
102
|
const subscription = makeCLIWorkerRotator({
|
|
29
|
-
spawnWorkerProcess:
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
return makeChild({ autoSpawn: false });
|
|
34
|
-
}
|
|
103
|
+
spawnWorkerProcess: () => child,
|
|
104
|
+
workerTtlMs: 1000,
|
|
105
|
+
request$,
|
|
106
|
+
onEvent: event => events.push(event)
|
|
35
107
|
}).subscribe();
|
|
36
|
-
|
|
37
|
-
|
|
108
|
+
try {
|
|
109
|
+
await waitFor(() => events.length >= 1);
|
|
110
|
+
child.emit('close', 0, null);
|
|
111
|
+
await waitFor(() => events.length >= 3);
|
|
112
|
+
assert.deepStrictEqual(events.slice(0, 3).map(event => event.type), ['hired', 'quit', 'relieved']);
|
|
113
|
+
assert.equal(events[1].type == 'quit' && events[1].reason, 'Worker exit 0');
|
|
114
|
+
}
|
|
115
|
+
finally {
|
|
116
|
+
subscription.unsubscribe();
|
|
117
|
+
}
|
|
38
118
|
});
|
|
39
119
|
});
|
|
40
|
-
function
|
|
120
|
+
function makeEchoChild({ autoSpawn = false } = {}) {
|
|
41
121
|
const child = new EventEmitter();
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
122
|
+
const stdout = new PassThrough();
|
|
123
|
+
let remainder = '';
|
|
124
|
+
let stdinEnded = false;
|
|
125
|
+
child.stdin = new Writable({
|
|
126
|
+
write(chunk, _encoding, callback) {
|
|
127
|
+
remainder += chunk.toString();
|
|
128
|
+
const lines = remainder.split(/\r?\n/);
|
|
129
|
+
remainder = lines.pop();
|
|
130
|
+
for (const line of lines) {
|
|
131
|
+
stdout.write(line.toUpperCase() + '\n');
|
|
132
|
+
}
|
|
133
|
+
callback();
|
|
134
|
+
},
|
|
135
|
+
final(callback) {
|
|
136
|
+
stdinEnded = true;
|
|
137
|
+
callback();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
child.stdout = stdout;
|
|
45
141
|
child.stderr = null;
|
|
46
|
-
|
|
47
|
-
|
|
142
|
+
child.spawn = () => child.emit('spawn');
|
|
143
|
+
Object.defineProperty(child, 'stdinEnded', {
|
|
144
|
+
get: () => stdinEnded
|
|
48
145
|
});
|
|
49
146
|
if (autoSpawn) {
|
|
50
|
-
setTimeout(() => child.
|
|
147
|
+
setTimeout(() => child.spawn(), 0);
|
|
51
148
|
}
|
|
52
149
|
return child;
|
|
53
150
|
}
|
|
151
|
+
function defer() {
|
|
152
|
+
let resolve;
|
|
153
|
+
let reject;
|
|
154
|
+
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
155
|
+
resolve = resolvePromise;
|
|
156
|
+
reject = rejectPromise;
|
|
157
|
+
});
|
|
158
|
+
return { promise, resolve, reject };
|
|
159
|
+
}
|
|
160
|
+
async function waitFor(condition) {
|
|
161
|
+
for (let i = 0; i < 100; i++) {
|
|
162
|
+
if (condition())
|
|
163
|
+
return;
|
|
164
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
165
|
+
}
|
|
166
|
+
assert(condition());
|
|
167
|
+
}
|