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
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createEnvelope, validateEnvelope } from "./envelope.js";
|
|
3
|
+
import { OrbitError } from "./errors.js";
|
|
4
|
+
import { decodeJson, encodeJson, osPut, publishSubject } from "./nats.js";
|
|
5
|
+
import { loadServiceRecordDistributed } from "./registry.js";
|
|
6
|
+
import { randomId } from "./util.js";
|
|
7
|
+
import { validateActionPayload } from "./api_contract.js";
|
|
8
|
+
import { executeRpcCall } from "./rpc_call.js";
|
|
9
|
+
import { prefixedSubject } from "./subjects.js";
|
|
10
|
+
export async function executeOrbitAction(config, nc, action, payload, actor) {
|
|
11
|
+
validateActionPayload(action, payload);
|
|
12
|
+
switch (action) {
|
|
13
|
+
case "ping":
|
|
14
|
+
return { ok: true, now: new Date().toISOString() };
|
|
15
|
+
case "call":
|
|
16
|
+
return executeCall(config, nc, payload, actor);
|
|
17
|
+
case "publish":
|
|
18
|
+
return executePublish(config, nc, payload, actor);
|
|
19
|
+
case "inspect":
|
|
20
|
+
return executeInspect(config, nc, payload);
|
|
21
|
+
default:
|
|
22
|
+
throw new OrbitError("BAD_ARGS", `unknown action: ${String(action)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
async function executeCall(config, nc, payload, actor) {
|
|
26
|
+
const target = String(payload.target ?? "");
|
|
27
|
+
if (!target.match(/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/)) {
|
|
28
|
+
throw new OrbitError("BAD_TARGET", "target must be <service>.<method>");
|
|
29
|
+
}
|
|
30
|
+
return executeRpcCall(config, nc, {
|
|
31
|
+
target,
|
|
32
|
+
body: payload.body,
|
|
33
|
+
timeoutMs: Number(payload.timeoutMs ?? config.requestTimeoutMs),
|
|
34
|
+
retries: Number(payload.retries ?? config.retries),
|
|
35
|
+
runId: typeof payload.runId === "string" ? payload.runId : undefined,
|
|
36
|
+
packFile: typeof payload.packFile === "string" ? payload.packFile : undefined,
|
|
37
|
+
a2a: {
|
|
38
|
+
task_id: typeof payload.taskId === "string" ? payload.taskId : undefined,
|
|
39
|
+
thread_id: typeof payload.threadId === "string" ? payload.threadId : undefined,
|
|
40
|
+
parent_message_id: typeof payload.parentMessageId === "string" ? payload.parentMessageId : undefined,
|
|
41
|
+
capabilities: Array.isArray(payload.capabilities)
|
|
42
|
+
? payload.capabilities.filter((v) => typeof v === "string" && Boolean(v))
|
|
43
|
+
: undefined,
|
|
44
|
+
traceparent: typeof payload.traceparent === "string" ? payload.traceparent : undefined,
|
|
45
|
+
dedupe_key: typeof payload.dedupeKey === "string" ? payload.dedupeKey : undefined
|
|
46
|
+
},
|
|
47
|
+
actor: `orbit-${actor}`,
|
|
48
|
+
traceStartEvent: "call_start"
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
async function executePublish(config, nc, payload, actor) {
|
|
52
|
+
const topic = String(payload.topic ?? "");
|
|
53
|
+
if (!topic)
|
|
54
|
+
throw new OrbitError("BAD_ARGS", "publish requires topic");
|
|
55
|
+
const runId = typeof payload.runId === "string" ? payload.runId : randomId();
|
|
56
|
+
const packFile = typeof payload.packFile === "string" ? payload.packFile : undefined;
|
|
57
|
+
const dataPackRef = packFile
|
|
58
|
+
? {
|
|
59
|
+
bucket: config.objectStoreBucket,
|
|
60
|
+
key: `${runId}/pub/${Date.now()}-${randomId()}.bin`,
|
|
61
|
+
bytes: fs.statSync(packFile).size
|
|
62
|
+
}
|
|
63
|
+
: undefined;
|
|
64
|
+
if (packFile && dataPackRef) {
|
|
65
|
+
await osPut(nc, dataPackRef.bucket, dataPackRef.key, fs.readFileSync(packFile), {
|
|
66
|
+
description: `orbit event data pack for ${topic}`
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
const env = createEnvelope({
|
|
70
|
+
kind: "event",
|
|
71
|
+
runId,
|
|
72
|
+
payload: payload.body,
|
|
73
|
+
dataPack: dataPackRef,
|
|
74
|
+
provenance: { publisher: `orbit-${actor}`, topic },
|
|
75
|
+
a2a: {
|
|
76
|
+
task_id: typeof payload.taskId === "string" ? payload.taskId : undefined,
|
|
77
|
+
thread_id: typeof payload.threadId === "string" ? payload.threadId : undefined,
|
|
78
|
+
parent_message_id: typeof payload.parentMessageId === "string" ? payload.parentMessageId : undefined,
|
|
79
|
+
capabilities: Array.isArray(payload.capabilities)
|
|
80
|
+
? payload.capabilities.filter((v) => typeof v === "string" && Boolean(v))
|
|
81
|
+
: undefined,
|
|
82
|
+
traceparent: typeof payload.traceparent === "string" ? payload.traceparent : undefined,
|
|
83
|
+
dedupe_key: typeof payload.dedupeKey === "string" ? payload.dedupeKey : undefined
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
const durable = typeof payload.durable === "boolean" ? payload.durable : config.runtime.publishDurableEnabled;
|
|
87
|
+
const dedupeKey = typeof payload.dedupeKey === "string" && payload.dedupeKey
|
|
88
|
+
? payload.dedupeKey
|
|
89
|
+
: env.a2a?.dedupe_key;
|
|
90
|
+
await publishSubject(nc, topic, encodeJson(env), {
|
|
91
|
+
durable,
|
|
92
|
+
dedupeKey,
|
|
93
|
+
timeoutMs: config.runtime.publishDurableTimeoutMs
|
|
94
|
+
});
|
|
95
|
+
await nc.flush();
|
|
96
|
+
return { ok: true, topic, run_id: runId, durable };
|
|
97
|
+
}
|
|
98
|
+
async function executeInspect(config, nc, payload) {
|
|
99
|
+
const service = String(payload.service ?? "");
|
|
100
|
+
const timeoutMs = Number(payload.timeoutMs ?? config.requestTimeoutMs);
|
|
101
|
+
if (!service)
|
|
102
|
+
throw new OrbitError("BAD_ARGS", "inspect requires service");
|
|
103
|
+
try {
|
|
104
|
+
const msg = await nc.request(prefixedSubject(config, "inspect", service), encodeJson(createEnvelope({ kind: "request", payload: {} })), {
|
|
105
|
+
timeout: timeoutMs
|
|
106
|
+
});
|
|
107
|
+
const env = validateEnvelope(decodeJson(msg.data), { skipHashCheck: config.performance.trustedLocal });
|
|
108
|
+
return env.payload;
|
|
109
|
+
}
|
|
110
|
+
catch {
|
|
111
|
+
try {
|
|
112
|
+
const msg = await nc.request(`$SRV.INFO.${service}`, encodeJson({}), { timeout: timeoutMs });
|
|
113
|
+
return decodeJson(msg.data);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
try {
|
|
117
|
+
const msg = await nc.request(prefixedSubject(config, "discovery", "query"), encodeJson({ service }), { timeout: timeoutMs });
|
|
118
|
+
const env = validateEnvelope(decodeJson(msg.data), { skipHashCheck: config.performance.trustedLocal });
|
|
119
|
+
return env.payload;
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
const local = await loadServiceRecordDistributed(config, service);
|
|
123
|
+
if (!local)
|
|
124
|
+
throw new OrbitError("NOT_FOUND", `service ${service} not found`);
|
|
125
|
+
return local;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
package/dist/src/otel.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
function toNanoTime(ts) {
|
|
2
|
+
const ms = Date.parse(ts);
|
|
3
|
+
return String(ms * 1_000_000);
|
|
4
|
+
}
|
|
5
|
+
const pendingByEndpoint = new Map();
|
|
6
|
+
const flushTimers = new Map();
|
|
7
|
+
function scheduleFlush(config) {
|
|
8
|
+
const endpoint = config.otel.endpoint;
|
|
9
|
+
if (!endpoint || flushTimers.has(endpoint))
|
|
10
|
+
return;
|
|
11
|
+
const timer = setTimeout(() => {
|
|
12
|
+
flushTimers.delete(endpoint);
|
|
13
|
+
void flushNow(config, endpoint);
|
|
14
|
+
}, config.performance.traceFlushIntervalMs);
|
|
15
|
+
timer.unref?.();
|
|
16
|
+
flushTimers.set(endpoint, timer);
|
|
17
|
+
}
|
|
18
|
+
function sleep(ms) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
function isRetryableStatus(status) {
|
|
22
|
+
return status === 408 || status === 429 || status >= 500;
|
|
23
|
+
}
|
|
24
|
+
function calculateBackoffMs(attempt) {
|
|
25
|
+
const base = 100 * 2 ** attempt;
|
|
26
|
+
const jitter = Math.floor(Math.random() * 50);
|
|
27
|
+
return base + jitter;
|
|
28
|
+
}
|
|
29
|
+
async function flushNow(config, endpoint) {
|
|
30
|
+
const events = pendingByEndpoint.get(endpoint) ?? [];
|
|
31
|
+
if (events.length === 0)
|
|
32
|
+
return;
|
|
33
|
+
pendingByEndpoint.set(endpoint, []);
|
|
34
|
+
const spans = events.map((event) => ({
|
|
35
|
+
traceId: (event.run_id.replace(/-/g, "").padEnd(32, "0")).slice(0, 32),
|
|
36
|
+
spanId: (event.span_id.replace(/-/g, "").padEnd(16, "0")).slice(0, 16),
|
|
37
|
+
name: event.event,
|
|
38
|
+
kind: 1,
|
|
39
|
+
startTimeUnixNano: toNanoTime(event.ts),
|
|
40
|
+
endTimeUnixNano: toNanoTime(event.ts),
|
|
41
|
+
attributes: [
|
|
42
|
+
{ key: "orbit.run_id", value: { stringValue: event.run_id } },
|
|
43
|
+
{ key: "orbit.actor", value: { stringValue: event.actor } },
|
|
44
|
+
...(event.svc ? [{ key: "orbit.service", value: { stringValue: event.svc } }] : []),
|
|
45
|
+
...(event.method ? [{ key: "orbit.method", value: { stringValue: event.method } }] : []),
|
|
46
|
+
...(event.retry !== undefined ? [{ key: "orbit.retry", value: { intValue: event.retry } }] : []),
|
|
47
|
+
...(event.error_code ? [{ key: "orbit.error_code", value: { stringValue: event.error_code } }] : [])
|
|
48
|
+
]
|
|
49
|
+
}));
|
|
50
|
+
const payload = {
|
|
51
|
+
resourceSpans: [
|
|
52
|
+
{
|
|
53
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: config.otel.serviceName } }] },
|
|
54
|
+
scopeSpans: [{ scope: { name: "orbit" }, spans }]
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
};
|
|
58
|
+
const maxAttempts = 3;
|
|
59
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
60
|
+
try {
|
|
61
|
+
const response = await fetch(endpoint, {
|
|
62
|
+
method: "POST",
|
|
63
|
+
headers: { "content-type": "application/json" },
|
|
64
|
+
body: JSON.stringify(payload)
|
|
65
|
+
});
|
|
66
|
+
if (response.ok)
|
|
67
|
+
return;
|
|
68
|
+
if (!isRetryableStatus(response.status))
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// network failure; retry with backoff
|
|
73
|
+
}
|
|
74
|
+
if (attempt < maxAttempts - 1) {
|
|
75
|
+
await sleep(calculateBackoffMs(attempt));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Preserve recent unsent trace events so transient OTLP outages don't silently drop data.
|
|
79
|
+
const queue = pendingByEndpoint.get(endpoint) ?? [];
|
|
80
|
+
const merged = [...events, ...queue];
|
|
81
|
+
pendingByEndpoint.set(endpoint, merged.slice(-config.performance.traceBufferMaxEvents));
|
|
82
|
+
scheduleFlush(config);
|
|
83
|
+
}
|
|
84
|
+
export async function exportTraceEvent(config, event) {
|
|
85
|
+
const endpoint = config.otel.endpoint;
|
|
86
|
+
if (!endpoint)
|
|
87
|
+
return;
|
|
88
|
+
const queue = pendingByEndpoint.get(endpoint) ?? [];
|
|
89
|
+
queue.push(event);
|
|
90
|
+
pendingByEndpoint.set(endpoint, queue);
|
|
91
|
+
if (queue.length >= 64) {
|
|
92
|
+
await flushNow(config, endpoint);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
scheduleFlush(config);
|
|
96
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { OrbitConfig, ServiceSpec } from "./types.js";
|
|
2
|
+
export interface ServiceRegistryRecord {
|
|
3
|
+
service: string;
|
|
4
|
+
registered_at: string;
|
|
5
|
+
spec: ServiceSpec;
|
|
6
|
+
}
|
|
7
|
+
export declare function saveServiceRecord(config: OrbitConfig, service: string, spec: ServiceSpec): void;
|
|
8
|
+
export declare function saveServiceRecordDistributed(config: OrbitConfig, service: string, spec: ServiceSpec): Promise<void>;
|
|
9
|
+
export declare function loadServiceRecord(config: OrbitConfig, service: string): ServiceRegistryRecord | null;
|
|
10
|
+
export declare function loadServiceRecordDistributed(config: OrbitConfig, service: string): Promise<ServiceRegistryRecord | null>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { writeJsonFile } from "./util.js";
|
|
4
|
+
import { closeBus, connectBus, kvGet, kvPut } from "./nats.js";
|
|
5
|
+
export function saveServiceRecord(config, service, spec) {
|
|
6
|
+
const rec = {
|
|
7
|
+
service,
|
|
8
|
+
registered_at: new Date().toISOString(),
|
|
9
|
+
spec
|
|
10
|
+
};
|
|
11
|
+
const filePath = path.join(config.servicesDir, `${service}.json`);
|
|
12
|
+
writeJsonFile(filePath, rec);
|
|
13
|
+
}
|
|
14
|
+
export async function saveServiceRecordDistributed(config, service, spec) {
|
|
15
|
+
saveServiceRecord(config, service, spec);
|
|
16
|
+
try {
|
|
17
|
+
const nc = await connectBus(config.natsUrl);
|
|
18
|
+
const rec = {
|
|
19
|
+
service,
|
|
20
|
+
registered_at: new Date().toISOString(),
|
|
21
|
+
spec
|
|
22
|
+
};
|
|
23
|
+
try {
|
|
24
|
+
await kvPut(nc, config.kvBucket, service, rec);
|
|
25
|
+
}
|
|
26
|
+
finally {
|
|
27
|
+
await closeBus(config.natsUrl);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
// local file registry remains authoritative fallback when JetStream/KV is unavailable.
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function loadServiceRecord(config, service) {
|
|
35
|
+
const filePath = path.join(config.servicesDir, `${service}.json`);
|
|
36
|
+
if (!fs.existsSync(filePath))
|
|
37
|
+
return null;
|
|
38
|
+
const text = fs.readFileSync(filePath, "utf-8");
|
|
39
|
+
return JSON.parse(text);
|
|
40
|
+
}
|
|
41
|
+
export async function loadServiceRecordDistributed(config, service) {
|
|
42
|
+
try {
|
|
43
|
+
const nc = await connectBus(config.natsUrl);
|
|
44
|
+
try {
|
|
45
|
+
const rec = await kvGet(nc, config.kvBucket, service);
|
|
46
|
+
if (rec)
|
|
47
|
+
return rec;
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await closeBus(config.natsUrl);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// no-op fallback to local registry below
|
|
55
|
+
}
|
|
56
|
+
return loadServiceRecord(config, service);
|
|
57
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface RetryOptions {
|
|
2
|
+
retries: number;
|
|
3
|
+
timeoutMs: number;
|
|
4
|
+
onRetry?: (attempt: number, err: unknown) => void;
|
|
5
|
+
}
|
|
6
|
+
export declare function withRetries<T>(work: (attempt: number) => Promise<T>, options: RetryOptions): Promise<{
|
|
7
|
+
value: T;
|
|
8
|
+
attempts: number;
|
|
9
|
+
}>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { OrbitError } from "./errors.js";
|
|
2
|
+
async function withTimeout(promise, ms) {
|
|
3
|
+
return new Promise((resolve, reject) => {
|
|
4
|
+
const timer = setTimeout(() => {
|
|
5
|
+
reject(new OrbitError("TIMEOUT", `Timed out after ${ms}ms`));
|
|
6
|
+
}, ms);
|
|
7
|
+
timer.unref?.();
|
|
8
|
+
promise.then((value) => {
|
|
9
|
+
clearTimeout(timer);
|
|
10
|
+
resolve(value);
|
|
11
|
+
}, (err) => {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
reject(err);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
export async function withRetries(work, options) {
|
|
18
|
+
const totalAttempts = Math.max(1, options.retries + 1);
|
|
19
|
+
let lastErr;
|
|
20
|
+
for (let attempt = 1; attempt <= totalAttempts; attempt += 1) {
|
|
21
|
+
try {
|
|
22
|
+
const value = await withTimeout(work(attempt), options.timeoutMs);
|
|
23
|
+
return { value, attempts: attempt };
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
lastErr = err;
|
|
27
|
+
if (attempt < totalAttempts)
|
|
28
|
+
options.onRetry?.(attempt, err);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
throw lastErr ?? new OrbitError("UNKNOWN", "retry failed");
|
|
32
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { NatsConnection } from "nats";
|
|
2
|
+
import { OrbitConfig } from "./types.js";
|
|
3
|
+
interface ExecuteRpcCallOptions {
|
|
4
|
+
target: string;
|
|
5
|
+
body: unknown;
|
|
6
|
+
timeoutMs?: number;
|
|
7
|
+
retries?: number;
|
|
8
|
+
runId?: string;
|
|
9
|
+
packFile?: string;
|
|
10
|
+
a2a?: {
|
|
11
|
+
task_id?: string;
|
|
12
|
+
thread_id?: string;
|
|
13
|
+
parent_message_id?: string;
|
|
14
|
+
capabilities?: string[];
|
|
15
|
+
traceparent?: string;
|
|
16
|
+
dedupe_key?: string;
|
|
17
|
+
};
|
|
18
|
+
actor: string;
|
|
19
|
+
traceStartEvent?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function executeRpcCall(config: OrbitConfig, nc: NatsConnection, opts: ExecuteRpcCallOptions): Promise<unknown>;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createEnvelope, validateEnvelope } from "./envelope.js";
|
|
3
|
+
import { withRetries } from "./retry.js";
|
|
4
|
+
import { decodeJson, encodeJson, osPut } from "./nats.js";
|
|
5
|
+
import { appendTraceEvent } from "./trace.js";
|
|
6
|
+
import { randomId } from "./util.js";
|
|
7
|
+
import { afterCallAttempt, beforeCall, onCallFailure, onCallSuccess } from "./call_protection.js";
|
|
8
|
+
import { prefixedSubject } from "./subjects.js";
|
|
9
|
+
import { OrbitError } from "./errors.js";
|
|
10
|
+
import { incCounter, observeHistogram } from "./metrics.js";
|
|
11
|
+
export async function executeRpcCall(config, nc, opts) {
|
|
12
|
+
const m = opts.target.match(/^([a-zA-Z0-9_-]+)\.([a-zA-Z0-9_-]+)$/);
|
|
13
|
+
if (!m)
|
|
14
|
+
throw new OrbitError("BAD_TARGET", "target must be <service>.<method>");
|
|
15
|
+
const [, svc, method] = m;
|
|
16
|
+
const timeoutMs = opts.timeoutMs ?? config.requestTimeoutMs;
|
|
17
|
+
const retries = opts.retries ?? config.retries;
|
|
18
|
+
const runId = opts.runId ?? randomId();
|
|
19
|
+
const dataPackRef = opts.packFile
|
|
20
|
+
? {
|
|
21
|
+
bucket: config.objectStoreBucket,
|
|
22
|
+
key: `${runId}/${svc}.${method}/${Date.now()}-${randomId()}.bin`,
|
|
23
|
+
bytes: fs.statSync(opts.packFile).size
|
|
24
|
+
}
|
|
25
|
+
: undefined;
|
|
26
|
+
if (opts.packFile && dataPackRef) {
|
|
27
|
+
await osPut(nc, dataPackRef.bucket, dataPackRef.key, fs.readFileSync(opts.packFile), {
|
|
28
|
+
description: `orbit data pack for ${svc}.${method}`
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
const reqEnv = createEnvelope({
|
|
32
|
+
kind: "request",
|
|
33
|
+
runId,
|
|
34
|
+
payload: opts.body,
|
|
35
|
+
dataPack: dataPackRef,
|
|
36
|
+
provenance: { caller: opts.actor, target: opts.target },
|
|
37
|
+
a2a: opts.a2a
|
|
38
|
+
});
|
|
39
|
+
const spanId = reqEnv.id;
|
|
40
|
+
appendTraceEvent(config, {
|
|
41
|
+
span_id: spanId,
|
|
42
|
+
run_id: runId,
|
|
43
|
+
ts: new Date().toISOString(),
|
|
44
|
+
actor: opts.actor,
|
|
45
|
+
event: opts.traceStartEvent ?? "call_start",
|
|
46
|
+
svc,
|
|
47
|
+
method
|
|
48
|
+
});
|
|
49
|
+
const subject = prefixedSubject(config, "rpc", svc, method);
|
|
50
|
+
const started = Date.now();
|
|
51
|
+
beforeCall(config, opts.target);
|
|
52
|
+
try {
|
|
53
|
+
const { value, attempts } = await withRetries(async (attempt) => {
|
|
54
|
+
const msg = await nc.request(subject, encodeJson(reqEnv), { timeout: timeoutMs });
|
|
55
|
+
appendTraceEvent(config, {
|
|
56
|
+
span_id: spanId,
|
|
57
|
+
run_id: runId,
|
|
58
|
+
ts: new Date().toISOString(),
|
|
59
|
+
actor: opts.actor,
|
|
60
|
+
event: "attempt_reply",
|
|
61
|
+
svc,
|
|
62
|
+
method,
|
|
63
|
+
retry: attempt - 1
|
|
64
|
+
});
|
|
65
|
+
return msg;
|
|
66
|
+
}, {
|
|
67
|
+
retries,
|
|
68
|
+
timeoutMs,
|
|
69
|
+
onRetry: (attempt, err) => {
|
|
70
|
+
onCallFailure(config, opts.target);
|
|
71
|
+
appendTraceEvent(config, {
|
|
72
|
+
span_id: spanId,
|
|
73
|
+
run_id: runId,
|
|
74
|
+
ts: new Date().toISOString(),
|
|
75
|
+
actor: opts.actor,
|
|
76
|
+
event: "retry",
|
|
77
|
+
svc,
|
|
78
|
+
method,
|
|
79
|
+
retry: attempt,
|
|
80
|
+
error_code: err.code ?? "RETRY"
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
const env = validateEnvelope(decodeJson(value.data), { skipHashCheck: config.performance.trustedLocal });
|
|
85
|
+
const payload = env.payload;
|
|
86
|
+
if (payload?.ok === false) {
|
|
87
|
+
onCallFailure(config, opts.target);
|
|
88
|
+
}
|
|
89
|
+
else {
|
|
90
|
+
onCallSuccess(opts.target);
|
|
91
|
+
}
|
|
92
|
+
appendTraceEvent(config, {
|
|
93
|
+
span_id: spanId,
|
|
94
|
+
run_id: runId,
|
|
95
|
+
ts: new Date().toISOString(),
|
|
96
|
+
actor: opts.actor,
|
|
97
|
+
event: "call_end",
|
|
98
|
+
svc,
|
|
99
|
+
method,
|
|
100
|
+
latency_ms: Date.now() - started,
|
|
101
|
+
retry: attempts - 1
|
|
102
|
+
});
|
|
103
|
+
const durationMs = Date.now() - started;
|
|
104
|
+
observeHistogram("orbit_rpc_call_duration_ms", durationMs, { target: opts.target, outcome: "ok" });
|
|
105
|
+
incCounter("orbit_rpc_calls_total", 1, { target: opts.target, outcome: "ok" });
|
|
106
|
+
return env.payload;
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
onCallFailure(config, opts.target);
|
|
110
|
+
const code = err.code ?? "ERROR";
|
|
111
|
+
const durationMs = Date.now() - started;
|
|
112
|
+
observeHistogram("orbit_rpc_call_duration_ms", durationMs, { target: opts.target, outcome: "error", code });
|
|
113
|
+
incCounter("orbit_rpc_calls_total", 1, { target: opts.target, outcome: "error", code });
|
|
114
|
+
throw err;
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
afterCallAttempt(opts.target);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { ServiceMethodSpec } from "./types.js";
|
|
2
|
+
interface WorkerExecutionPolicy {
|
|
3
|
+
poolSize: number;
|
|
4
|
+
maxPendingPerWorker: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function executeMethod(methodSpec: ServiceMethodSpec, requestPayload: unknown, defaultTimeoutMs: number, workerPolicy?: WorkerExecutionPolicy): Promise<unknown>;
|
|
7
|
+
export {};
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { OrbitError } from "./errors.js";
|
|
3
|
+
import { assertJsonSchema } from "./json_schema.js";
|
|
4
|
+
import { WorkerPool } from "./worker_pool.js";
|
|
5
|
+
function readPath(obj, segments) {
|
|
6
|
+
if (segments.length === 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
let curr = obj;
|
|
9
|
+
for (const key of segments) {
|
|
10
|
+
if (!curr || typeof curr !== "object" || !(key in curr))
|
|
11
|
+
return undefined;
|
|
12
|
+
curr = curr[key];
|
|
13
|
+
}
|
|
14
|
+
return curr;
|
|
15
|
+
}
|
|
16
|
+
const templateCache = new Map();
|
|
17
|
+
function compileTemplate(input) {
|
|
18
|
+
const cached = templateCache.get(input);
|
|
19
|
+
if (cached)
|
|
20
|
+
return cached;
|
|
21
|
+
const parts = [];
|
|
22
|
+
const regex = /\{\{\s*([a-zA-Z0-9_.]+)\s*\}\}/g;
|
|
23
|
+
let lastIndex = 0;
|
|
24
|
+
let match;
|
|
25
|
+
while ((match = regex.exec(input))) {
|
|
26
|
+
if (match.index > lastIndex)
|
|
27
|
+
parts.push({ literal: input.slice(lastIndex, match.index) });
|
|
28
|
+
parts.push({ path: match[1].split(".") });
|
|
29
|
+
lastIndex = regex.lastIndex;
|
|
30
|
+
}
|
|
31
|
+
if (lastIndex < input.length)
|
|
32
|
+
parts.push({ literal: input.slice(lastIndex) });
|
|
33
|
+
templateCache.set(input, parts);
|
|
34
|
+
return parts;
|
|
35
|
+
}
|
|
36
|
+
function template(input, ctx) {
|
|
37
|
+
const parts = compileTemplate(input);
|
|
38
|
+
let out = "";
|
|
39
|
+
for (const part of parts) {
|
|
40
|
+
if (part.literal !== undefined) {
|
|
41
|
+
out += part.literal;
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const val = readPath(ctx, part.path ?? []);
|
|
45
|
+
if (val === undefined || val === null)
|
|
46
|
+
continue;
|
|
47
|
+
if (typeof val === "string" || typeof val === "number" || typeof val === "boolean") {
|
|
48
|
+
out += String(val);
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
out += JSON.stringify(val);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
const workerPool = new WorkerPool();
|
|
56
|
+
async function executeHttpMethod(methodSpec, requestPayload, timeoutMs) {
|
|
57
|
+
const endpoint = methodSpec.http_endpoint ? template(methodSpec.http_endpoint, requestPayload) : "";
|
|
58
|
+
if (!endpoint)
|
|
59
|
+
throw new OrbitError("METHOD_BAD_HTTP_SPEC", "http transport requires http_endpoint");
|
|
60
|
+
const method = methodSpec.http_method ?? "POST";
|
|
61
|
+
const templatedHeaders = {};
|
|
62
|
+
for (const [k, v] of Object.entries(methodSpec.headers ?? {})) {
|
|
63
|
+
templatedHeaders[k] = template(v, requestPayload);
|
|
64
|
+
}
|
|
65
|
+
const headers = {
|
|
66
|
+
"content-type": "application/json",
|
|
67
|
+
...templatedHeaders
|
|
68
|
+
};
|
|
69
|
+
const controller = new AbortController();
|
|
70
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
71
|
+
timer.unref?.();
|
|
72
|
+
try {
|
|
73
|
+
const res = await fetch(endpoint, {
|
|
74
|
+
method,
|
|
75
|
+
headers,
|
|
76
|
+
body: method === "GET" ? undefined : JSON.stringify(requestPayload ?? {}),
|
|
77
|
+
signal: controller.signal
|
|
78
|
+
});
|
|
79
|
+
const text = await res.text();
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
throw new OrbitError("METHOD_HTTP_ERROR", `http method failed with status ${res.status}`, {
|
|
82
|
+
status: res.status,
|
|
83
|
+
body: text
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!text.trim())
|
|
87
|
+
return {};
|
|
88
|
+
try {
|
|
89
|
+
return JSON.parse(text);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return { body: text };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (err.name === "AbortError") {
|
|
97
|
+
throw new OrbitError("METHOD_TIMEOUT", `http method timed out after ${timeoutMs}ms`);
|
|
98
|
+
}
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
clearTimeout(timer);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export async function executeMethod(methodSpec, requestPayload, defaultTimeoutMs, workerPolicy) {
|
|
106
|
+
const timeoutMs = methodSpec.timeout_ms ?? defaultTimeoutMs;
|
|
107
|
+
const transport = methodSpec.transport ?? "worker";
|
|
108
|
+
assertJsonSchema(requestPayload, methodSpec.request_schema, "method request");
|
|
109
|
+
let result;
|
|
110
|
+
if (transport === "http") {
|
|
111
|
+
result = await executeHttpMethod(methodSpec, requestPayload, timeoutMs);
|
|
112
|
+
assertJsonSchema(result, methodSpec.response_schema, "method response");
|
|
113
|
+
return result;
|
|
114
|
+
}
|
|
115
|
+
if (!methodSpec.command) {
|
|
116
|
+
throw new OrbitError("METHOD_BAD_SPEC", `transport ${transport} requires command`);
|
|
117
|
+
}
|
|
118
|
+
const cmd = template(methodSpec.command, requestPayload);
|
|
119
|
+
const args = (methodSpec.args ?? []).map((v) => template(v, requestPayload));
|
|
120
|
+
if (transport === "worker") {
|
|
121
|
+
result = await workerPool.execute(cmd, args, requestPayload, timeoutMs, {
|
|
122
|
+
poolSize: Math.max(1, workerPolicy?.poolSize ?? 1),
|
|
123
|
+
maxPendingPerWorker: Math.max(1, workerPolicy?.maxPendingPerWorker ?? 64)
|
|
124
|
+
});
|
|
125
|
+
assertJsonSchema(result, methodSpec.response_schema, "method response");
|
|
126
|
+
return result;
|
|
127
|
+
}
|
|
128
|
+
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
|
|
129
|
+
let stdout = "";
|
|
130
|
+
let stderr = "";
|
|
131
|
+
child.stdout.on("data", (chunk) => {
|
|
132
|
+
stdout += String(chunk);
|
|
133
|
+
});
|
|
134
|
+
child.stderr.on("data", (chunk) => {
|
|
135
|
+
stderr += String(chunk);
|
|
136
|
+
});
|
|
137
|
+
const exitCode = await new Promise((resolve, reject) => {
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
child.kill("SIGKILL");
|
|
140
|
+
reject(new OrbitError("METHOD_TIMEOUT", `method command timed out after ${timeoutMs}ms`));
|
|
141
|
+
}, timeoutMs);
|
|
142
|
+
timer.unref?.();
|
|
143
|
+
child.on("error", (err) => reject(new OrbitError("METHOD_SPAWN_ERROR", "failed to spawn method command", { err })));
|
|
144
|
+
child.on("exit", (code) => {
|
|
145
|
+
clearTimeout(timer);
|
|
146
|
+
resolve(code ?? 0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
if (exitCode !== 0) {
|
|
150
|
+
throw new OrbitError("METHOD_EXIT_NONZERO", `method exited with code ${exitCode}`, { stderr, stdout, exitCode });
|
|
151
|
+
}
|
|
152
|
+
const text = stdout.trim();
|
|
153
|
+
if (!text)
|
|
154
|
+
return {};
|
|
155
|
+
try {
|
|
156
|
+
result = JSON.parse(text);
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
result = { stdout: text, stderr: stderr.trim(), exit_code: exitCode };
|
|
160
|
+
}
|
|
161
|
+
assertJsonSchema(result, methodSpec.response_schema, "method response");
|
|
162
|
+
return result;
|
|
163
|
+
}
|