orbit-bus 0.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/CHANGELOG.md +36 -0
- package/LICENSE +21 -0
- package/README.md +501 -0
- package/dist/src/agent_ipc.d.ts +4 -0
- package/dist/src/agent_ipc.js +77 -0
- package/dist/src/api_contract.d.ts +19 -0
- package/dist/src/api_contract.js +81 -0
- package/dist/src/api_http.d.ts +23 -0
- package/dist/src/api_http.js +62 -0
- package/dist/src/call_protection.d.ts +5 -0
- package/dist/src/call_protection.js +83 -0
- package/dist/src/cell/gateway.d.ts +13 -0
- package/dist/src/cell/gateway.js +171 -0
- package/dist/src/cell/routing.d.ts +18 -0
- package/dist/src/cell/routing.js +48 -0
- package/dist/src/cell/template.d.ts +7 -0
- package/dist/src/cell/template.js +24 -0
- package/dist/src/cli.d.ts +1 -0
- package/dist/src/cli.js +305 -0
- package/dist/src/commands/agent.d.ts +3 -0
- package/dist/src/commands/agent.js +187 -0
- package/dist/src/commands/api.d.ts +6 -0
- package/dist/src/commands/api.js +226 -0
- package/dist/src/commands/bench.d.ts +13 -0
- package/dist/src/commands/bench.js +125 -0
- package/dist/src/commands/bench_overhead.d.ts +8 -0
- package/dist/src/commands/bench_overhead.js +71 -0
- package/dist/src/commands/call.d.ts +10 -0
- package/dist/src/commands/call.js +45 -0
- package/dist/src/commands/cell.d.ts +3 -0
- package/dist/src/commands/cell.js +186 -0
- package/dist/src/commands/context.d.ts +9 -0
- package/dist/src/commands/context.js +71 -0
- package/dist/src/commands/dlq_inspect.d.ts +11 -0
- package/dist/src/commands/dlq_inspect.js +86 -0
- package/dist/src/commands/dlq_purge.d.ts +12 -0
- package/dist/src/commands/dlq_purge.js +84 -0
- package/dist/src/commands/dlq_replay.d.ts +15 -0
- package/dist/src/commands/dlq_replay.js +127 -0
- package/dist/src/commands/inspect.d.ts +6 -0
- package/dist/src/commands/inspect.js +57 -0
- package/dist/src/commands/monitor.d.ts +32 -0
- package/dist/src/commands/monitor.js +201 -0
- package/dist/src/commands/publish.d.ts +10 -0
- package/dist/src/commands/publish.js +64 -0
- package/dist/src/commands/serve.d.ts +8 -0
- package/dist/src/commands/serve.js +258 -0
- package/dist/src/commands/subscribe.d.ts +11 -0
- package/dist/src/commands/subscribe.js +78 -0
- package/dist/src/commands/trace.d.ts +5 -0
- package/dist/src/commands/trace.js +26 -0
- package/dist/src/commands/up.d.ts +3 -0
- package/dist/src/commands/up.js +91 -0
- package/dist/src/config.d.ts +6 -0
- package/dist/src/config.js +281 -0
- package/dist/src/dlq.d.ts +20 -0
- package/dist/src/dlq.js +71 -0
- package/dist/src/echo/benchmark.d.ts +10 -0
- package/dist/src/echo/benchmark.js +105 -0
- package/dist/src/echo/bus.d.ts +22 -0
- package/dist/src/echo/bus.js +89 -0
- package/dist/src/echo/cli.d.ts +1 -0
- package/dist/src/echo/cli.js +135 -0
- package/dist/src/echo/client.d.ts +12 -0
- package/dist/src/echo/client.js +46 -0
- package/dist/src/echo/daemon.d.ts +8 -0
- package/dist/src/echo/daemon.js +181 -0
- package/dist/src/echo/index.d.ts +6 -0
- package/dist/src/echo/index.js +5 -0
- package/dist/src/echo/ring_buffer.d.ts +27 -0
- package/dist/src/echo/ring_buffer.js +73 -0
- package/dist/src/echo/types.d.ts +27 -0
- package/dist/src/echo/types.js +1 -0
- package/dist/src/echocore.d.ts +2 -0
- package/dist/src/echocore.js +6 -0
- package/dist/src/envelope.d.ts +14 -0
- package/dist/src/envelope.js +92 -0
- package/dist/src/errors.d.ts +5 -0
- package/dist/src/errors.js +9 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +12 -0
- package/dist/src/jetstream_durable.d.ts +4 -0
- package/dist/src/jetstream_durable.js +51 -0
- package/dist/src/json_schema.d.ts +6 -0
- package/dist/src/json_schema.js +154 -0
- package/dist/src/logger.d.ts +16 -0
- package/dist/src/logger.js +31 -0
- package/dist/src/metrics.d.ts +6 -0
- package/dist/src/metrics.js +95 -0
- package/dist/src/nats.d.ts +16 -0
- package/dist/src/nats.js +129 -0
- package/dist/src/orbit_actions.d.ts +4 -0
- package/dist/src/orbit_actions.js +129 -0
- package/dist/src/otel.d.ts +2 -0
- package/dist/src/otel.js +96 -0
- package/dist/src/registry.d.ts +10 -0
- package/dist/src/registry.js +57 -0
- package/dist/src/retry.d.ts +9 -0
- package/dist/src/retry.js +32 -0
- package/dist/src/rpc_call.d.ts +22 -0
- package/dist/src/rpc_call.js +119 -0
- package/dist/src/service_adapter.d.ts +7 -0
- package/dist/src/service_adapter.js +163 -0
- package/dist/src/spec.d.ts +2 -0
- package/dist/src/spec.js +30 -0
- package/dist/src/subjects.d.ts +2 -0
- package/dist/src/subjects.js +4 -0
- package/dist/src/trace.d.ts +4 -0
- package/dist/src/trace.js +86 -0
- package/dist/src/types.d.ts +133 -0
- package/dist/src/types.js +1 -0
- package/dist/src/util.d.ts +10 -0
- package/dist/src/util.js +63 -0
- package/dist/src/worker_pool.d.ts +9 -0
- package/dist/src/worker_pool.js +163 -0
- package/docs/orbit-api-contract.yaml +376 -0
- package/package.json +40 -0
package/dist/src/spec.js
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { OrbitError } from "./errors.js";
|
|
2
|
+
import { readJsonFile } from "./util.js";
|
|
3
|
+
export function loadServiceSpec(specPath) {
|
|
4
|
+
const spec = readJsonFile(specPath);
|
|
5
|
+
if (!spec || typeof spec !== "object" || !spec.methods || typeof spec.methods !== "object") {
|
|
6
|
+
throw new OrbitError("INVALID_SPEC", "spec must include methods object");
|
|
7
|
+
}
|
|
8
|
+
if (typeof spec.version === "string" && spec.version.trim().length === 0) {
|
|
9
|
+
throw new OrbitError("INVALID_SPEC", "spec version cannot be empty");
|
|
10
|
+
}
|
|
11
|
+
for (const [method, conf] of Object.entries(spec.methods)) {
|
|
12
|
+
const transport = conf.transport ?? "worker";
|
|
13
|
+
if (conf.request_schema !== undefined && (!conf.request_schema || typeof conf.request_schema !== "object" || Array.isArray(conf.request_schema))) {
|
|
14
|
+
throw new OrbitError("INVALID_SPEC", `method ${method} request_schema must be an object`);
|
|
15
|
+
}
|
|
16
|
+
if (conf.response_schema !== undefined && (!conf.response_schema || typeof conf.response_schema !== "object" || Array.isArray(conf.response_schema))) {
|
|
17
|
+
throw new OrbitError("INVALID_SPEC", `method ${method} response_schema must be an object`);
|
|
18
|
+
}
|
|
19
|
+
if (transport === "http") {
|
|
20
|
+
if (!conf.http_endpoint || typeof conf.http_endpoint !== "string") {
|
|
21
|
+
throw new OrbitError("INVALID_SPEC", `method ${method} missing http_endpoint for http transport`);
|
|
22
|
+
}
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
if (!conf.command || typeof conf.command !== "string") {
|
|
26
|
+
throw new OrbitError("INVALID_SPEC", `method ${method} missing command`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return spec;
|
|
30
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import { OrbitConfig, TraceEvent } from "./types.js";
|
|
2
|
+
export declare function flushTraceWritesSync(): void;
|
|
3
|
+
export declare function appendTraceEvent(config: OrbitConfig, event: TraceEvent): void;
|
|
4
|
+
export declare function readTraceTimeline(config: OrbitConfig, runId: string): TraceEvent[];
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ensureDir } from "./util.js";
|
|
4
|
+
import { exportTraceEvent } from "./otel.js";
|
|
5
|
+
const pendingWrites = new Map();
|
|
6
|
+
let flushScheduled = false;
|
|
7
|
+
let exitHooksInstalled = false;
|
|
8
|
+
let traceDirEnsured = false;
|
|
9
|
+
let pendingEventCount = 0;
|
|
10
|
+
function flushPendingAsync() {
|
|
11
|
+
flushScheduled = false;
|
|
12
|
+
for (const [filePath, lines] of pendingWrites.entries()) {
|
|
13
|
+
if (lines.length === 0)
|
|
14
|
+
continue;
|
|
15
|
+
pendingEventCount -= lines.length;
|
|
16
|
+
if (pendingEventCount < 0)
|
|
17
|
+
pendingEventCount = 0;
|
|
18
|
+
pendingWrites.set(filePath, []);
|
|
19
|
+
const chunk = lines.join("");
|
|
20
|
+
fs.promises.appendFile(filePath, chunk, "utf-8").catch(() => {
|
|
21
|
+
// best effort trace write; tracing should never break request flow
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function flushTraceWritesSync() {
|
|
26
|
+
flushScheduled = false;
|
|
27
|
+
for (const [filePath, lines] of pendingWrites.entries()) {
|
|
28
|
+
if (lines.length === 0)
|
|
29
|
+
continue;
|
|
30
|
+
pendingEventCount -= lines.length;
|
|
31
|
+
if (pendingEventCount < 0)
|
|
32
|
+
pendingEventCount = 0;
|
|
33
|
+
pendingWrites.set(filePath, []);
|
|
34
|
+
fs.appendFileSync(filePath, lines.join(""), "utf-8");
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
function scheduleFlush(delayMs) {
|
|
38
|
+
if (flushScheduled)
|
|
39
|
+
return;
|
|
40
|
+
flushScheduled = true;
|
|
41
|
+
const timer = setTimeout(flushPendingAsync, delayMs);
|
|
42
|
+
timer.unref?.();
|
|
43
|
+
}
|
|
44
|
+
function installExitHooks() {
|
|
45
|
+
if (exitHooksInstalled)
|
|
46
|
+
return;
|
|
47
|
+
exitHooksInstalled = true;
|
|
48
|
+
process.once("beforeExit", flushTraceWritesSync);
|
|
49
|
+
process.once("exit", flushTraceWritesSync);
|
|
50
|
+
}
|
|
51
|
+
export function appendTraceEvent(config, event) {
|
|
52
|
+
if (config.performance.traceSampleRate <= 0)
|
|
53
|
+
return;
|
|
54
|
+
if (config.performance.traceSampleRate < 1 && Math.random() > config.performance.traceSampleRate)
|
|
55
|
+
return;
|
|
56
|
+
installExitHooks();
|
|
57
|
+
if (!traceDirEnsured) {
|
|
58
|
+
ensureDir(config.traceDir);
|
|
59
|
+
traceDirEnsured = true;
|
|
60
|
+
}
|
|
61
|
+
if (pendingEventCount >= config.performance.traceBufferMaxEvents) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const filePath = path.join(config.traceDir, `${event.run_id}.jsonl`);
|
|
65
|
+
const existing = pendingWrites.get(filePath) ?? [];
|
|
66
|
+
existing.push(`${JSON.stringify(event)}\n`);
|
|
67
|
+
pendingEventCount += 1;
|
|
68
|
+
pendingWrites.set(filePath, existing);
|
|
69
|
+
if (existing.length >= 32) {
|
|
70
|
+
flushPendingAsync();
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
scheduleFlush(config.performance.traceFlushIntervalMs);
|
|
74
|
+
}
|
|
75
|
+
void exportTraceEvent(config, event);
|
|
76
|
+
}
|
|
77
|
+
export function readTraceTimeline(config, runId) {
|
|
78
|
+
flushTraceWritesSync();
|
|
79
|
+
const filePath = path.join(config.traceDir, `${runId}.jsonl`);
|
|
80
|
+
if (!fs.existsSync(filePath))
|
|
81
|
+
return [];
|
|
82
|
+
const lines = fs.readFileSync(filePath, "utf-8").split("\n").filter(Boolean);
|
|
83
|
+
return lines
|
|
84
|
+
.map((line) => JSON.parse(line))
|
|
85
|
+
.sort((a, b) => Date.parse(a.ts) - Date.parse(b.ts));
|
|
86
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
export type EnvelopeKind = "request" | "response" | "event" | "capability" | "trace";
|
|
2
|
+
export interface Envelope {
|
|
3
|
+
id: string;
|
|
4
|
+
run_id: string;
|
|
5
|
+
ts: string;
|
|
6
|
+
kind: EnvelopeKind;
|
|
7
|
+
schema_version: string;
|
|
8
|
+
payload: unknown;
|
|
9
|
+
data_pack?: {
|
|
10
|
+
bucket: string;
|
|
11
|
+
key: string;
|
|
12
|
+
bytes?: number;
|
|
13
|
+
content_type?: string;
|
|
14
|
+
};
|
|
15
|
+
provenance?: Record<string, unknown>;
|
|
16
|
+
cost?: Record<string, number>;
|
|
17
|
+
a2a?: {
|
|
18
|
+
task_id?: string;
|
|
19
|
+
thread_id?: string;
|
|
20
|
+
parent_message_id?: string;
|
|
21
|
+
capabilities?: string[];
|
|
22
|
+
traceparent?: string;
|
|
23
|
+
dedupe_key?: string;
|
|
24
|
+
};
|
|
25
|
+
hash: string;
|
|
26
|
+
}
|
|
27
|
+
export interface ServiceMethodSpec {
|
|
28
|
+
description?: string;
|
|
29
|
+
request_schema?: Record<string, unknown>;
|
|
30
|
+
response_schema?: Record<string, unknown>;
|
|
31
|
+
command?: string;
|
|
32
|
+
args?: string[];
|
|
33
|
+
timeout_ms?: number;
|
|
34
|
+
transport?: "spawn" | "worker" | "http";
|
|
35
|
+
http_method?: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
36
|
+
http_endpoint?: string;
|
|
37
|
+
headers?: Record<string, string>;
|
|
38
|
+
}
|
|
39
|
+
export interface ServiceSpec {
|
|
40
|
+
service?: string;
|
|
41
|
+
version?: string;
|
|
42
|
+
description?: string;
|
|
43
|
+
methods: Record<string, ServiceMethodSpec>;
|
|
44
|
+
}
|
|
45
|
+
export interface OrbitConfig {
|
|
46
|
+
natsUrl: string;
|
|
47
|
+
requestTimeoutMs: number;
|
|
48
|
+
retries: number;
|
|
49
|
+
logLevel: "debug" | "info" | "warn" | "error";
|
|
50
|
+
dataDir: string;
|
|
51
|
+
traceDir: string;
|
|
52
|
+
servicesDir: string;
|
|
53
|
+
activeContext: string;
|
|
54
|
+
kvBucket: string;
|
|
55
|
+
objectStoreBucket: string;
|
|
56
|
+
otel: {
|
|
57
|
+
endpoint?: string;
|
|
58
|
+
serviceName: string;
|
|
59
|
+
};
|
|
60
|
+
performance: {
|
|
61
|
+
mode: "balanced" | "hyper";
|
|
62
|
+
traceSampleRate: number;
|
|
63
|
+
trustedLocal: boolean;
|
|
64
|
+
traceBufferMaxEvents: number;
|
|
65
|
+
traceFlushIntervalMs: number;
|
|
66
|
+
};
|
|
67
|
+
routing: {
|
|
68
|
+
subjectPrefix: string;
|
|
69
|
+
};
|
|
70
|
+
runtime: {
|
|
71
|
+
serveMaxInflightGlobal: number;
|
|
72
|
+
serveMaxInflightPerMethod: number;
|
|
73
|
+
serveMaxQueueDepth: number;
|
|
74
|
+
workerPoolSize: number;
|
|
75
|
+
workerMaxPendingPerWorker: number;
|
|
76
|
+
apiMaxConcurrent: number;
|
|
77
|
+
apiMaxBodyBytes: number;
|
|
78
|
+
apiRequestTimeoutMs: number;
|
|
79
|
+
agentMaxConcurrent: number;
|
|
80
|
+
agentMaxRequestBytes: number;
|
|
81
|
+
publishDurableEnabled: boolean;
|
|
82
|
+
publishDurableTimeoutMs: number;
|
|
83
|
+
callRateLimitPerSec: number;
|
|
84
|
+
circuitBreakerFailureThreshold: number;
|
|
85
|
+
circuitBreakerCooldownMs: number;
|
|
86
|
+
circuitBreakerHalfOpenMax: number;
|
|
87
|
+
monitorMaxParallel: number;
|
|
88
|
+
monitorJitterMs: number;
|
|
89
|
+
monitorDownBackoffFactor: number;
|
|
90
|
+
monitorDownBackoffMaxMs: number;
|
|
91
|
+
};
|
|
92
|
+
api: {
|
|
93
|
+
authToken?: string;
|
|
94
|
+
allowedHosts: string[];
|
|
95
|
+
tls: {
|
|
96
|
+
enabled: boolean;
|
|
97
|
+
certFile?: string;
|
|
98
|
+
keyFile?: string;
|
|
99
|
+
caFile?: string;
|
|
100
|
+
requestClientCert: boolean;
|
|
101
|
+
requireClientCert: boolean;
|
|
102
|
+
};
|
|
103
|
+
};
|
|
104
|
+
agent: {
|
|
105
|
+
enabled: boolean;
|
|
106
|
+
socketPath: string;
|
|
107
|
+
};
|
|
108
|
+
broker: {
|
|
109
|
+
host: string;
|
|
110
|
+
port: number;
|
|
111
|
+
dockerImage: string;
|
|
112
|
+
containerName: string;
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
export interface TraceEvent {
|
|
116
|
+
span_id: string;
|
|
117
|
+
parent_span_id?: string;
|
|
118
|
+
run_id: string;
|
|
119
|
+
ts: string;
|
|
120
|
+
actor: string;
|
|
121
|
+
event: string;
|
|
122
|
+
svc?: string;
|
|
123
|
+
method?: string;
|
|
124
|
+
latency_ms?: number;
|
|
125
|
+
retry?: number;
|
|
126
|
+
error_code?: string;
|
|
127
|
+
detail?: string;
|
|
128
|
+
}
|
|
129
|
+
export interface OrbitContext {
|
|
130
|
+
natsUrl: string;
|
|
131
|
+
requestTimeoutMs: number;
|
|
132
|
+
retries: number;
|
|
133
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export declare function randomId(): string;
|
|
2
|
+
export declare function sha256(input: string): string;
|
|
3
|
+
export declare function stableStringify(value: unknown): string;
|
|
4
|
+
export declare function expandHome(p: string): string;
|
|
5
|
+
export declare function ensureDir(p: string): void;
|
|
6
|
+
export declare function readJsonFile<T>(filePath: string): T;
|
|
7
|
+
export declare function writeJsonFile(filePath: string, value: unknown): void;
|
|
8
|
+
export declare function parseJsonInput(arg: string): unknown;
|
|
9
|
+
export declare function argValue(args: string[], flag: string): string | undefined;
|
|
10
|
+
export declare function hasFlag(args: string[], flag: string): boolean;
|
package/dist/src/util.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import os from "node:os";
|
|
5
|
+
import { OrbitError } from "./errors.js";
|
|
6
|
+
export function randomId() {
|
|
7
|
+
return crypto.randomUUID();
|
|
8
|
+
}
|
|
9
|
+
export function sha256(input) {
|
|
10
|
+
return crypto.createHash("sha256").update(input).digest("hex");
|
|
11
|
+
}
|
|
12
|
+
export function stableStringify(value) {
|
|
13
|
+
if (Array.isArray(value)) {
|
|
14
|
+
return `[${value.map(stableStringify).join(",")}]`;
|
|
15
|
+
}
|
|
16
|
+
if (value && typeof value === "object") {
|
|
17
|
+
const rec = value;
|
|
18
|
+
const keys = Object.keys(rec).sort();
|
|
19
|
+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(rec[k])}`).join(",")}}`;
|
|
20
|
+
}
|
|
21
|
+
return JSON.stringify(value);
|
|
22
|
+
}
|
|
23
|
+
export function expandHome(p) {
|
|
24
|
+
if (p.startsWith("~/"))
|
|
25
|
+
return path.join(os.homedir(), p.slice(2));
|
|
26
|
+
return p;
|
|
27
|
+
}
|
|
28
|
+
export function ensureDir(p) {
|
|
29
|
+
fs.mkdirSync(p, { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
export function readJsonFile(filePath) {
|
|
32
|
+
const text = fs.readFileSync(filePath, "utf-8");
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(text);
|
|
35
|
+
}
|
|
36
|
+
catch (err) {
|
|
37
|
+
throw new OrbitError("BAD_JSON", `Invalid JSON in ${filePath}`, { err });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function writeJsonFile(filePath, value) {
|
|
41
|
+
ensureDir(path.dirname(filePath));
|
|
42
|
+
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf-8");
|
|
43
|
+
}
|
|
44
|
+
export function parseJsonInput(arg) {
|
|
45
|
+
try {
|
|
46
|
+
if (arg.startsWith("@")) {
|
|
47
|
+
return readJsonFile(arg.slice(1));
|
|
48
|
+
}
|
|
49
|
+
return JSON.parse(arg);
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
throw new OrbitError("BAD_JSON_INPUT", `Failed to parse JSON from ${arg}`, { err });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
export function argValue(args, flag) {
|
|
56
|
+
const i = args.indexOf(flag);
|
|
57
|
+
if (i >= 0 && i + 1 < args.length)
|
|
58
|
+
return args[i + 1];
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
export function hasFlag(args, flag) {
|
|
62
|
+
return args.includes(flag);
|
|
63
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface WorkerExecutionOptions {
|
|
2
|
+
poolSize: number;
|
|
3
|
+
maxPendingPerWorker: number;
|
|
4
|
+
}
|
|
5
|
+
export declare class WorkerPool {
|
|
6
|
+
private readonly groups;
|
|
7
|
+
execute(command: string, args: string[], payload: unknown, timeoutMs: number, options: WorkerExecutionOptions): Promise<unknown>;
|
|
8
|
+
}
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import readline from "node:readline";
|
|
3
|
+
import { OrbitError } from "./errors.js";
|
|
4
|
+
import { randomId } from "./util.js";
|
|
5
|
+
class PersistentWorker {
|
|
6
|
+
command;
|
|
7
|
+
args;
|
|
8
|
+
child = null;
|
|
9
|
+
lines = null;
|
|
10
|
+
pending = new Map();
|
|
11
|
+
stderrTail = [];
|
|
12
|
+
idleTimer = null;
|
|
13
|
+
constructor(command, args) {
|
|
14
|
+
this.command = command;
|
|
15
|
+
this.args = args;
|
|
16
|
+
}
|
|
17
|
+
get pendingCount() {
|
|
18
|
+
return this.pending.size;
|
|
19
|
+
}
|
|
20
|
+
ensureStarted() {
|
|
21
|
+
if (this.child && !this.child.killed)
|
|
22
|
+
return this.child;
|
|
23
|
+
const child = spawn(this.command, this.args, { stdio: ["pipe", "pipe", "pipe"] });
|
|
24
|
+
this.child = child;
|
|
25
|
+
this.clearIdleTimer();
|
|
26
|
+
this.lines = readline.createInterface({ input: child.stdout });
|
|
27
|
+
this.lines.on("line", (line) => {
|
|
28
|
+
if (!line.trim())
|
|
29
|
+
return;
|
|
30
|
+
let parsed;
|
|
31
|
+
try {
|
|
32
|
+
parsed = JSON.parse(line);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
if (!parsed.id)
|
|
38
|
+
return;
|
|
39
|
+
const pending = this.pending.get(parsed.id);
|
|
40
|
+
if (!pending)
|
|
41
|
+
return;
|
|
42
|
+
this.pending.delete(parsed.id);
|
|
43
|
+
clearTimeout(pending.timer);
|
|
44
|
+
if (parsed.ok === false) {
|
|
45
|
+
const msg = typeof parsed.error === "string" ? parsed.error : parsed.error?.message ?? "worker returned error";
|
|
46
|
+
pending.reject(new OrbitError(typeof parsed.error === "string" ? "WORKER_ERROR" : (parsed.error?.code ?? "WORKER_ERROR"), msg));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
pending.resolve(parsed.result);
|
|
50
|
+
});
|
|
51
|
+
child.stderr.on("data", (chunk) => {
|
|
52
|
+
const text = String(chunk).trim();
|
|
53
|
+
if (!text)
|
|
54
|
+
return;
|
|
55
|
+
this.stderrTail.push(text);
|
|
56
|
+
if (this.stderrTail.length > 20)
|
|
57
|
+
this.stderrTail.shift();
|
|
58
|
+
});
|
|
59
|
+
child.on("exit", () => {
|
|
60
|
+
this.child = null;
|
|
61
|
+
this.lines?.close();
|
|
62
|
+
this.lines = null;
|
|
63
|
+
this.clearIdleTimer();
|
|
64
|
+
const err = new OrbitError("WORKER_EXITED", `worker exited unexpectedly: ${this.command}`, {
|
|
65
|
+
command: this.command,
|
|
66
|
+
args: this.args,
|
|
67
|
+
stderr: this.stderrTail.join("\n")
|
|
68
|
+
});
|
|
69
|
+
for (const [id, pending] of this.pending.entries()) {
|
|
70
|
+
this.pending.delete(id);
|
|
71
|
+
clearTimeout(pending.timer);
|
|
72
|
+
pending.reject(err);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
child.on("error", (err) => {
|
|
76
|
+
const wrapped = new OrbitError("WORKER_SPAWN_ERROR", "failed to spawn persistent worker", {
|
|
77
|
+
command: this.command,
|
|
78
|
+
args: this.args,
|
|
79
|
+
err
|
|
80
|
+
});
|
|
81
|
+
for (const [id, pending] of this.pending.entries()) {
|
|
82
|
+
this.pending.delete(id);
|
|
83
|
+
clearTimeout(pending.timer);
|
|
84
|
+
pending.reject(wrapped);
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
return child;
|
|
88
|
+
}
|
|
89
|
+
async request(payload, timeoutMs) {
|
|
90
|
+
const child = this.ensureStarted();
|
|
91
|
+
const requestId = randomId();
|
|
92
|
+
return new Promise((resolve, reject) => {
|
|
93
|
+
const timer = setTimeout(() => {
|
|
94
|
+
this.pending.delete(requestId);
|
|
95
|
+
this.scheduleIdleShutdown();
|
|
96
|
+
reject(new OrbitError("WORKER_TIMEOUT", `worker request timed out after ${timeoutMs}ms`));
|
|
97
|
+
}, timeoutMs);
|
|
98
|
+
timer.unref?.();
|
|
99
|
+
this.pending.set(requestId, { resolve, reject, timer });
|
|
100
|
+
const row = JSON.stringify({ id: requestId, payload });
|
|
101
|
+
child.stdin.write(`${row}\n`, "utf-8", (err) => {
|
|
102
|
+
if (!err)
|
|
103
|
+
return;
|
|
104
|
+
this.pending.delete(requestId);
|
|
105
|
+
clearTimeout(timer);
|
|
106
|
+
this.scheduleIdleShutdown();
|
|
107
|
+
reject(new OrbitError("WORKER_WRITE_ERROR", "failed to write request to worker stdin", { err }));
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
onSettledRequest() {
|
|
112
|
+
if (this.pending.size > 0)
|
|
113
|
+
return;
|
|
114
|
+
this.scheduleIdleShutdown();
|
|
115
|
+
}
|
|
116
|
+
scheduleIdleShutdown() {
|
|
117
|
+
this.clearIdleTimer();
|
|
118
|
+
this.idleTimer = setTimeout(() => {
|
|
119
|
+
if (this.pending.size > 0)
|
|
120
|
+
return;
|
|
121
|
+
if (!this.child || this.child.killed)
|
|
122
|
+
return;
|
|
123
|
+
this.child.kill("SIGTERM");
|
|
124
|
+
}, 1000);
|
|
125
|
+
this.idleTimer.unref?.();
|
|
126
|
+
}
|
|
127
|
+
clearIdleTimer() {
|
|
128
|
+
if (!this.idleTimer)
|
|
129
|
+
return;
|
|
130
|
+
clearTimeout(this.idleTimer);
|
|
131
|
+
this.idleTimer = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
export class WorkerPool {
|
|
135
|
+
groups = new Map();
|
|
136
|
+
async execute(command, args, payload, timeoutMs, options) {
|
|
137
|
+
const key = JSON.stringify({ command, args });
|
|
138
|
+
let group = this.groups.get(key);
|
|
139
|
+
if (!group) {
|
|
140
|
+
group = {
|
|
141
|
+
workers: Array.from({ length: options.poolSize }, () => new PersistentWorker(command, args)),
|
|
142
|
+
maxPendingPerWorker: options.maxPendingPerWorker
|
|
143
|
+
};
|
|
144
|
+
this.groups.set(key, group);
|
|
145
|
+
}
|
|
146
|
+
const sorted = [...group.workers].sort((a, b) => a.pendingCount - b.pendingCount);
|
|
147
|
+
const selected = sorted.find((w) => w.pendingCount < group.maxPendingPerWorker);
|
|
148
|
+
if (!selected) {
|
|
149
|
+
throw new OrbitError("WORKER_OVERLOADED", "all workers at max pending capacity", {
|
|
150
|
+
command,
|
|
151
|
+
args,
|
|
152
|
+
pool_size: group.workers.length,
|
|
153
|
+
max_pending_per_worker: group.maxPendingPerWorker
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
try {
|
|
157
|
+
return await selected.request(payload, timeoutMs);
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
selected.onSettledRequest();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|