nexus-ai-pro 2.0.0 → 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/API_STABILITY.md +65 -0
- package/CHANGELOG.md +267 -1
- package/MIGRATING.md +1 -1
- package/README.md +17 -8
- package/dist/agent/create-agent.d.ts +9 -1
- package/dist/agent/create-agent.js +40 -1
- package/dist/cache/memory-cache.d.ts +8 -0
- package/dist/cache/memory-cache.js +9 -0
- package/dist/cache/semantic-cache.d.ts +6 -3
- package/dist/cache/semantic-cache.js +5 -2
- package/dist/cli/args.js +3 -0
- package/dist/cli/db.d.ts +16 -4
- package/dist/cli/db.js +169 -22
- package/dist/cli/doctor.d.ts +17 -0
- package/dist/cli/doctor.js +53 -0
- package/dist/cli/graph.d.ts +9 -0
- package/dist/cli/graph.js +47 -0
- package/dist/cli.js +21 -2
- package/dist/core/lifecycle.js +11 -6
- package/dist/core/nexus.d.ts +12 -2
- package/dist/core/nexus.js +106 -23
- package/dist/doctor/index.d.ts +90 -0
- package/dist/doctor/index.js +237 -0
- package/dist/evals/judge.d.ts +11 -0
- package/dist/evals/judge.js +33 -0
- package/dist/evaluate/index.d.ts +1 -0
- package/dist/evaluate/index.js +1 -0
- package/dist/evaluate/provenance.d.ts +22 -0
- package/dist/evaluate/provenance.js +10 -0
- package/dist/evaluate/run.js +20 -4
- package/dist/graph/durability.d.ts +27 -0
- package/dist/graph/durability.js +66 -0
- package/dist/graph/errors.d.ts +30 -4
- package/dist/graph/errors.js +18 -2
- package/dist/graph/event-stream.d.ts +75 -0
- package/dist/graph/event-stream.js +289 -0
- package/dist/graph/functional.d.ts +80 -7
- package/dist/graph/functional.js +138 -35
- package/dist/graph/graph.d.ts +56 -8
- package/dist/graph/graph.js +331 -49
- package/dist/graph/index.d.ts +4 -2
- package/dist/graph/index.js +3 -1
- package/dist/graph/lint.d.ts +57 -0
- package/dist/graph/lint.js +141 -0
- package/dist/graph/run-control.d.ts +28 -0
- package/dist/graph/run-control.js +26 -0
- package/dist/images/evals.js +4 -2
- package/dist/operations/adapters.d.ts +52 -4
- package/dist/operations/adapters.js +194 -7
- package/dist/operations/runner.js +14 -15
- package/dist/operations/stats.js +8 -1
- package/dist/operations/store.d.ts +5 -1
- package/dist/operations/store.js +7 -0
- package/dist/ops/circuit-breaker.d.ts +52 -0
- package/dist/ops/circuit-breaker.js +4 -0
- package/dist/ops/circuit-store.d.ts +13 -2
- package/dist/ops/circuit-store.js +68 -0
- package/dist/ops/circuit-sync.d.ts +17 -0
- package/dist/ops/circuit-sync.js +86 -0
- package/dist/ops/health.d.ts +24 -0
- package/dist/ops/health.js +38 -10
- package/dist/ops/index.d.ts +1 -1
- package/dist/ops/index.js +1 -1
- package/dist/ops/rate-limit-adapters.d.ts +23 -0
- package/dist/ops/rate-limit-adapters.js +34 -0
- package/dist/ops/rate-limiter.d.ts +14 -2
- package/dist/ops/rate-limiter.js +31 -0
- package/dist/postgres/circuits.d.ts +21 -4
- package/dist/postgres/circuits.js +65 -4
- package/dist/postgres/client.d.ts +0 -2
- package/dist/postgres/client.js +0 -4
- package/dist/postgres/evaluate.d.ts +16 -4
- package/dist/postgres/evaluate.js +17 -5
- package/dist/postgres/index.d.ts +32 -11
- package/dist/postgres/index.js +65 -31
- package/dist/postgres/migrations.d.ts +60 -0
- package/dist/postgres/migrations.js +121 -0
- package/dist/postgres/operations.d.ts +14 -3
- package/dist/postgres/operations.js +45 -15
- package/dist/postgres/prompts.d.ts +11 -2
- package/dist/postgres/prompts.js +14 -3
- package/dist/postgres/rollups.d.ts +35 -0
- package/dist/postgres/rollups.js +129 -0
- package/dist/postgres/store.d.ts +11 -2
- package/dist/postgres/store.js +14 -3
- package/dist/postgres/traces.d.ts +13 -3
- package/dist/postgres/traces.js +25 -3
- package/dist/postgres/vectors.d.ts +11 -2
- package/dist/postgres/vectors.js +14 -3
- package/dist/router/auto-router.d.ts +34 -7
- package/dist/router/auto-router.js +201 -68
- package/dist/server/assistant.d.ts +44 -0
- package/dist/server/assistant.js +39 -0
- package/dist/server/errors.d.ts +12 -0
- package/dist/server/errors.js +7 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/runs.d.ts +28 -1
- package/dist/server/runs.js +110 -14
- package/dist/sqlite/index.d.ts +16 -3
- package/dist/sqlite/index.js +16 -3
- package/dist/sqlite/migrations.d.ts +26 -0
- package/dist/sqlite/migrations.js +63 -0
- package/dist/sqlite/operations.d.ts +14 -3
- package/dist/sqlite/operations.js +42 -15
- package/dist/sqlite/store.d.ts +9 -2
- package/dist/sqlite/store.js +13 -3
- package/dist/sqlite/vectors.d.ts +11 -2
- package/dist/sqlite/vectors.js +13 -2
- package/dist/store/tenant.d.ts +12 -0
- package/dist/store/tenant.js +29 -0
- package/dist/tenancy/index.d.ts +113 -0
- package/dist/tenancy/index.js +233 -0
- package/dist/testing/record.d.ts +42 -0
- package/dist/testing/record.js +57 -5
- package/dist/tracing/index.d.ts +3 -3
- package/dist/tracing/index.js +2 -2
- package/dist/tracing/instrument.js +30 -0
- package/dist/tracing/otlp.d.ts +91 -0
- package/dist/tracing/otlp.js +124 -0
- package/dist/tracing/rollups.d.ts +100 -0
- package/dist/tracing/rollups.js +139 -0
- package/dist/tracing/stores.d.ts +17 -0
- package/dist/tracing/stores.js +23 -0
- package/dist/tracing/tracer.d.ts +50 -4
- package/dist/tracing/tracer.js +85 -6
- package/dist/types/config.d.ts +33 -2
- package/dist/types/evaluate.d.ts +37 -0
- package/dist/types/graph.d.ts +354 -9
- package/dist/types/graph.js +3 -1
- package/dist/types/messages.d.ts +5 -0
- package/dist/types/operations.d.ts +22 -2
- package/dist/types/response.d.ts +20 -0
- package/dist/types/server.d.ts +18 -0
- package/dist/types/tracing.d.ts +11 -0
- package/dist/utils/schema-migrations.d.ts +97 -0
- package/dist/utils/schema-migrations.js +91 -0
- package/dist/utils/signals.d.ts +17 -0
- package/dist/utils/signals.js +26 -0
- package/dist/utils/tenant.d.ts +14 -0
- package/dist/utils/tenant.js +16 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +1 -0
- package/dist-cjs/agent/create-agent.d.ts +9 -1
- package/dist-cjs/agent/create-agent.js +40 -1
- package/dist-cjs/cache/memory-cache.d.ts +8 -0
- package/dist-cjs/cache/memory-cache.js +9 -0
- package/dist-cjs/cache/semantic-cache.d.ts +6 -3
- package/dist-cjs/cache/semantic-cache.js +5 -2
- package/dist-cjs/cli/args.js +3 -0
- package/dist-cjs/cli/db.d.ts +16 -4
- package/dist-cjs/cli/db.js +200 -20
- package/dist-cjs/cli/doctor.d.ts +17 -0
- package/dist-cjs/cli/doctor.js +56 -0
- package/dist-cjs/cli/graph.d.ts +9 -0
- package/dist-cjs/cli/graph.js +50 -0
- package/dist-cjs/core/lifecycle.js +11 -6
- package/dist-cjs/core/nexus.d.ts +12 -2
- package/dist-cjs/core/nexus.js +106 -23
- package/dist-cjs/doctor/index.d.ts +90 -0
- package/dist-cjs/doctor/index.js +276 -0
- package/dist-cjs/evals/judge.d.ts +11 -0
- package/dist-cjs/evals/judge.js +33 -0
- package/dist-cjs/evaluate/index.d.ts +1 -0
- package/dist-cjs/evaluate/index.js +3 -1
- package/dist-cjs/evaluate/provenance.d.ts +22 -0
- package/dist-cjs/evaluate/provenance.js +14 -0
- package/dist-cjs/evaluate/run.js +20 -4
- package/dist-cjs/graph/durability.d.ts +27 -0
- package/dist-cjs/graph/durability.js +70 -0
- package/dist-cjs/graph/errors.d.ts +30 -4
- package/dist-cjs/graph/errors.js +20 -3
- package/dist-cjs/graph/event-stream.d.ts +75 -0
- package/dist-cjs/graph/event-stream.js +295 -0
- package/dist-cjs/graph/functional.d.ts +80 -7
- package/dist-cjs/graph/functional.js +137 -34
- package/dist-cjs/graph/graph.d.ts +56 -8
- package/dist-cjs/graph/graph.js +330 -48
- package/dist-cjs/graph/index.d.ts +4 -2
- package/dist-cjs/graph/index.js +7 -1
- package/dist-cjs/graph/lint.d.ts +57 -0
- package/dist-cjs/graph/lint.js +144 -0
- package/dist-cjs/graph/run-control.d.ts +28 -0
- package/dist-cjs/graph/run-control.js +30 -0
- package/dist-cjs/images/evals.js +4 -2
- package/dist-cjs/operations/adapters.d.ts +52 -4
- package/dist-cjs/operations/adapters.js +193 -6
- package/dist-cjs/operations/runner.js +14 -15
- package/dist-cjs/operations/stats.js +8 -1
- package/dist-cjs/operations/store.d.ts +5 -1
- package/dist-cjs/operations/store.js +7 -0
- package/dist-cjs/ops/circuit-breaker.d.ts +52 -0
- package/dist-cjs/ops/circuit-breaker.js +4 -0
- package/dist-cjs/ops/circuit-store.d.ts +13 -2
- package/dist-cjs/ops/circuit-store.js +68 -0
- package/dist-cjs/ops/circuit-sync.d.ts +17 -0
- package/dist-cjs/ops/circuit-sync.js +87 -0
- package/dist-cjs/ops/health.d.ts +24 -0
- package/dist-cjs/ops/health.js +38 -10
- package/dist-cjs/ops/index.d.ts +1 -1
- package/dist-cjs/ops/rate-limit-adapters.d.ts +23 -0
- package/dist-cjs/ops/rate-limit-adapters.js +35 -1
- package/dist-cjs/ops/rate-limiter.d.ts +14 -2
- package/dist-cjs/ops/rate-limiter.js +32 -0
- package/dist-cjs/postgres/circuits.d.ts +21 -4
- package/dist-cjs/postgres/circuits.js +98 -3
- package/dist-cjs/postgres/client.d.ts +0 -2
- package/dist-cjs/postgres/client.js +0 -5
- package/dist-cjs/postgres/evaluate.d.ts +16 -4
- package/dist-cjs/postgres/evaluate.js +50 -4
- package/dist-cjs/postgres/index.d.ts +32 -11
- package/dist-cjs/postgres/index.js +69 -20
- package/dist-cjs/postgres/migrations.d.ts +60 -0
- package/dist-cjs/postgres/migrations.js +129 -0
- package/dist-cjs/postgres/operations.d.ts +14 -3
- package/dist-cjs/postgres/operations.js +78 -14
- package/dist-cjs/postgres/prompts.d.ts +11 -2
- package/dist-cjs/postgres/prompts.js +47 -2
- package/dist-cjs/postgres/rollups.d.ts +35 -0
- package/dist-cjs/postgres/rollups.js +168 -0
- package/dist-cjs/postgres/store.d.ts +11 -2
- package/dist-cjs/postgres/store.js +47 -2
- package/dist-cjs/postgres/traces.d.ts +13 -3
- package/dist-cjs/postgres/traces.js +58 -2
- package/dist-cjs/postgres/vectors.d.ts +11 -2
- package/dist-cjs/postgres/vectors.js +47 -2
- package/dist-cjs/router/auto-router.d.ts +34 -7
- package/dist-cjs/router/auto-router.js +201 -68
- package/dist-cjs/server/assistant.d.ts +44 -0
- package/dist-cjs/server/assistant.js +39 -0
- package/dist-cjs/server/errors.d.ts +12 -0
- package/dist-cjs/server/errors.js +9 -1
- package/dist-cjs/server/index.d.ts +1 -1
- package/dist-cjs/server/index.js +2 -1
- package/dist-cjs/server/runs.d.ts +28 -1
- package/dist-cjs/server/runs.js +109 -13
- package/dist-cjs/sqlite/index.d.ts +16 -3
- package/dist-cjs/sqlite/index.js +31 -10
- package/dist-cjs/sqlite/migrations.d.ts +26 -0
- package/dist-cjs/sqlite/migrations.js +71 -0
- package/dist-cjs/sqlite/operations.d.ts +14 -3
- package/dist-cjs/sqlite/operations.js +76 -15
- package/dist-cjs/sqlite/store.d.ts +9 -2
- package/dist-cjs/sqlite/store.js +47 -3
- package/dist-cjs/sqlite/vectors.d.ts +11 -2
- package/dist-cjs/sqlite/vectors.js +47 -2
- package/dist-cjs/store/tenant.d.ts +12 -0
- package/dist-cjs/store/tenant.js +33 -0
- package/dist-cjs/tenancy/index.d.ts +113 -0
- package/dist-cjs/tenancy/index.js +250 -0
- package/dist-cjs/testing/record.d.ts +42 -0
- package/dist-cjs/testing/record.js +57 -5
- package/dist-cjs/tracing/index.d.ts +3 -3
- package/dist-cjs/tracing/index.js +4 -1
- package/dist-cjs/tracing/instrument.js +30 -0
- package/dist-cjs/tracing/otlp.d.ts +91 -0
- package/dist-cjs/tracing/otlp.js +129 -0
- package/dist-cjs/tracing/rollups.d.ts +100 -0
- package/dist-cjs/tracing/rollups.js +147 -0
- package/dist-cjs/tracing/stores.d.ts +17 -0
- package/dist-cjs/tracing/stores.js +24 -0
- package/dist-cjs/tracing/tracer.d.ts +50 -4
- package/dist-cjs/tracing/tracer.js +87 -6
- package/dist-cjs/types/config.d.ts +33 -2
- package/dist-cjs/types/evaluate.d.ts +37 -0
- package/dist-cjs/types/graph.d.ts +354 -9
- package/dist-cjs/types/graph.js +3 -1
- package/dist-cjs/types/messages.d.ts +5 -0
- package/dist-cjs/types/operations.d.ts +22 -2
- package/dist-cjs/types/response.d.ts +20 -0
- package/dist-cjs/types/server.d.ts +18 -0
- package/dist-cjs/types/tracing.d.ts +11 -0
- package/dist-cjs/utils/schema-migrations.d.ts +97 -0
- package/dist-cjs/utils/schema-migrations.js +102 -0
- package/dist-cjs/utils/signals.d.ts +17 -0
- package/dist-cjs/utils/signals.js +29 -0
- package/dist-cjs/utils/tenant.d.ts +14 -0
- package/dist-cjs/utils/tenant.js +22 -0
- package/dist-cjs/version.d.ts +5 -0
- package/dist-cjs/version.js +4 -0
- package/llms.txt +4 -3
- package/package.json +110 -2
|
@@ -93,6 +93,7 @@ export class OperationRunner {
|
|
|
93
93
|
expiresAt: options.expiresAt,
|
|
94
94
|
idempotencyKey: options.idempotencyKey,
|
|
95
95
|
kind: options.kind,
|
|
96
|
+
...(options.tenantId ? { tenantId: options.tenantId } : {}),
|
|
96
97
|
traceContext: options.traceContext,
|
|
97
98
|
metadata: options.metadata,
|
|
98
99
|
};
|
|
@@ -214,7 +215,7 @@ export class OperationRunner {
|
|
|
214
215
|
const heartbeat = this.startHeartbeat(record, handle);
|
|
215
216
|
let outcome;
|
|
216
217
|
try {
|
|
217
|
-
const value = await executor(this.buildContext(record, handle));
|
|
218
|
+
const value = await executor(this.buildContext(record, handle, heartbeat.beat));
|
|
218
219
|
outcome = { ok: true, value };
|
|
219
220
|
}
|
|
220
221
|
catch (error) {
|
|
@@ -290,7 +291,13 @@ export class OperationRunner {
|
|
|
290
291
|
startHeartbeat(record, handle) {
|
|
291
292
|
let latest = record;
|
|
292
293
|
let stopped = false;
|
|
293
|
-
|
|
294
|
+
let inFlight = Promise.resolve();
|
|
295
|
+
const beat = (details) => {
|
|
296
|
+
const next = inFlight.then(() => renew(details));
|
|
297
|
+
inFlight = next.catch(() => undefined);
|
|
298
|
+
return next;
|
|
299
|
+
};
|
|
300
|
+
const renew = async (details) => {
|
|
294
301
|
if (stopped)
|
|
295
302
|
return;
|
|
296
303
|
const current = await this.store.read(latest.id);
|
|
@@ -315,6 +322,8 @@ export class OperationRunner {
|
|
|
315
322
|
heartbeatAt: renewed.updatedAt,
|
|
316
323
|
};
|
|
317
324
|
renewed.progress = handle.progress() ?? current.progress;
|
|
325
|
+
if (details !== undefined)
|
|
326
|
+
renewed.heartbeatDetails = details;
|
|
318
327
|
if (await this.store.update(renewed, current.sequence))
|
|
319
328
|
latest = renewed;
|
|
320
329
|
};
|
|
@@ -335,24 +344,14 @@ export class OperationRunner {
|
|
|
335
344
|
beat,
|
|
336
345
|
};
|
|
337
346
|
}
|
|
338
|
-
buildContext(record, handle) {
|
|
347
|
+
buildContext(record, handle, beat) {
|
|
339
348
|
return {
|
|
340
349
|
operationId: record.id,
|
|
341
350
|
attempt: record.attempt,
|
|
342
351
|
signal: handle.signal,
|
|
343
352
|
report: (progress) => handle.report(progress),
|
|
344
|
-
heartbeat:
|
|
345
|
-
|
|
346
|
-
if (!current || isTerminalOperationStatus(current.status))
|
|
347
|
-
return;
|
|
348
|
-
const renewed = this.advance(current, 'running');
|
|
349
|
-
renewed.lease = {
|
|
350
|
-
owner: this.owner,
|
|
351
|
-
expiresAt: new Date(this.now().getTime() + this.leaseMs).toISOString(),
|
|
352
|
-
heartbeatAt: renewed.updatedAt,
|
|
353
|
-
};
|
|
354
|
-
await this.store.update(renewed, current.sequence);
|
|
355
|
-
},
|
|
353
|
+
heartbeat: (details) => beat(details),
|
|
354
|
+
...(record.heartbeatDetails === undefined ? {} : { previousHeartbeat: record.heartbeatDetails }),
|
|
356
355
|
traceContext: record.traceContext,
|
|
357
356
|
metadata: record.metadata,
|
|
358
357
|
};
|
package/dist/operations/stats.js
CHANGED
|
@@ -3,6 +3,8 @@ export function isUnheldQueued(record, now) {
|
|
|
3
3
|
return record.status === 'queued' && (!record.lease || record.lease.expiresAt <= now);
|
|
4
4
|
}
|
|
5
5
|
export function matchesFilter(record, filter) {
|
|
6
|
+
if (filter?.tenantId !== undefined && record.tenantId !== filter.tenantId)
|
|
7
|
+
return false;
|
|
6
8
|
return !filter?.kindPrefix || (record.kind ?? '').startsWith(filter.kindPrefix);
|
|
7
9
|
}
|
|
8
10
|
export function countRecords(records, now, filter) {
|
|
@@ -23,7 +25,12 @@ export function countRecords(records, now, filter) {
|
|
|
23
25
|
}
|
|
24
26
|
export async function operationStats(store, options = {}) {
|
|
25
27
|
const now = (options.now ?? new Date()).toISOString();
|
|
26
|
-
const filter = options.kindPrefix
|
|
28
|
+
const filter = options.kindPrefix || options.tenantId !== undefined
|
|
29
|
+
? {
|
|
30
|
+
...(options.kindPrefix ? { kindPrefix: options.kindPrefix } : {}),
|
|
31
|
+
...(options.tenantId !== undefined ? { tenantId: options.tenantId } : {}),
|
|
32
|
+
}
|
|
33
|
+
: undefined;
|
|
27
34
|
if (store.stats)
|
|
28
35
|
return store.stats(now, filter);
|
|
29
36
|
if (store.list)
|
|
@@ -19,7 +19,11 @@ export declare class MemoryOperationStore<TResult = unknown> implements Operatio
|
|
|
19
19
|
private readonly byIdempotencyKey;
|
|
20
20
|
private readonly maxRecords;
|
|
21
21
|
constructor(options?: MemoryOperationStoreOptions);
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Stores a new record. Throws `OperationDuplicateError` when another record already holds its
|
|
24
|
+
* idempotency key, as every shared store does, so two submissions racing under one key cannot both
|
|
25
|
+
* start. Refuses records carrying raw bytes.
|
|
26
|
+
*/
|
|
23
27
|
create(record: OperationRecord<TResult>): void;
|
|
24
28
|
/** Reads a record. */
|
|
25
29
|
read(id: string): OperationRecord<TResult> | undefined;
|
package/dist/operations/store.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isTerminalOperationStatus } from '../types/operations.js';
|
|
2
|
+
import { OperationDuplicateError } from './errors.js';
|
|
2
3
|
import { assertSerializableRecord } from './serialization.js';
|
|
3
4
|
import { countRecords, isUnheldQueued, matchesFilter } from './stats.js';
|
|
4
5
|
export { assertSerializableRecord };
|
|
@@ -12,6 +13,12 @@ export class MemoryOperationStore {
|
|
|
12
13
|
}
|
|
13
14
|
create(record) {
|
|
14
15
|
assertSerializableRecord(record);
|
|
16
|
+
if (record.idempotencyKey) {
|
|
17
|
+
const owner = this.byIdempotencyKey.get(record.idempotencyKey);
|
|
18
|
+
if (owner !== undefined && owner !== record.id && this.records.has(owner)) {
|
|
19
|
+
throw new OperationDuplicateError(record.idempotencyKey);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
15
22
|
this.records.set(record.id, clone(record));
|
|
16
23
|
if (record.idempotencyKey)
|
|
17
24
|
this.byIdempotencyKey.set(record.idempotencyKey, record.id);
|
|
@@ -41,6 +41,15 @@ export interface CircuitBreakerConfig {
|
|
|
41
41
|
* `nexus-ai-pro/ops/circuit-store` and `nexus-ai-pro/postgres/circuits`.
|
|
42
42
|
*/
|
|
43
43
|
store?: CircuitStateStore;
|
|
44
|
+
/**
|
|
45
|
+
* Counts every worker's calls together, so nine failures spread over three workers count as nine.
|
|
46
|
+
* Each worker adds the calls it saw to a rolling window in the `store` when it syncs, and opens the
|
|
47
|
+
* circuit everywhere once the shared count crosses `failureThreshold` (failures since any worker's
|
|
48
|
+
* last success) or `failureRateThreshold`. Checks stay synchronous: a worker learns the shared count
|
|
49
|
+
* on its next sync, at most `syncIntervalMs` late. Needs a store with `observe()`; every bundled one
|
|
50
|
+
* has it. Off by default, when each worker counts only its own calls.
|
|
51
|
+
*/
|
|
52
|
+
shareObservations?: boolean;
|
|
44
53
|
/** Names this worker in shared state. Defaults to a random id. */
|
|
45
54
|
workerId?: string;
|
|
46
55
|
/** Shared state is refreshed at most this often, on the next check that needs it. Defaults to 1 second. */
|
|
@@ -85,6 +94,41 @@ export interface CircuitSnapshot {
|
|
|
85
94
|
retryAt?: string;
|
|
86
95
|
/** The most recent error, as text. */
|
|
87
96
|
lastError?: string;
|
|
97
|
+
/** Every worker's calls together, as of this worker's last sync, with `shareObservations`. */
|
|
98
|
+
shared?: CircuitWindow;
|
|
99
|
+
}
|
|
100
|
+
/** Calls one worker saw since it last reported, which `CircuitStateStore.observe()` adds to the shared window. */
|
|
101
|
+
export interface CircuitObservation {
|
|
102
|
+
/** Calls and failures per window bucket, by the bucket's start in epoch milliseconds. */
|
|
103
|
+
buckets: Array<{
|
|
104
|
+
at: number;
|
|
105
|
+
calls: number;
|
|
106
|
+
failures: number;
|
|
107
|
+
}>;
|
|
108
|
+
/** Whether any of the calls succeeded, which ends the shared failure streak before `trailingFailures` is added. */
|
|
109
|
+
succeeded: boolean;
|
|
110
|
+
/** Failures after the last success, or every failure when none succeeded. */
|
|
111
|
+
trailingFailures: number;
|
|
112
|
+
/** The rolling window, in milliseconds. */
|
|
113
|
+
windowMs: number;
|
|
114
|
+
/** The width of one bucket. A bucket that ended before `now - windowMs` is dropped. */
|
|
115
|
+
bucketMs: number;
|
|
116
|
+
/** Epoch milliseconds now, by the breaker's clock. */
|
|
117
|
+
now: number;
|
|
118
|
+
/**
|
|
119
|
+
* Epoch milliseconds of the circuit's last close. Buckets that started before it are not counted, so
|
|
120
|
+
* failures that opened a circuit do not reopen it the moment a probe closes it.
|
|
121
|
+
*/
|
|
122
|
+
since?: number;
|
|
123
|
+
}
|
|
124
|
+
/** A provider's calls across every worker, inside the rolling window. */
|
|
125
|
+
export interface CircuitWindow {
|
|
126
|
+
/** Calls counted. */
|
|
127
|
+
calls: number;
|
|
128
|
+
/** Of those, failures. */
|
|
129
|
+
failures: number;
|
|
130
|
+
/** Failures since any worker last reported a success. */
|
|
131
|
+
consecutiveFailures: number;
|
|
88
132
|
}
|
|
89
133
|
/** A circuit decision as every worker sees it. */
|
|
90
134
|
export interface SharedCircuitState {
|
|
@@ -119,6 +163,12 @@ export interface CircuitStateStore {
|
|
|
119
163
|
* recovering provider is probed once rather than by every replica at the same moment.
|
|
120
164
|
*/
|
|
121
165
|
claimProbe(providerName: string, owner: string, ttlMs: number): Promise<boolean> | boolean;
|
|
166
|
+
/**
|
|
167
|
+
* Adds one worker's calls to a provider's shared window, and returns the window across every
|
|
168
|
+
* worker. Optional; `shareObservations` needs it. Each call must add atomically, so two workers
|
|
169
|
+
* reporting at once both count.
|
|
170
|
+
*/
|
|
171
|
+
observe?(providerName: string, observation: CircuitObservation): Promise<CircuitWindow> | CircuitWindow;
|
|
122
172
|
}
|
|
123
173
|
/** @internal A circuit's local state. Shared with the coordinator module, never exported publicly. */
|
|
124
174
|
export interface CircuitEntry {
|
|
@@ -139,6 +189,8 @@ export interface CircuitEntry {
|
|
|
139
189
|
probeClaimed: boolean;
|
|
140
190
|
/** Why the circuit last changed state. */
|
|
141
191
|
reason?: string;
|
|
192
|
+
/** The shared window from the last sync, with `shareObservations`. */
|
|
193
|
+
shared?: CircuitWindow;
|
|
142
194
|
}
|
|
143
195
|
/**
|
|
144
196
|
* Trips routing away from a provider that is failing.
|
|
@@ -69,6 +69,7 @@ export class CircuitBreaker {
|
|
|
69
69
|
this.refreshState(providerName, entry);
|
|
70
70
|
this.pushWindow(entry, false);
|
|
71
71
|
entry.consecutiveFailures = 0;
|
|
72
|
+
this.coordinator?.observed(providerName, false);
|
|
72
73
|
if (entry.state === 'half-open') {
|
|
73
74
|
entry.halfOpenInFlight = Math.max(0, entry.halfOpenInFlight - 1);
|
|
74
75
|
entry.halfOpenSuccesses += 1;
|
|
@@ -90,6 +91,7 @@ export class CircuitBreaker {
|
|
|
90
91
|
}
|
|
91
92
|
this.pushWindow(entry, true);
|
|
92
93
|
entry.consecutiveFailures += 1;
|
|
94
|
+
this.coordinator?.observed(providerName, true);
|
|
93
95
|
entry.lastError = error instanceof Error ? error.message : error === undefined ? undefined : String(error);
|
|
94
96
|
if (entry.state === 'half-open') {
|
|
95
97
|
entry.halfOpenInFlight = Math.max(0, entry.halfOpenInFlight - 1);
|
|
@@ -126,6 +128,7 @@ export class CircuitBreaker {
|
|
|
126
128
|
? new Date(entry.openedAt + resetTimeoutMs).toISOString()
|
|
127
129
|
: undefined,
|
|
128
130
|
lastError: entry.lastError,
|
|
131
|
+
...(entry.shared ? { shared: { ...entry.shared } } : {}),
|
|
129
132
|
};
|
|
130
133
|
});
|
|
131
134
|
}
|
|
@@ -176,6 +179,7 @@ export class CircuitBreaker {
|
|
|
176
179
|
entry.openedAt = undefined;
|
|
177
180
|
entry.consecutiveFailures = 0;
|
|
178
181
|
entry.window = [];
|
|
182
|
+
entry.shared = undefined;
|
|
179
183
|
entry.halfOpenInFlight = 0;
|
|
180
184
|
entry.halfOpenSuccesses = 0;
|
|
181
185
|
entry.probeClaimed = false;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import type { CircuitStateStore, SharedCircuitState } from './circuit-breaker.js';
|
|
2
|
-
export type { CircuitStateStore, SharedCircuitState } from './circuit-breaker.js';
|
|
1
|
+
import type { CircuitObservation, CircuitStateStore, CircuitWindow, SharedCircuitState } from './circuit-breaker.js';
|
|
2
|
+
export type { CircuitObservation, CircuitStateStore, CircuitWindow, SharedCircuitState, } from './circuit-breaker.js';
|
|
3
3
|
/**
|
|
4
4
|
* Shared circuit state in one process.
|
|
5
5
|
*
|
|
@@ -10,7 +10,10 @@ export declare class MemoryCircuitStateStore implements CircuitStateStore {
|
|
|
10
10
|
private readonly now;
|
|
11
11
|
private readonly states;
|
|
12
12
|
private readonly probes;
|
|
13
|
+
private readonly windows;
|
|
13
14
|
constructor(now?: () => number);
|
|
15
|
+
/** Adds one worker's calls to a provider's window and returns the window across every worker. */
|
|
16
|
+
observe(providerName: string, observation: CircuitObservation): CircuitWindow;
|
|
14
17
|
/** Every provider's shared circuit state. */
|
|
15
18
|
read(): SharedCircuitState[];
|
|
16
19
|
/**
|
|
@@ -66,7 +69,15 @@ export declare class RedisCircuitStateStore implements CircuitStateStore {
|
|
|
66
69
|
private readonly client;
|
|
67
70
|
private readonly prefix;
|
|
68
71
|
private readonly useEval;
|
|
72
|
+
/**
|
|
73
|
+
* Adds one worker's calls to a provider's shared window in one Lua call, and returns the window
|
|
74
|
+
* across every worker. Present only when the client has `eval`, since the add and the sum must
|
|
75
|
+
* happen together; without it, `shareObservations` has nothing to call and each worker counts
|
|
76
|
+
* its own calls.
|
|
77
|
+
*/
|
|
78
|
+
readonly observe?: (providerName: string, observation: CircuitObservation) => Promise<CircuitWindow>;
|
|
69
79
|
constructor(client: RedisCircuitLikeClient, options?: RedisCircuitStateStoreOptions);
|
|
80
|
+
private observeWithEval;
|
|
70
81
|
/** Every provider's shared circuit state. Unreadable entries are skipped. */
|
|
71
82
|
read(): Promise<SharedCircuitState[]>;
|
|
72
83
|
/**
|
|
@@ -2,9 +2,37 @@ export class MemoryCircuitStateStore {
|
|
|
2
2
|
now;
|
|
3
3
|
states = new Map();
|
|
4
4
|
probes = new Map();
|
|
5
|
+
windows = new Map();
|
|
5
6
|
constructor(now = () => Date.now()) {
|
|
6
7
|
this.now = now;
|
|
7
8
|
}
|
|
9
|
+
observe(providerName, observation) {
|
|
10
|
+
let window = this.windows.get(providerName);
|
|
11
|
+
if (!window) {
|
|
12
|
+
window = { buckets: new Map(), streak: 0 };
|
|
13
|
+
this.windows.set(providerName, window);
|
|
14
|
+
}
|
|
15
|
+
for (const bucket of observation.buckets) {
|
|
16
|
+
const stored = window.buckets.get(bucket.at) ?? { calls: 0, failures: 0 };
|
|
17
|
+
stored.calls += bucket.calls;
|
|
18
|
+
stored.failures += bucket.failures;
|
|
19
|
+
window.buckets.set(bucket.at, stored);
|
|
20
|
+
}
|
|
21
|
+
window.streak = observation.succeeded ? observation.trailingFailures : window.streak + observation.trailingFailures;
|
|
22
|
+
const keepAfter = observation.now - observation.windowMs - observation.bucketMs;
|
|
23
|
+
const since = observation.since ?? Number.NEGATIVE_INFINITY;
|
|
24
|
+
let calls = 0;
|
|
25
|
+
let failures = 0;
|
|
26
|
+
for (const [at, bucket] of window.buckets) {
|
|
27
|
+
if (at <= keepAfter)
|
|
28
|
+
window.buckets.delete(at);
|
|
29
|
+
else if (at >= since) {
|
|
30
|
+
calls += bucket.calls;
|
|
31
|
+
failures += bucket.failures;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { calls, failures, consecutiveFailures: window.streak };
|
|
35
|
+
}
|
|
8
36
|
read() {
|
|
9
37
|
return [...this.states.values()].map((state) => ({ ...state }));
|
|
10
38
|
}
|
|
@@ -34,14 +62,54 @@ redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
|
34
62
|
redis.call('DEL', KEYS[2])
|
|
35
63
|
return 1
|
|
36
64
|
`;
|
|
65
|
+
const OBSERVE_SCRIPT = `
|
|
66
|
+
local key = KEYS[1]
|
|
67
|
+
for i = 6, #ARGV, 3 do
|
|
68
|
+
redis.call('HINCRBY', key, 'c:' .. ARGV[i], ARGV[i + 1])
|
|
69
|
+
redis.call('HINCRBY', key, 'f:' .. ARGV[i], ARGV[i + 2])
|
|
70
|
+
end
|
|
71
|
+
local streak
|
|
72
|
+
if ARGV[1] == '1' then
|
|
73
|
+
redis.call('HSET', key, 'streak', ARGV[2])
|
|
74
|
+
streak = tonumber(ARGV[2])
|
|
75
|
+
else
|
|
76
|
+
streak = tonumber(redis.call('HINCRBY', key, 'streak', ARGV[2]))
|
|
77
|
+
end
|
|
78
|
+
local keepAfter = tonumber(ARGV[3])
|
|
79
|
+
local since = tonumber(ARGV[4])
|
|
80
|
+
local calls, failures = 0, 0
|
|
81
|
+
local fields = redis.call('HGETALL', key)
|
|
82
|
+
for i = 1, #fields, 2 do
|
|
83
|
+
local name = fields[i]
|
|
84
|
+
local kind = string.sub(name, 1, 2)
|
|
85
|
+
if kind == 'c:' or kind == 'f:' then
|
|
86
|
+
local at = tonumber(string.sub(name, 3))
|
|
87
|
+
if at <= keepAfter then
|
|
88
|
+
redis.call('HDEL', key, name)
|
|
89
|
+
elseif at >= since then
|
|
90
|
+
if kind == 'c:' then calls = calls + tonumber(fields[i + 1]) else failures = failures + tonumber(fields[i + 1]) end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
redis.call('PEXPIRE', key, ARGV[5])
|
|
95
|
+
return { calls, failures, streak }
|
|
96
|
+
`;
|
|
37
97
|
export class RedisCircuitStateStore {
|
|
38
98
|
client;
|
|
39
99
|
prefix;
|
|
40
100
|
useEval;
|
|
101
|
+
observe;
|
|
41
102
|
constructor(client, options = {}) {
|
|
42
103
|
this.client = client;
|
|
43
104
|
this.prefix = options.prefix ?? 'nexus-ai-pro:circuits:';
|
|
44
105
|
this.useEval = options.useEval !== false && typeof client.eval === 'function';
|
|
106
|
+
if (this.useEval)
|
|
107
|
+
this.observe = (providerName, observation) => this.observeWithEval(providerName, observation);
|
|
108
|
+
}
|
|
109
|
+
async observeWithEval(providerName, observation) {
|
|
110
|
+
const reply = (await this.client.eval(OBSERVE_SCRIPT, 1, `${this.prefix}window:${providerName}`, observation.succeeded ? '1' : '0', String(observation.trailingFailures), String(observation.now - observation.windowMs - observation.bucketMs), String(observation.since ?? -1), String(observation.windowMs * 2 + observation.bucketMs), ...observation.buckets.flatMap((bucket) => [String(bucket.at), String(bucket.calls), String(bucket.failures)])));
|
|
111
|
+
const [calls, failures, streak] = (reply ?? []).map(Number);
|
|
112
|
+
return { calls: calls ?? 0, failures: failures ?? 0, consecutiveFailures: streak ?? 0 };
|
|
45
113
|
}
|
|
46
114
|
async read() {
|
|
47
115
|
const all = (await this.client.hgetall(this.statesKey())) ?? {};
|
|
@@ -26,6 +26,8 @@ export declare class CircuitCoordinator {
|
|
|
26
26
|
private lastSyncAt;
|
|
27
27
|
/** False after a store failure, until a sync succeeds; the breaker then decides locally. */
|
|
28
28
|
private reachable;
|
|
29
|
+
/** Calls not yet added to the shared window, per provider, with `shareObservations`. */
|
|
30
|
+
private readonly unreported;
|
|
29
31
|
constructor(host: CircuitHost);
|
|
30
32
|
/**
|
|
31
33
|
* Whether this worker may go half-open once a cooldown has elapsed. With a reachable store only
|
|
@@ -35,6 +37,11 @@ export declare class CircuitCoordinator {
|
|
|
35
37
|
/** Starts a background sync when the last one is older than the interval. Never waits. */
|
|
36
38
|
tick(): void;
|
|
37
39
|
sync(): Promise<void>;
|
|
40
|
+
/**
|
|
41
|
+
* Counts one call toward the shared window, to be added on the next sync. Cheap and synchronous:
|
|
42
|
+
* a check never waits for the store.
|
|
43
|
+
*/
|
|
44
|
+
observed(providerName: string, failed: boolean): void;
|
|
38
45
|
/** Publishes a transition this worker made. */
|
|
39
46
|
published(providerName: string, entry: CircuitEntry, to: 'open' | 'closed', reason: string): void;
|
|
40
47
|
/** Publishes an operator reset, so every worker closes the circuit rather than only this one. */
|
|
@@ -45,6 +52,14 @@ export declare class CircuitCoordinator {
|
|
|
45
52
|
*/
|
|
46
53
|
reconcile(): void;
|
|
47
54
|
flush(): Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Adds this worker's unreported calls to each provider's shared window, then opens a closed circuit
|
|
57
|
+
* whose shared count crossed a threshold. The batch is taken before the first await, so calls made
|
|
58
|
+
* while it is in flight wait for the next sync rather than being lost or counted twice.
|
|
59
|
+
*/
|
|
60
|
+
private report;
|
|
61
|
+
/** Opens a closed circuit whose shared window crossed the breaker's thresholds. */
|
|
62
|
+
private judge;
|
|
48
63
|
/** Takes a decision another worker published, when it is newer than what this worker knows. */
|
|
49
64
|
private adopt;
|
|
50
65
|
/**
|
|
@@ -55,3 +70,5 @@ export declare class CircuitCoordinator {
|
|
|
55
70
|
private publish;
|
|
56
71
|
private failed;
|
|
57
72
|
}
|
|
73
|
+
/** The width of one shared-window bucket: a twelfth of the window, so a bucket rolling out moves the count by little. */
|
|
74
|
+
export declare function bucketWidth(windowMs: number): number;
|
package/dist/ops/circuit-sync.js
CHANGED
|
@@ -4,6 +4,7 @@ export class CircuitCoordinator {
|
|
|
4
4
|
syncing;
|
|
5
5
|
lastSyncAt = Number.NEGATIVE_INFINITY;
|
|
6
6
|
reachable = true;
|
|
7
|
+
unreported = new Map();
|
|
7
8
|
constructor(host) {
|
|
8
9
|
this.host = host;
|
|
9
10
|
}
|
|
@@ -26,6 +27,8 @@ export class CircuitCoordinator {
|
|
|
26
27
|
this.syncing = (async () => {
|
|
27
28
|
try {
|
|
28
29
|
this.lastSyncAt = this.host.now();
|
|
30
|
+
if (this.unreported.size > 0)
|
|
31
|
+
await this.report(store);
|
|
29
32
|
for (const shared of await store.read())
|
|
30
33
|
this.adopt(shared);
|
|
31
34
|
const resetTimeoutMs = this.host.config.resetTimeoutMs ?? 30_000;
|
|
@@ -51,6 +54,29 @@ export class CircuitCoordinator {
|
|
|
51
54
|
})();
|
|
52
55
|
return this.syncing;
|
|
53
56
|
}
|
|
57
|
+
observed(providerName, failed) {
|
|
58
|
+
if (!this.host.config.shareObservations || !this.host.config.store?.observe)
|
|
59
|
+
return;
|
|
60
|
+
const bucketMs = bucketWidth(this.host.config.windowMs ?? 60_000);
|
|
61
|
+
const at = Math.floor(this.host.now() / bucketMs) * bucketMs;
|
|
62
|
+
let pending = this.unreported.get(providerName);
|
|
63
|
+
if (!pending) {
|
|
64
|
+
pending = { buckets: new Map(), succeeded: false, trailing: 0 };
|
|
65
|
+
this.unreported.set(providerName, pending);
|
|
66
|
+
}
|
|
67
|
+
const bucket = pending.buckets.get(at) ?? { calls: 0, failures: 0 };
|
|
68
|
+
bucket.calls += 1;
|
|
69
|
+
if (failed)
|
|
70
|
+
bucket.failures += 1;
|
|
71
|
+
pending.buckets.set(at, bucket);
|
|
72
|
+
if (failed)
|
|
73
|
+
pending.trailing += 1;
|
|
74
|
+
else {
|
|
75
|
+
pending.succeeded = true;
|
|
76
|
+
pending.trailing = 0;
|
|
77
|
+
}
|
|
78
|
+
this.tick();
|
|
79
|
+
}
|
|
54
80
|
published(providerName, entry, to, reason) {
|
|
55
81
|
entry.changedAt = this.stampAfter(entry.changedAt);
|
|
56
82
|
this.publish({
|
|
@@ -72,6 +98,23 @@ export class CircuitCoordinator {
|
|
|
72
98
|
reconcile() {
|
|
73
99
|
for (const name of [...this.host.names()]) {
|
|
74
100
|
const entry = this.host.entry(name);
|
|
101
|
+
if (this.host.config.shareObservations && this.host.config.store?.observe && entry.window.length > 0) {
|
|
102
|
+
const bucketMs = bucketWidth(this.host.config.windowMs ?? 60_000);
|
|
103
|
+
const buckets = new Map();
|
|
104
|
+
for (const call of entry.window) {
|
|
105
|
+
const at = Math.floor(call.at / bucketMs) * bucketMs;
|
|
106
|
+
const bucket = buckets.get(at) ?? { calls: 0, failures: 0 };
|
|
107
|
+
bucket.calls += 1;
|
|
108
|
+
if (call.failed)
|
|
109
|
+
bucket.failures += 1;
|
|
110
|
+
buckets.set(at, bucket);
|
|
111
|
+
}
|
|
112
|
+
this.unreported.set(name, {
|
|
113
|
+
buckets,
|
|
114
|
+
succeeded: entry.window.some((call) => !call.failed),
|
|
115
|
+
trailing: entry.consecutiveFailures,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
75
118
|
if (entry.state === 'open' && entry.changedAt === Number.NEGATIVE_INFINITY) {
|
|
76
119
|
this.published(name, entry, 'open', entry.reason ?? 'opened while shared state was loading');
|
|
77
120
|
}
|
|
@@ -81,6 +124,46 @@ export class CircuitCoordinator {
|
|
|
81
124
|
async flush() {
|
|
82
125
|
await Promise.all([...this.pending]);
|
|
83
126
|
}
|
|
127
|
+
async report(store) {
|
|
128
|
+
if (!store.observe || this.unreported.size === 0)
|
|
129
|
+
return;
|
|
130
|
+
const batches = [...this.unreported];
|
|
131
|
+
this.unreported.clear();
|
|
132
|
+
const windowMs = this.host.config.windowMs ?? 60_000;
|
|
133
|
+
for (const [name, pending] of batches) {
|
|
134
|
+
const entry = this.host.entry(name);
|
|
135
|
+
const window = await store.observe(name, {
|
|
136
|
+
buckets: [...pending.buckets].map(([at, bucket]) => ({ at, calls: bucket.calls, failures: bucket.failures })),
|
|
137
|
+
succeeded: pending.succeeded,
|
|
138
|
+
trailingFailures: pending.trailing,
|
|
139
|
+
windowMs,
|
|
140
|
+
bucketMs: bucketWidth(windowMs),
|
|
141
|
+
now: this.host.now(),
|
|
142
|
+
...(entry.state === 'closed' && Number.isFinite(entry.changedAt) ? { since: entry.changedAt } : {}),
|
|
143
|
+
});
|
|
144
|
+
entry.shared = {
|
|
145
|
+
calls: window.calls,
|
|
146
|
+
failures: window.failures,
|
|
147
|
+
consecutiveFailures: window.consecutiveFailures,
|
|
148
|
+
};
|
|
149
|
+
this.judge(name, entry, window);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
judge(name, entry, window) {
|
|
153
|
+
if (entry.state !== 'closed')
|
|
154
|
+
return;
|
|
155
|
+
const config = this.host.config;
|
|
156
|
+
if (window.consecutiveFailures >= (config.failureThreshold ?? 5)) {
|
|
157
|
+
this.host.transition(name, entry, 'open', 'shared failure threshold reached');
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
const rate = config.failureRateThreshold;
|
|
161
|
+
if (rate !== undefined &&
|
|
162
|
+
window.calls >= (config.minimumThroughput ?? 10) &&
|
|
163
|
+
window.failures / window.calls >= rate) {
|
|
164
|
+
this.host.transition(name, entry, 'open', 'shared failure rate threshold reached');
|
|
165
|
+
}
|
|
166
|
+
}
|
|
84
167
|
adopt(shared) {
|
|
85
168
|
const entry = this.host.entry(shared.providerName);
|
|
86
169
|
if (shared.updatedAt <= entry.changedAt)
|
|
@@ -120,3 +203,6 @@ export class CircuitCoordinator {
|
|
|
120
203
|
}
|
|
121
204
|
}
|
|
122
205
|
}
|
|
206
|
+
export function bucketWidth(windowMs) {
|
|
207
|
+
return Math.max(1, Math.floor(windowMs / 12));
|
|
208
|
+
}
|
package/dist/ops/health.d.ts
CHANGED
|
@@ -21,7 +21,18 @@ export interface ProviderHealthSnapshot {
|
|
|
21
21
|
* latency up to 30.
|
|
22
22
|
*/
|
|
23
23
|
score: number;
|
|
24
|
+
/**
|
|
25
|
+
* `unknown` with no recent calls to judge by, `unhealthy` past the failure threshold or under the
|
|
26
|
+
* minimum score, `degraded` when calls are failing or slow but not enough to stop routing to it,
|
|
27
|
+
* and `healthy` otherwise. Always set by `ProviderHealthMonitor`; optional so a hand-built snapshot
|
|
28
|
+
* still type-checks.
|
|
29
|
+
*/
|
|
30
|
+
status?: ProviderHealthStatus;
|
|
31
|
+
/** True when the last call is older than `observationTtlMs`, so the figures above are old news. */
|
|
32
|
+
stale?: boolean;
|
|
24
33
|
}
|
|
34
|
+
/** Where a provider stands, by what its recent calls say. */
|
|
35
|
+
export type ProviderHealthStatus = 'healthy' | 'degraded' | 'unhealthy' | 'unknown';
|
|
25
36
|
/** Health tracking for providers, used to route around unhealthy ones. */
|
|
26
37
|
export interface HealthConfig {
|
|
27
38
|
/** Turns tracking on. Off by default, and then nothing is recorded. */
|
|
@@ -30,12 +41,22 @@ export interface HealthConfig {
|
|
|
30
41
|
failureThreshold?: number;
|
|
31
42
|
/** Score below which a provider is unhealthy. Defaults to 20. */
|
|
32
43
|
minScore?: number;
|
|
44
|
+
/**
|
|
45
|
+
* How long a call's outcome counts, in milliseconds. A provider whose last call is older is
|
|
46
|
+
* `unknown`, and is routed to as a provider never seen is, rather than kept down for a failure
|
|
47
|
+
* hours ago or kept up for a success days ago. `checkProviders({ staleOnly: true })` refreshes it.
|
|
48
|
+
* Defaults to never expiring, as before.
|
|
49
|
+
*/
|
|
50
|
+
observationTtlMs?: number;
|
|
51
|
+
/** Replaces the system clock, for tests. */
|
|
52
|
+
now?: () => Date;
|
|
33
53
|
}
|
|
34
54
|
/** Tracks provider health from call outcomes and scores each provider for routing. */
|
|
35
55
|
export declare class ProviderHealthMonitor {
|
|
36
56
|
private config;
|
|
37
57
|
private states;
|
|
38
58
|
constructor(config?: HealthConfig);
|
|
59
|
+
private nowDate;
|
|
39
60
|
/** Records a successful call and its latency. */
|
|
40
61
|
recordSuccess(providerName: string, latencyMs: number): void;
|
|
41
62
|
/** Records a failed call. */
|
|
@@ -46,6 +67,9 @@ export declare class ProviderHealthMonitor {
|
|
|
46
67
|
score(providerName: string): number;
|
|
47
68
|
/** Whether a provider is healthy. */
|
|
48
69
|
isHealthy(providerName: string): boolean;
|
|
70
|
+
/** Whether a provider's figures are too old to judge by, or there are none. */
|
|
71
|
+
isUnknown(providerName: string): boolean;
|
|
72
|
+
private isStale;
|
|
49
73
|
private snapshotOne;
|
|
50
74
|
private state;
|
|
51
75
|
}
|
package/dist/ops/health.js
CHANGED
|
@@ -4,23 +4,30 @@ export class ProviderHealthMonitor {
|
|
|
4
4
|
constructor(config = {}) {
|
|
5
5
|
this.config = config;
|
|
6
6
|
}
|
|
7
|
+
nowDate() {
|
|
8
|
+
return this.config.now?.() ?? new Date();
|
|
9
|
+
}
|
|
7
10
|
recordSuccess(providerName, latencyMs) {
|
|
8
11
|
if (!this.config.enabled)
|
|
9
12
|
return;
|
|
10
13
|
const state = this.state(providerName);
|
|
11
14
|
state.successes += 1;
|
|
12
15
|
state.consecutiveFailures = 0;
|
|
16
|
+
if (this.isStale(state))
|
|
17
|
+
Object.assign(state, freshState());
|
|
13
18
|
state.avgLatencyMs = state.avgLatencyMs ? state.avgLatencyMs * 0.7 + latencyMs * 0.3 : latencyMs;
|
|
14
|
-
state.lastCheckedAt =
|
|
19
|
+
state.lastCheckedAt = this.nowDate().toISOString();
|
|
15
20
|
}
|
|
16
21
|
recordFailure(providerName, error) {
|
|
17
22
|
if (!this.config.enabled)
|
|
18
23
|
return;
|
|
19
24
|
const state = this.state(providerName);
|
|
25
|
+
if (this.isStale(state))
|
|
26
|
+
state.consecutiveFailures = 0;
|
|
20
27
|
state.failures += 1;
|
|
21
28
|
state.consecutiveFailures += 1;
|
|
22
29
|
state.lastError = error instanceof Error ? error.message : String(error);
|
|
23
|
-
state.lastCheckedAt =
|
|
30
|
+
state.lastCheckedAt = this.nowDate().toISOString();
|
|
24
31
|
}
|
|
25
32
|
snapshot(providerName) {
|
|
26
33
|
const names = providerName ? [providerName] : [...this.states.keys()];
|
|
@@ -32,16 +39,39 @@ export class ProviderHealthMonitor {
|
|
|
32
39
|
isHealthy(providerName) {
|
|
33
40
|
return this.snapshotOne(providerName).healthy;
|
|
34
41
|
}
|
|
42
|
+
isUnknown(providerName) {
|
|
43
|
+
return this.snapshotOne(providerName).status === 'unknown';
|
|
44
|
+
}
|
|
45
|
+
isStale(state) {
|
|
46
|
+
const ttl = this.config.observationTtlMs;
|
|
47
|
+
if (ttl === undefined || !state.lastCheckedAt)
|
|
48
|
+
return false;
|
|
49
|
+
return this.nowDate().getTime() - Date.parse(state.lastCheckedAt) > ttl;
|
|
50
|
+
}
|
|
35
51
|
snapshotOne(providerName) {
|
|
36
|
-
const
|
|
52
|
+
const recorded = this.state(providerName);
|
|
53
|
+
const stale = this.isStale(recorded);
|
|
54
|
+
const state = stale
|
|
55
|
+
? { ...freshState(), lastError: recorded.lastError, lastCheckedAt: recorded.lastCheckedAt }
|
|
56
|
+
: recorded;
|
|
37
57
|
const failureThreshold = this.config.failureThreshold || 3;
|
|
38
58
|
const failurePenalty = Math.min(60, state.consecutiveFailures * 20);
|
|
39
59
|
const latencyPenalty = Math.min(30, state.avgLatencyMs / 1000);
|
|
40
60
|
const score = Math.max(0, 100 - failurePenalty - latencyPenalty);
|
|
41
61
|
const minScore = this.config.minScore ?? 20;
|
|
62
|
+
const healthy = state.consecutiveFailures < failureThreshold && score >= minScore;
|
|
63
|
+
const seen = recorded.successes + recorded.failures > 0;
|
|
42
64
|
return {
|
|
43
65
|
providerName,
|
|
44
|
-
healthy
|
|
66
|
+
healthy,
|
|
67
|
+
status: stale || !seen
|
|
68
|
+
? 'unknown'
|
|
69
|
+
: !healthy
|
|
70
|
+
? 'unhealthy'
|
|
71
|
+
: state.consecutiveFailures > 0 || score < 70
|
|
72
|
+
? 'degraded'
|
|
73
|
+
: 'healthy',
|
|
74
|
+
stale,
|
|
45
75
|
successes: state.successes,
|
|
46
76
|
failures: state.failures,
|
|
47
77
|
consecutiveFailures: state.consecutiveFailures,
|
|
@@ -54,14 +84,12 @@ export class ProviderHealthMonitor {
|
|
|
54
84
|
state(providerName) {
|
|
55
85
|
let state = this.states.get(providerName);
|
|
56
86
|
if (!state) {
|
|
57
|
-
state =
|
|
58
|
-
successes: 0,
|
|
59
|
-
failures: 0,
|
|
60
|
-
consecutiveFailures: 0,
|
|
61
|
-
avgLatencyMs: 0,
|
|
62
|
-
};
|
|
87
|
+
state = freshState();
|
|
63
88
|
this.states.set(providerName, state);
|
|
64
89
|
}
|
|
65
90
|
return state;
|
|
66
91
|
}
|
|
67
92
|
}
|
|
93
|
+
function freshState() {
|
|
94
|
+
return { successes: 0, failures: 0, consecutiveFailures: 0, avgLatencyMs: 0 };
|
|
95
|
+
}
|