@lsdsoftware/utils 2.1.1 → 2.2.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 -36
- package/dist/cli-worker-rotator.d.ts +19 -16
- package/dist/cli-worker-rotator.js +23 -65
- package/dist/cli-worker-rotator.test.js +39 -37
- package/dist/index.d.ts +0 -1
- package/dist/index.js +0 -1
- package/dist/index.test.d.ts +0 -1
- package/dist/index.test.js +0 -1
- package/package.json +1 -1
- package/dist/worker-rotator.d.ts +0 -26
- package/dist/worker-rotator.js +0 -27
- package/dist/worker-rotator.test.d.ts +0 -1
- package/dist/worker-rotator.test.js +0 -149
package/README.md
CHANGED
|
@@ -29,52 +29,33 @@ const result = await semaphore.runTask(async () => {
|
|
|
29
29
|
Observable wrapper for net.connect (see connect-socket.test.ts for usage)
|
|
30
30
|
|
|
31
31
|
|
|
32
|
-
### Worker Rotator
|
|
33
|
-
Rotate worker instances over a request stream.
|
|
34
|
-
|
|
35
|
-
```typescript
|
|
36
|
-
import { makeWorkerRotator } from "@lsdsoftware/utils"
|
|
37
|
-
|
|
38
|
-
const subscription = makeWorkerRotator({
|
|
39
|
-
makeWorker,
|
|
40
|
-
workerTtlMs: 60_000,
|
|
41
|
-
request$,
|
|
42
|
-
maxPendingRequests: 100,
|
|
43
|
-
onEvent: event => console.debug('[worker-rotator]', event)
|
|
44
|
-
}).subscribe()
|
|
45
|
-
```
|
|
46
|
-
|
|
47
|
-
Contract:
|
|
48
|
-
|
|
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.
|
|
51
|
-
- Requests emitted before the first worker is ready, or between workers, are buffered and handed to the next worker.
|
|
52
|
-
- `maxPendingRequests` caps the no-worker buffer and errors the rotator when exceeded. The default is `Infinity`.
|
|
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.
|
|
54
|
-
- 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.
|
|
55
|
-
- `worker.relieve()` is called during worker teardown. Implementations should make it safe to call more than once.
|
|
56
|
-
|
|
57
|
-
|
|
58
32
|
### CLI Worker Rotator
|
|
59
|
-
Rotate child processes that communicate over stdin/stdout
|
|
33
|
+
Rotate child processes that communicate over stdin/stdout.
|
|
60
34
|
|
|
61
35
|
```typescript
|
|
62
36
|
import { spawn } from "node:child_process"
|
|
63
37
|
import { makeCLIWorkerRotator } from "@lsdsoftware/utils"
|
|
64
38
|
|
|
65
39
|
const subscription = makeCLIWorkerRotator({
|
|
66
|
-
spawnWorkerProcess:
|
|
40
|
+
spawnWorkerProcess: signal => spawn("my-cli-worker", {
|
|
41
|
+
signal,
|
|
67
42
|
stdio: ["pipe", "pipe", "inherit"]
|
|
68
43
|
}),
|
|
69
|
-
|
|
70
|
-
request$,
|
|
71
|
-
maxPendingRequests: 100,
|
|
44
|
+
workerTTL: 60_000,
|
|
72
45
|
onEvent: event => console.debug('[cli-worker-rotator]', event)
|
|
73
|
-
}).subscribe(
|
|
46
|
+
}).subscribe(worker => {
|
|
47
|
+
if (worker) {
|
|
48
|
+
worker.stdin.write("hello\n")
|
|
49
|
+
}
|
|
50
|
+
})
|
|
74
51
|
```
|
|
75
52
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
For custom framing or multiplexed protocols, implement `Worker<R>` directly and use `makeWorkerRotator`.
|
|
53
|
+
Contract:
|
|
79
54
|
|
|
80
|
-
|
|
55
|
+
- The returned observable is cold. Each subscription owns its own child process lifecycle.
|
|
56
|
+
- The observable emits the child process when a worker starts, emits `null` when that worker closes, then repeats with a new worker.
|
|
57
|
+
- `spawnWorkerProcess` receives an `AbortSignal`; pass it to `spawn` so unsubscribing can cancel an in-flight spawn.
|
|
58
|
+
- `workerTTL` rotates a worker after the given number of milliseconds. It must be greater than zero when provided.
|
|
59
|
+
- `retryConfig` controls retries after spawn failures. `repeatConfig` controls rotation after normal close or TTL expiry; the default waits 1000 ms before spawning the next worker.
|
|
60
|
+
- `onEvent` receives `spawn`, `fail-spawn`, and `close` events for logging, tracing, or diagnostics.
|
|
61
|
+
- Request framing, response matching, backpressure, and protocol-specific timeouts belong in the caller that uses the emitted stdin/stdout streams.
|
|
@@ -1,23 +1,26 @@
|
|
|
1
|
-
import type { ChildProcessByStdio } from "child_process";
|
|
1
|
+
import type { ChildProcess, ChildProcessByStdio } from "child_process";
|
|
2
2
|
import * as rxjs from "rxjs";
|
|
3
3
|
import type { Readable, Writable } from "stream";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
4
|
+
export type CLIWorkerRotatorEvent = {
|
|
5
|
+
type: 'spawn';
|
|
6
|
+
worker: ChildProcess;
|
|
7
|
+
} | {
|
|
8
|
+
type: 'fail-spawn';
|
|
9
|
+
reason: unknown;
|
|
10
|
+
} | {
|
|
11
|
+
type: 'close';
|
|
12
|
+
worker: ChildProcess;
|
|
13
|
+
reason: unknown;
|
|
14
|
+
};
|
|
11
15
|
export interface CLIWorkerRotatorOptions {
|
|
12
|
-
spawnWorkerProcess: () =>
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
spawnWorkerProcess: (signal: AbortSignal) => ChildProcessByStdio<Writable, Readable, null>;
|
|
17
|
+
workerTTL?: number;
|
|
18
|
+
retryConfig?: rxjs.RetryConfig;
|
|
19
|
+
repeatConfig?: rxjs.RepeatConfig;
|
|
16
20
|
onEvent?: (event: CLIWorkerRotatorEvent) => void;
|
|
17
21
|
}
|
|
18
22
|
/**
|
|
19
|
-
* Rotates child processes that communicate over stdin/stdout
|
|
20
|
-
*
|
|
21
|
-
* See the README for the protocol and lifecycle contract.
|
|
23
|
+
* Rotates child processes that communicate over stdin/stdout.
|
|
24
|
+
* See the README for the lifecycle contract.
|
|
22
25
|
*/
|
|
23
|
-
export declare function makeCLIWorkerRotator({ spawnWorkerProcess,
|
|
26
|
+
export declare function makeCLIWorkerRotator({ spawnWorkerProcess, workerTTL, retryConfig, repeatConfig, onEvent }: CLIWorkerRotatorOptions): rxjs.Observable<ChildProcessByStdio<Writable, Readable, null> | null>;
|
|
@@ -1,70 +1,28 @@
|
|
|
1
1
|
import * as rxjs from "rxjs";
|
|
2
|
-
import { makeLineReader } from "./line-reader.js";
|
|
3
|
-
import { makeWorkerRotator } from "./worker-rotator.js";
|
|
4
2
|
/**
|
|
5
|
-
* Rotates child processes that communicate over stdin/stdout
|
|
6
|
-
*
|
|
7
|
-
* See the README for the protocol and lifecycle contract.
|
|
3
|
+
* Rotates child processes that communicate over stdin/stdout.
|
|
4
|
+
* See the README for the lifecycle contract.
|
|
8
5
|
*/
|
|
9
|
-
export function makeCLIWorkerRotator({ spawnWorkerProcess,
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
child.stdin.end();
|
|
31
|
-
},
|
|
32
|
-
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 })))
|
|
33
|
-
};
|
|
34
|
-
}
|
|
35
|
-
function readLines(stream) {
|
|
36
|
-
return new rxjs.Observable(subscriber => {
|
|
37
|
-
const lineReader = makeLineReader(line => subscriber.next(line));
|
|
38
|
-
const onError = (err) => subscriber.error(err);
|
|
39
|
-
const onFinish = () => subscriber.complete();
|
|
40
|
-
lineReader.once('error', onError);
|
|
41
|
-
lineReader.once('finish', onFinish);
|
|
42
|
-
stream.once('error', onError);
|
|
43
|
-
stream.pipe(lineReader);
|
|
44
|
-
return () => {
|
|
45
|
-
lineReader.off('error', onError);
|
|
46
|
-
lineReader.off('finish', onFinish);
|
|
47
|
-
stream.off('error', onError);
|
|
48
|
-
stream.unpipe(lineReader);
|
|
49
|
-
lineReader.destroy();
|
|
50
|
-
};
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
function writeLn(stream, line) {
|
|
54
|
-
return new rxjs.Observable(subscriber => {
|
|
55
|
-
try {
|
|
56
|
-
stream.write(line + "\n", err => {
|
|
57
|
-
if (err) {
|
|
58
|
-
subscriber.error(err);
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
subscriber.next();
|
|
62
|
-
subscriber.complete();
|
|
63
|
-
}
|
|
64
|
-
});
|
|
65
|
-
}
|
|
66
|
-
catch (err) {
|
|
67
|
-
subscriber.error(err);
|
|
68
|
-
}
|
|
6
|
+
export function makeCLIWorkerRotator({ spawnWorkerProcess, workerTTL, retryConfig, repeatConfig = { delay: 1000 }, onEvent }) {
|
|
7
|
+
if (workerTTL != undefined && workerTTL <= 0) {
|
|
8
|
+
throw new Error('Invalid workerTTL');
|
|
9
|
+
}
|
|
10
|
+
return rxjs.defer(() => {
|
|
11
|
+
const abortCtl = new AbortController();
|
|
12
|
+
return rxjs.defer(() => {
|
|
13
|
+
const child = spawnWorkerProcess(abortCtl.signal);
|
|
14
|
+
return rxjs.race(rxjs.fromEvent(child, 'spawn').pipe(rxjs.take(1), rxjs.map(() => child)), rxjs.fromEvent(child, 'error').pipe(rxjs.map(err => { throw err; })));
|
|
15
|
+
}).pipe(rxjs.tap({
|
|
16
|
+
next: worker => onEvent?.({ type: 'spawn', worker }),
|
|
17
|
+
error: err => onEvent?.({ type: 'fail-spawn', reason: err })
|
|
18
|
+
}), retryConfig ? rxjs.retry(retryConfig) : rxjs.identity, rxjs.exhaustMap(worker => {
|
|
19
|
+
return rxjs.NEVER.pipe(rxjs.startWith(worker), rxjs.takeUntil(rxjs.race(rxjs.fromEvent(worker, 'close', (code, signal) => `Process exit (${signal || code})`), rxjs.fromEvent(worker.stdin, 'error'), workerTTL
|
|
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
|
+
}));
|
|
69
27
|
});
|
|
70
28
|
}
|
|
@@ -2,50 +2,52 @@ 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
|
|
5
|
+
import { PassThrough } 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
|
|
12
|
-
|
|
8
|
+
test('emits each spawned worker and null when it closes', async () => {
|
|
9
|
+
const children = [makeChild(), makeChild()];
|
|
10
|
+
const spawned = [...children];
|
|
11
|
+
const emissions = await rxjs.firstValueFrom(makeCLIWorkerRotator({
|
|
12
|
+
spawnWorkerProcess: () => children.shift(),
|
|
13
|
+
onEvent: event => {
|
|
14
|
+
if (event.type === 'spawn') {
|
|
15
|
+
setTimeout(() => event.worker.emit('close', 0, null), 0);
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
repeatConfig: { count: 2, delay: 0 }
|
|
19
|
+
}).pipe(rxjs.take(4), rxjs.toArray()));
|
|
20
|
+
assert.strictEqual(emissions.length, 4);
|
|
21
|
+
assert.strictEqual(emissions[0], spawned[0]);
|
|
22
|
+
assert.strictEqual(emissions[1], null);
|
|
23
|
+
assert.strictEqual(emissions[2], spawned[1]);
|
|
24
|
+
assert.strictEqual(emissions[3], null);
|
|
25
|
+
});
|
|
26
|
+
test('aborts an in-flight spawn when unsubscribed before the worker starts', async () => {
|
|
27
|
+
let aborted = false;
|
|
13
28
|
const subscription = makeCLIWorkerRotator({
|
|
14
|
-
spawnWorkerProcess:
|
|
15
|
-
|
|
16
|
-
|
|
29
|
+
spawnWorkerProcess: signal => {
|
|
30
|
+
signal.addEventListener('abort', () => {
|
|
31
|
+
aborted = true;
|
|
32
|
+
});
|
|
33
|
+
return makeChild({ autoSpawn: false });
|
|
34
|
+
}
|
|
17
35
|
}).subscribe();
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
}
|
|
36
|
+
subscription.unsubscribe();
|
|
37
|
+
assert.strictEqual(aborted, true);
|
|
30
38
|
});
|
|
31
39
|
});
|
|
32
|
-
function
|
|
40
|
+
function makeChild({ autoSpawn = true } = {}) {
|
|
33
41
|
const child = new EventEmitter();
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
child.
|
|
37
|
-
write(chunk, _encoding, callback) {
|
|
38
|
-
remainder += chunk.toString();
|
|
39
|
-
const lines = remainder.split(/\r?\n/);
|
|
40
|
-
remainder = lines.pop();
|
|
41
|
-
for (const line of lines) {
|
|
42
|
-
stdout.write(line.toUpperCase() + '\n');
|
|
43
|
-
}
|
|
44
|
-
callback();
|
|
45
|
-
}
|
|
46
|
-
});
|
|
47
|
-
child.stdout = stdout;
|
|
42
|
+
const stdin = new PassThrough();
|
|
43
|
+
child.stdin = stdin;
|
|
44
|
+
child.stdout = new PassThrough();
|
|
48
45
|
child.stderr = null;
|
|
49
|
-
|
|
46
|
+
stdin.on('close', () => {
|
|
47
|
+
child.emit('close', 0, null);
|
|
48
|
+
});
|
|
49
|
+
if (autoSpawn) {
|
|
50
|
+
setTimeout(() => child.emit('spawn'), 0);
|
|
51
|
+
}
|
|
50
52
|
return child;
|
|
51
53
|
}
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/index.test.d.ts
CHANGED
package/dist/index.test.js
CHANGED
package/package.json
CHANGED
package/dist/worker-rotator.d.ts
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
import * as rxjs from "rxjs";
|
|
2
|
-
export interface Worker<R> {
|
|
3
|
-
process(request$: rxjs.Observable<R>): rxjs.Observable<never>;
|
|
4
|
-
relieve(): void;
|
|
5
|
-
quit$: rxjs.Observable<unknown>;
|
|
6
|
-
}
|
|
7
|
-
export type WorkerRotatorEvent<W> = {
|
|
8
|
-
type: 'hired' | 'expired' | 'relieved';
|
|
9
|
-
worker: W;
|
|
10
|
-
} | {
|
|
11
|
-
type: 'quit';
|
|
12
|
-
worker: W;
|
|
13
|
-
reason: unknown;
|
|
14
|
-
};
|
|
15
|
-
export interface WorkerRotatorOptions<R, W extends Worker<R>> {
|
|
16
|
-
makeWorker: () => Promise<W>;
|
|
17
|
-
workerTtlMs: number;
|
|
18
|
-
request$: rxjs.Observable<R>;
|
|
19
|
-
maxPendingRequests?: number;
|
|
20
|
-
onEvent?: (event: WorkerRotatorEvent<W>) => void;
|
|
21
|
-
}
|
|
22
|
-
/**
|
|
23
|
-
* Rotates workers over a request stream.
|
|
24
|
-
* See the README for the lifecycle and buffering contract.
|
|
25
|
-
*/
|
|
26
|
-
export declare function makeWorkerRotator<R, W extends Worker<R>>({ makeWorker, workerTtlMs, request$, maxPendingRequests, onEvent }: WorkerRotatorOptions<R, W>): rxjs.Observable<never>;
|
package/dist/worker-rotator.js
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import * as rxjs from "rxjs";
|
|
2
|
-
/**
|
|
3
|
-
* Rotates workers over a request stream.
|
|
4
|
-
* See the README for the lifecycle and buffering contract.
|
|
5
|
-
*/
|
|
6
|
-
export function makeWorkerRotator({ makeWorker, workerTtlMs, request$, maxPendingRequests = Infinity, onEvent }) {
|
|
7
|
-
return rxjs.defer(() => {
|
|
8
|
-
if (maxPendingRequests !== Infinity && (!Number.isInteger(maxPendingRequests) || maxPendingRequests < 0)) {
|
|
9
|
-
throw new RangeError('maxPendingRequests must be a non-negative integer or Infinity');
|
|
10
|
-
}
|
|
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(() => {
|
|
12
|
-
worker.relieve();
|
|
13
|
-
onEvent?.({ type: 'relieved', worker });
|
|
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
|
|
15
|
-
? request$ => worker.process(request$).pipe(rxjs.startWith([]))
|
|
16
|
-
: request$ => request$.pipe(bufferRequests(maxPendingRequests))), []), rxjs.ignoreElements()));
|
|
17
|
-
});
|
|
18
|
-
}
|
|
19
|
-
function bufferRequests(maxPendingRequests) {
|
|
20
|
-
return request$ => request$.pipe(rxjs.reduce((pending, request) => {
|
|
21
|
-
if (pending.length >= maxPendingRequests) {
|
|
22
|
-
throw new Error(`Worker rotator exceeded max pending requests (${maxPendingRequests})`);
|
|
23
|
-
}
|
|
24
|
-
pending.push(request);
|
|
25
|
-
return pending;
|
|
26
|
-
}, []));
|
|
27
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
import { describe } from "@service-broker/test-utils";
|
|
2
|
-
import assert from "assert";
|
|
3
|
-
import * as rxjs from "rxjs";
|
|
4
|
-
import { makeWorkerRotator } from "./worker-rotator.js";
|
|
5
|
-
describe('worker-rotator', ({ test }) => {
|
|
6
|
-
test('buffers requests before the first worker is ready', async () => {
|
|
7
|
-
const request$ = new rxjs.Subject;
|
|
8
|
-
const worker = makeTestWorker();
|
|
9
|
-
const workerReady = defer();
|
|
10
|
-
const processed = [];
|
|
11
|
-
worker.processRequests$.subscribe(request => processed.push(request));
|
|
12
|
-
const subscription = makeWorkerRotator({
|
|
13
|
-
makeWorker: () => workerReady.promise,
|
|
14
|
-
workerTtlMs: 1000,
|
|
15
|
-
request$
|
|
16
|
-
}).subscribe();
|
|
17
|
-
try {
|
|
18
|
-
request$.next(1);
|
|
19
|
-
request$.next(2);
|
|
20
|
-
workerReady.resolve(worker);
|
|
21
|
-
await waitFor(() => processed.length == 2);
|
|
22
|
-
assert.deepStrictEqual(processed, [1, 2]);
|
|
23
|
-
}
|
|
24
|
-
finally {
|
|
25
|
-
subscription.unsubscribe();
|
|
26
|
-
}
|
|
27
|
-
});
|
|
28
|
-
test('buffers requests between workers', async () => {
|
|
29
|
-
const request$ = new rxjs.Subject;
|
|
30
|
-
const firstWorker = makeTestWorker();
|
|
31
|
-
const secondWorker = makeTestWorker();
|
|
32
|
-
const firstWorkerReady = defer();
|
|
33
|
-
const secondWorkerReady = defer();
|
|
34
|
-
const workersReady = [firstWorkerReady, secondWorkerReady];
|
|
35
|
-
const processed = [];
|
|
36
|
-
firstWorker.processRequests$.subscribe(request => processed.push(request));
|
|
37
|
-
secondWorker.processRequests$.subscribe(request => processed.push(request));
|
|
38
|
-
const subscription = makeWorkerRotator({
|
|
39
|
-
makeWorker: () => workersReady.shift().promise,
|
|
40
|
-
workerTtlMs: 1000,
|
|
41
|
-
request$
|
|
42
|
-
}).subscribe();
|
|
43
|
-
try {
|
|
44
|
-
firstWorkerReady.resolve(firstWorker);
|
|
45
|
-
await waitFor(() => firstWorker.processCalls == 1);
|
|
46
|
-
request$.next(1);
|
|
47
|
-
firstWorker.quit$.next('done');
|
|
48
|
-
request$.next(2);
|
|
49
|
-
secondWorkerReady.resolve(secondWorker);
|
|
50
|
-
await waitFor(() => processed.length == 2);
|
|
51
|
-
assert.deepStrictEqual(processed, [1, 2]);
|
|
52
|
-
assert(firstWorker.relieved);
|
|
53
|
-
}
|
|
54
|
-
finally {
|
|
55
|
-
subscription.unsubscribe();
|
|
56
|
-
}
|
|
57
|
-
});
|
|
58
|
-
test('errors when pending request cap is exceeded', async () => {
|
|
59
|
-
const request$ = new rxjs.Subject;
|
|
60
|
-
const workerReady = defer();
|
|
61
|
-
const error = defer();
|
|
62
|
-
makeWorkerRotator({
|
|
63
|
-
makeWorker: () => workerReady.promise,
|
|
64
|
-
workerTtlMs: 1000,
|
|
65
|
-
request$,
|
|
66
|
-
maxPendingRequests: 1
|
|
67
|
-
}).subscribe({
|
|
68
|
-
error: err => error.resolve(err)
|
|
69
|
-
});
|
|
70
|
-
request$.next(1);
|
|
71
|
-
request$.next(2);
|
|
72
|
-
const err = await error.promise;
|
|
73
|
-
assert(err instanceof Error);
|
|
74
|
-
assert.equal(err.message, 'Worker rotator exceeded max pending requests (1)');
|
|
75
|
-
});
|
|
76
|
-
test('reports lifecycle events', async () => {
|
|
77
|
-
const request$ = new rxjs.Subject;
|
|
78
|
-
const worker = makeTestWorker();
|
|
79
|
-
const events = [];
|
|
80
|
-
const subscription = makeWorkerRotator({
|
|
81
|
-
makeWorker: async () => worker,
|
|
82
|
-
workerTtlMs: 1000,
|
|
83
|
-
request$,
|
|
84
|
-
onEvent: event => events.push(event)
|
|
85
|
-
}).subscribe();
|
|
86
|
-
try {
|
|
87
|
-
await waitFor(() => events.length >= 1);
|
|
88
|
-
worker.quit$.next('done');
|
|
89
|
-
await waitFor(() => events.length >= 3);
|
|
90
|
-
assert.deepStrictEqual(events.slice(0, 3).map(event => event.type), ['hired', 'quit', 'relieved']);
|
|
91
|
-
assert.equal(events[1].type == 'quit' && events[1].reason, 'done');
|
|
92
|
-
}
|
|
93
|
-
finally {
|
|
94
|
-
subscription.unsubscribe();
|
|
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
|
-
});
|
|
116
|
-
});
|
|
117
|
-
function makeTestWorker() {
|
|
118
|
-
const processRequests$ = new rxjs.Subject;
|
|
119
|
-
return {
|
|
120
|
-
processCalls: 0,
|
|
121
|
-
processRequests$,
|
|
122
|
-
quit$: new rxjs.Subject(),
|
|
123
|
-
relieved: false,
|
|
124
|
-
process(request$) {
|
|
125
|
-
this.processCalls++;
|
|
126
|
-
return request$.pipe(rxjs.tap(request => processRequests$.next(request)), rxjs.ignoreElements());
|
|
127
|
-
},
|
|
128
|
-
relieve() {
|
|
129
|
-
this.relieved = true;
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
function defer() {
|
|
134
|
-
let resolve;
|
|
135
|
-
let reject;
|
|
136
|
-
const promise = new Promise((resolvePromise, rejectPromise) => {
|
|
137
|
-
resolve = resolvePromise;
|
|
138
|
-
reject = rejectPromise;
|
|
139
|
-
});
|
|
140
|
-
return { promise, resolve, reject };
|
|
141
|
-
}
|
|
142
|
-
async function waitFor(condition) {
|
|
143
|
-
for (let i = 0; i < 100; i++) {
|
|
144
|
-
if (condition())
|
|
145
|
-
return;
|
|
146
|
-
await new Promise(resolve => setTimeout(resolve, 1));
|
|
147
|
-
}
|
|
148
|
-
assert(condition());
|
|
149
|
-
}
|