nexus-ai-pro 2.1.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 +38 -0
- package/CHANGELOG.md +145 -1
- package/README.md +13 -8
- 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.js +12 -1
- package/dist/core/lifecycle.js +11 -6
- package/dist/core/nexus.d.ts +6 -0
- package/dist/core/nexus.js +103 -22
- package/dist/doctor/index.d.ts +90 -0
- package/dist/doctor/index.js +237 -0
- package/dist/graph/functional.d.ts +80 -7
- package/dist/graph/functional.js +138 -35
- package/dist/graph/graph.d.ts +15 -5
- package/dist/graph/graph.js +72 -20
- 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 +1 -0
- 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/rate-limiter.d.ts +2 -0
- package/dist/ops/rate-limiter.js +2 -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 +4 -0
- package/dist/server/assistant.js +8 -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 +24 -1
- package/dist/server/runs.js +90 -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/tracing/instrument.js +2 -0
- package/dist/types/config.d.ts +24 -2
- package/dist/types/graph.d.ts +11 -1
- package/dist/types/messages.d.ts +5 -0
- package/dist/types/operations.d.ts +6 -0
- package/dist/types/response.d.ts +20 -0
- package/dist/types/server.d.ts +10 -1
- 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 +1 -1
- package/dist/version.js +1 -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/core/lifecycle.js +11 -6
- package/dist-cjs/core/nexus.d.ts +6 -0
- package/dist-cjs/core/nexus.js +103 -22
- package/dist-cjs/doctor/index.d.ts +90 -0
- package/dist-cjs/doctor/index.js +276 -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 +15 -5
- package/dist-cjs/graph/graph.js +72 -20
- 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 +1 -0
- 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/rate-limiter.d.ts +2 -0
- package/dist-cjs/ops/rate-limiter.js +2 -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 +4 -0
- package/dist-cjs/server/assistant.js +8 -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 +24 -1
- package/dist-cjs/server/runs.js +89 -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/tracing/instrument.js +2 -0
- package/dist-cjs/types/config.d.ts +24 -2
- package/dist-cjs/types/graph.d.ts +11 -1
- package/dist-cjs/types/messages.d.ts +5 -0
- package/dist-cjs/types/operations.d.ts +6 -0
- package/dist-cjs/types/response.d.ts +20 -0
- package/dist-cjs/types/server.d.ts +10 -1
- 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 +1 -1
- package/dist-cjs/version.js +1 -1
- package/llms.txt +4 -3
- package/package.json +71 -2
|
@@ -3,7 +3,8 @@ import type { OperationDispatcher, OperationRecord, OperationStore, OperationSto
|
|
|
3
3
|
* The Redis commands the operation store needs.
|
|
4
4
|
*
|
|
5
5
|
* Structural rather than tied to one client, so `ioredis`, `node-redis`, or a cluster proxy all
|
|
6
|
-
* satisfy it without this package depending on any of them.
|
|
6
|
+
* satisfy it without this package depending on any of them. The sorted-set commands are needed only
|
|
7
|
+
* with `index: true`; `ioredis` has them all.
|
|
7
8
|
*/
|
|
8
9
|
export interface RedisOperationLikeClient {
|
|
9
10
|
/** Reads a hash field. */
|
|
@@ -16,6 +17,14 @@ export interface RedisOperationLikeClient {
|
|
|
16
17
|
hvals(key: string): Promise<string[]> | string[];
|
|
17
18
|
/** Optional CAS primitive. When absent the store falls back to a read-compare-write. */
|
|
18
19
|
eval?(script: string, numKeys: number, ...args: string[]): Promise<unknown> | unknown;
|
|
20
|
+
/** Reads several hash fields in one round trip. Used by the index when present. */
|
|
21
|
+
hmget?(key: string, ...fields: string[]): Promise<Array<string | null>> | Array<string | null>;
|
|
22
|
+
/** Adds a sorted-set member. Needed with `index: true`. */
|
|
23
|
+
zadd?(key: string, score: number, member: string): Promise<unknown> | unknown;
|
|
24
|
+
/** Removes a sorted-set member. Needed with `index: true`. */
|
|
25
|
+
zrem?(key: string, member: string): Promise<unknown> | unknown;
|
|
26
|
+
/** Members by score, as `ZRANGEBYSCORE key min max LIMIT offset count`. Needed with `index: true`. */
|
|
27
|
+
zrangebyscore?(key: string, min: number | string, max: number | string, limit: 'LIMIT', offset: number, count: number): Promise<string[]> | string[];
|
|
19
28
|
}
|
|
20
29
|
/** Options for the Redis operation store. */
|
|
21
30
|
export interface RedisOperationStoreOptions {
|
|
@@ -23,6 +32,19 @@ export interface RedisOperationStoreOptions {
|
|
|
23
32
|
prefix?: string;
|
|
24
33
|
/** Disables the Lua compare-and-set even when the client exposes `eval`. */
|
|
25
34
|
useEval?: boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Keeps queued work in a sorted set by age, and leased work in one by lease expiry, so
|
|
37
|
+
* `listQueued()` and `claimExpired()` read the oldest few records instead of every one: a claim
|
|
38
|
+
* costs the same with 100,000 operations queued as with 1,000. The records stay the source of
|
|
39
|
+
* truth; an index entry only says where to look, and one that no longer matches its record is
|
|
40
|
+
* skipped and removed.
|
|
41
|
+
*
|
|
42
|
+
* Off by default, because a 2.1 worker writes records without index entries. Turn it on once every
|
|
43
|
+
* worker sharing the prefix runs 2.2, then call `reindex()` once to index what is already there.
|
|
44
|
+
* Needs `zadd`, `zrem`, and `zrangebyscore`; with `eval`, a record and its index entries change in
|
|
45
|
+
* one step.
|
|
46
|
+
*/
|
|
47
|
+
index?: boolean;
|
|
26
48
|
}
|
|
27
49
|
/**
|
|
28
50
|
* Redis-backed operation storage, so a submitted operation survives a restart.
|
|
@@ -36,8 +58,13 @@ export declare class RedisOperationStore<TResult = unknown> implements Operation
|
|
|
36
58
|
private readonly client;
|
|
37
59
|
private readonly prefix;
|
|
38
60
|
private readonly useEval;
|
|
61
|
+
private readonly indexed;
|
|
39
62
|
constructor(client: RedisOperationLikeClient, options?: RedisOperationStoreOptions);
|
|
40
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Stores a new record and indexes its idempotency key. Throws `OperationDuplicateError` when another
|
|
65
|
+
* record holds the key, checked in the same Lua call as the write when the client has `eval`.
|
|
66
|
+
* Refuses records carrying raw bytes.
|
|
67
|
+
*/
|
|
41
68
|
create(record: OperationRecord<TResult>): Promise<void>;
|
|
42
69
|
/** Reads a record. */
|
|
43
70
|
read(id: string): Promise<OperationRecord<TResult> | undefined>;
|
|
@@ -50,19 +77,40 @@ export declare class RedisOperationStore<TResult = unknown> implements Operation
|
|
|
50
77
|
delete(id: string): Promise<boolean>;
|
|
51
78
|
/**
|
|
52
79
|
* Records whose lease has expired, or running records without one, up to `limit`, for another
|
|
53
|
-
* worker to take over.
|
|
80
|
+
* worker to take over. With the index, reads only records whose lease has lapsed.
|
|
54
81
|
*/
|
|
55
82
|
claimExpired(now: string, limit: number): Promise<Array<OperationRecord<TResult>>>;
|
|
56
83
|
/** Finds the record that claimed an idempotency key. */
|
|
57
84
|
findByIdempotencyKey(key: string): Promise<OperationRecord<TResult> | undefined>;
|
|
58
85
|
/** Every record. */
|
|
59
86
|
list(): Promise<Array<OperationRecord<TResult>>>;
|
|
60
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Queued records no worker holds, oldest first. With the index, reads them oldest first from the
|
|
89
|
+
* sorted set and stops at `limit`; without it, reads every record, as `list()` does.
|
|
90
|
+
*/
|
|
61
91
|
listQueued(limit: number, filter?: OperationStoreFilter): Promise<Array<OperationRecord<TResult>>>;
|
|
62
92
|
/** Counts unfinished records by status. Reads every record, as `list()` does. */
|
|
63
93
|
stats(now: string, filter?: OperationStoreFilter): Promise<OperationStoreStats>;
|
|
94
|
+
/**
|
|
95
|
+
* Indexes every record already stored, for turning `index` on over existing data. Safe to run
|
|
96
|
+
* while workers are busy, and to run again: it only adds entries, then removes those that no
|
|
97
|
+
* longer match their record, checking each record as it does. Resolves to how many records it read.
|
|
98
|
+
*/
|
|
99
|
+
reindex(): Promise<number>;
|
|
100
|
+
/**
|
|
101
|
+
* Reads one index in score order up to `max`, a page at a time, keeping records that pass `keep`
|
|
102
|
+
* until `limit`. Entries whose record is gone or no longer `belongs` are removed afterwards.
|
|
103
|
+
*/
|
|
104
|
+
private scanIndex;
|
|
105
|
+
private readMany;
|
|
106
|
+
/** Removes index entries that no longer describe their record. Only with `eval`, where the check is atomic. */
|
|
107
|
+
private prune;
|
|
108
|
+
/** Writes a record's index entries without `eval`, after the record itself. */
|
|
109
|
+
private place;
|
|
64
110
|
private recordsKey;
|
|
65
111
|
private idempotencyKey;
|
|
112
|
+
private queuedKey;
|
|
113
|
+
private leasesKey;
|
|
66
114
|
}
|
|
67
115
|
/** The part of a BullMQ `Queue` the dispatcher needs. */
|
|
68
116
|
export interface BullMQLikeOperationQueue {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { isTerminalOperationStatus } from '../types/operations.js';
|
|
1
|
+
import { isTerminalOperationStatus, TERMINAL_OPERATION_STATUSES } 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
|
const CAS_SCRIPT = `
|
|
@@ -9,21 +10,102 @@ if tonumber(decoded.sequence) ~= tonumber(ARGV[2]) then return 0 end
|
|
|
9
10
|
redis.call('HSET', KEYS[1], ARGV[1], ARGV[3])
|
|
10
11
|
return 1
|
|
11
12
|
`;
|
|
13
|
+
const PLACE_LUA = `
|
|
14
|
+
local function place(key, score, id)
|
|
15
|
+
if score ~= '' then redis.call('ZADD', key, score, id) else redis.call('ZREM', key, id) end
|
|
16
|
+
end
|
|
17
|
+
`;
|
|
18
|
+
const UNIQUE_KEY_LUA = `
|
|
19
|
+
if ARGV[3] ~= '' then
|
|
20
|
+
local owner = redis.call('HGET', KEYS[2], ARGV[3])
|
|
21
|
+
if owner and owner ~= ARGV[1] and redis.call('HEXISTS', KEYS[1], owner) == 1 then return 0 end
|
|
22
|
+
end
|
|
23
|
+
`;
|
|
24
|
+
const CREATE_SCRIPT = `${UNIQUE_KEY_LUA}
|
|
25
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
26
|
+
if ARGV[3] ~= '' then redis.call('HSET', KEYS[2], ARGV[3], ARGV[1]) end
|
|
27
|
+
return 1
|
|
28
|
+
`;
|
|
29
|
+
const INDEXED_CREATE_SCRIPT = `${PLACE_LUA}${UNIQUE_KEY_LUA}
|
|
30
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[2])
|
|
31
|
+
if ARGV[3] ~= '' then redis.call('HSET', KEYS[2], ARGV[3], ARGV[1]) end
|
|
32
|
+
place(KEYS[3], ARGV[4], ARGV[1])
|
|
33
|
+
place(KEYS[4], ARGV[5], ARGV[1])
|
|
34
|
+
return 1
|
|
35
|
+
`;
|
|
36
|
+
const INDEXED_CAS_SCRIPT = `${PLACE_LUA}
|
|
37
|
+
local current = redis.call('HGET', KEYS[1], ARGV[1])
|
|
38
|
+
if current == false then return 0 end
|
|
39
|
+
local decoded = cjson.decode(current)
|
|
40
|
+
if tonumber(decoded.sequence) ~= tonumber(ARGV[2]) then return 0 end
|
|
41
|
+
redis.call('HSET', KEYS[1], ARGV[1], ARGV[3])
|
|
42
|
+
place(KEYS[2], ARGV[4], ARGV[1])
|
|
43
|
+
place(KEYS[3], ARGV[5], ARGV[1])
|
|
44
|
+
return 1
|
|
45
|
+
`;
|
|
46
|
+
const PRUNE_SCRIPT = `
|
|
47
|
+
local terminal = { ${TERMINAL_OPERATION_STATUSES.map((status) => `${status} = true`).join(', ')} }
|
|
48
|
+
local removed = 0
|
|
49
|
+
for i = 2, #ARGV do
|
|
50
|
+
local raw = redis.call('HGET', KEYS[1], ARGV[i])
|
|
51
|
+
local keep = false
|
|
52
|
+
if raw then
|
|
53
|
+
local record = cjson.decode(raw)
|
|
54
|
+
if ARGV[1] == 'queued' then
|
|
55
|
+
keep = record.status == 'queued'
|
|
56
|
+
else
|
|
57
|
+
keep = (not terminal[record.status]) and (record.lease ~= nil or record.status == 'running')
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
if not keep then
|
|
61
|
+
redis.call('ZREM', KEYS[2], ARGV[i])
|
|
62
|
+
removed = removed + 1
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
return removed
|
|
66
|
+
`;
|
|
12
67
|
export class RedisOperationStore {
|
|
13
68
|
client;
|
|
14
69
|
prefix;
|
|
15
70
|
useEval;
|
|
71
|
+
indexed;
|
|
16
72
|
constructor(client, options = {}) {
|
|
17
73
|
this.client = client;
|
|
18
74
|
this.prefix = options.prefix ?? 'nexus-ai-pro:operations:';
|
|
19
75
|
this.useEval = options.useEval !== false && typeof client.eval === 'function';
|
|
76
|
+
this.indexed = options.index === true;
|
|
77
|
+
if (this.indexed && !(client.zadd && client.zrem && client.zrangebyscore)) {
|
|
78
|
+
throw new TypeError('A Redis operation store with index: true needs zadd, zrem, and zrangebyscore');
|
|
79
|
+
}
|
|
20
80
|
}
|
|
21
81
|
async create(record) {
|
|
22
82
|
assertSerializableRecord(record);
|
|
23
|
-
|
|
83
|
+
const encoded = JSON.stringify(record);
|
|
84
|
+
const duplicate = () => new OperationDuplicateError(record.idempotencyKey);
|
|
85
|
+
if (this.useEval && this.client.eval && !this.indexed) {
|
|
86
|
+
const created = await this.client.eval(CREATE_SCRIPT, 2, this.recordsKey(), this.idempotencyKey(), record.id, encoded, record.idempotencyKey ?? '');
|
|
87
|
+
if (Number(created) !== 1)
|
|
88
|
+
throw duplicate();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (this.indexed && this.useEval && this.client.eval) {
|
|
92
|
+
const { queued, lease } = indexScores(record);
|
|
93
|
+
const created = await this.client.eval(INDEXED_CREATE_SCRIPT, 4, this.recordsKey(), this.idempotencyKey(), this.queuedKey(), this.leasesKey(), record.id, encoded, record.idempotencyKey ?? '', queued, lease);
|
|
94
|
+
if (Number(created) !== 1)
|
|
95
|
+
throw duplicate();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (record.idempotencyKey) {
|
|
99
|
+
const owner = await this.client.hget(this.idempotencyKey(), record.idempotencyKey);
|
|
100
|
+
if (owner && owner !== record.id && (await this.client.hget(this.recordsKey(), owner)))
|
|
101
|
+
throw duplicate();
|
|
102
|
+
}
|
|
103
|
+
await this.client.hset(this.recordsKey(), record.id, encoded);
|
|
24
104
|
if (record.idempotencyKey) {
|
|
25
105
|
await this.client.hset(this.idempotencyKey(), record.idempotencyKey, record.id);
|
|
26
106
|
}
|
|
107
|
+
if (this.indexed)
|
|
108
|
+
await this.place(record);
|
|
27
109
|
}
|
|
28
110
|
async read(id) {
|
|
29
111
|
const raw = await this.client.hget(this.recordsKey(), id);
|
|
@@ -33,6 +115,11 @@ export class RedisOperationStore {
|
|
|
33
115
|
assertSerializableRecord(record);
|
|
34
116
|
const encoded = JSON.stringify(record);
|
|
35
117
|
if (this.useEval && this.client.eval) {
|
|
118
|
+
if (this.indexed) {
|
|
119
|
+
const { queued, lease } = indexScores(record);
|
|
120
|
+
const result = await this.client.eval(INDEXED_CAS_SCRIPT, 3, this.recordsKey(), this.queuedKey(), this.leasesKey(), record.id, String(expectedSequence), encoded, queued, lease);
|
|
121
|
+
return Number(result) === 1;
|
|
122
|
+
}
|
|
36
123
|
const result = await this.client.eval(CAS_SCRIPT, 1, this.recordsKey(), record.id, String(expectedSequence), encoded);
|
|
37
124
|
return Number(result) === 1;
|
|
38
125
|
}
|
|
@@ -40,6 +127,8 @@ export class RedisOperationStore {
|
|
|
40
127
|
if (!current || current.sequence !== expectedSequence)
|
|
41
128
|
return false;
|
|
42
129
|
await this.client.hset(this.recordsKey(), record.id, encoded);
|
|
130
|
+
if (this.indexed)
|
|
131
|
+
await this.place(record);
|
|
43
132
|
return true;
|
|
44
133
|
}
|
|
45
134
|
async delete(id) {
|
|
@@ -47,19 +136,22 @@ export class RedisOperationStore {
|
|
|
47
136
|
if (record?.idempotencyKey)
|
|
48
137
|
await this.client.hdel(this.idempotencyKey(), record.idempotencyKey);
|
|
49
138
|
await this.client.hdel(this.recordsKey(), id);
|
|
139
|
+
if (this.indexed) {
|
|
140
|
+
await this.client.zrem?.(this.queuedKey(), id);
|
|
141
|
+
await this.client.zrem?.(this.leasesKey(), id);
|
|
142
|
+
}
|
|
50
143
|
return record !== undefined;
|
|
51
144
|
}
|
|
52
145
|
async claimExpired(now, limit) {
|
|
146
|
+
if (this.indexed) {
|
|
147
|
+
return this.scanIndex('leases', Date.parse(now), limit, (record) => isExpired(record, now), isLeased);
|
|
148
|
+
}
|
|
53
149
|
const records = await this.list();
|
|
54
150
|
const expired = [];
|
|
55
151
|
for (const record of records) {
|
|
56
152
|
if (expired.length >= limit)
|
|
57
153
|
break;
|
|
58
|
-
if (
|
|
59
|
-
continue;
|
|
60
|
-
if (record.lease && record.lease.expiresAt > now)
|
|
61
|
-
continue;
|
|
62
|
-
if (record.status === 'running' || record.lease)
|
|
154
|
+
if (isExpired(record, now))
|
|
63
155
|
expired.push(record);
|
|
64
156
|
}
|
|
65
157
|
return expired;
|
|
@@ -74,6 +166,9 @@ export class RedisOperationStore {
|
|
|
74
166
|
}
|
|
75
167
|
async listQueued(limit, filter) {
|
|
76
168
|
const now = new Date().toISOString();
|
|
169
|
+
if (this.indexed) {
|
|
170
|
+
return this.scanIndex('queued', Number.POSITIVE_INFINITY, limit, (record) => isUnheldQueued(record, now) && matchesFilter(record, filter), (record) => record.status === 'queued');
|
|
171
|
+
}
|
|
77
172
|
return (await this.list())
|
|
78
173
|
.filter((record) => isUnheldQueued(record, now) && matchesFilter(record, filter))
|
|
79
174
|
.sort((a, b) => (a.createdAt < b.createdAt ? -1 : a.createdAt > b.createdAt ? 1 : 0))
|
|
@@ -82,12 +177,104 @@ export class RedisOperationStore {
|
|
|
82
177
|
async stats(now, filter) {
|
|
83
178
|
return countRecords(await this.list(), now, filter);
|
|
84
179
|
}
|
|
180
|
+
async reindex() {
|
|
181
|
+
if (!this.indexed)
|
|
182
|
+
throw new TypeError('reindex() needs a store created with index: true');
|
|
183
|
+
const records = await this.list();
|
|
184
|
+
for (const record of records) {
|
|
185
|
+
const { queued, lease } = indexScores(record);
|
|
186
|
+
if (queued !== '')
|
|
187
|
+
await this.client.zadd?.(this.queuedKey(), Number(queued), record.id);
|
|
188
|
+
if (lease !== '')
|
|
189
|
+
await this.client.zadd?.(this.leasesKey(), Number(lease), record.id);
|
|
190
|
+
}
|
|
191
|
+
await this.prune('queued', records.filter((record) => record.status !== 'queued').map((record) => record.id));
|
|
192
|
+
await this.prune('leases', records.filter((record) => !isLeased(record)).map((record) => record.id));
|
|
193
|
+
return records.length;
|
|
194
|
+
}
|
|
195
|
+
async scanIndex(index, max, limit, keep, belongs) {
|
|
196
|
+
const found = [];
|
|
197
|
+
if (limit <= 0)
|
|
198
|
+
return found;
|
|
199
|
+
const stale = [];
|
|
200
|
+
const key = index === 'queued' ? this.queuedKey() : this.leasesKey();
|
|
201
|
+
const upper = max === Number.POSITIVE_INFINITY ? '+inf' : max;
|
|
202
|
+
const page = Math.max(16, limit * 4);
|
|
203
|
+
for (let offset = 0; found.length < limit; offset += page) {
|
|
204
|
+
const ids = (await this.client.zrangebyscore?.(key, '-inf', upper, 'LIMIT', offset, page)) ?? [];
|
|
205
|
+
const records = await this.readMany(ids);
|
|
206
|
+
for (const [position, id] of ids.entries()) {
|
|
207
|
+
const record = records[position];
|
|
208
|
+
if (!record || !belongs(record)) {
|
|
209
|
+
stale.push(id);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (keep(record))
|
|
213
|
+
found.push(record);
|
|
214
|
+
if (found.length >= limit)
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
if (ids.length < page)
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
await this.prune(index, stale);
|
|
221
|
+
return found;
|
|
222
|
+
}
|
|
223
|
+
async readMany(ids) {
|
|
224
|
+
if (ids.length === 0)
|
|
225
|
+
return [];
|
|
226
|
+
const raws = this.client.hmget
|
|
227
|
+
? await this.client.hmget(this.recordsKey(), ...ids)
|
|
228
|
+
: await Promise.all(ids.map((id) => this.client.hget(this.recordsKey(), id)));
|
|
229
|
+
return raws.map((raw) => (raw ? JSON.parse(raw) : undefined));
|
|
230
|
+
}
|
|
231
|
+
async prune(index, ids) {
|
|
232
|
+
if (ids.length === 0 || !this.useEval || !this.client.eval)
|
|
233
|
+
return;
|
|
234
|
+
const key = index === 'queued' ? this.queuedKey() : this.leasesKey();
|
|
235
|
+
for (let start = 0; start < ids.length; start += 500) {
|
|
236
|
+
await this.client.eval(PRUNE_SCRIPT, 2, this.recordsKey(), key, index, ...ids.slice(start, start + 500));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
async place(record) {
|
|
240
|
+
const { queued, lease } = indexScores(record);
|
|
241
|
+
if (queued !== '')
|
|
242
|
+
await this.client.zadd?.(this.queuedKey(), Number(queued), record.id);
|
|
243
|
+
else
|
|
244
|
+
await this.client.zrem?.(this.queuedKey(), record.id);
|
|
245
|
+
if (lease !== '')
|
|
246
|
+
await this.client.zadd?.(this.leasesKey(), Number(lease), record.id);
|
|
247
|
+
else
|
|
248
|
+
await this.client.zrem?.(this.leasesKey(), record.id);
|
|
249
|
+
}
|
|
85
250
|
recordsKey() {
|
|
86
251
|
return `${this.prefix}records`;
|
|
87
252
|
}
|
|
88
253
|
idempotencyKey() {
|
|
89
254
|
return `${this.prefix}idempotency`;
|
|
90
255
|
}
|
|
256
|
+
queuedKey() {
|
|
257
|
+
return `${this.prefix}queued`;
|
|
258
|
+
}
|
|
259
|
+
leasesKey() {
|
|
260
|
+
return `${this.prefix}leases`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
function isExpired(record, now) {
|
|
264
|
+
if (isTerminalOperationStatus(record.status))
|
|
265
|
+
return false;
|
|
266
|
+
if (record.lease && record.lease.expiresAt > now)
|
|
267
|
+
return false;
|
|
268
|
+
return record.status === 'running' || record.lease !== undefined;
|
|
269
|
+
}
|
|
270
|
+
function isLeased(record) {
|
|
271
|
+
return !isTerminalOperationStatus(record.status) && (record.lease !== undefined || record.status === 'running');
|
|
272
|
+
}
|
|
273
|
+
function indexScores(record) {
|
|
274
|
+
return {
|
|
275
|
+
queued: record.status === 'queued' ? String(Date.parse(record.createdAt)) : '',
|
|
276
|
+
lease: isLeased(record) ? String(record.lease ? Date.parse(record.lease.expiresAt) : 0) : '',
|
|
277
|
+
};
|
|
91
278
|
}
|
|
92
279
|
export class BullMQOperationDispatcher {
|
|
93
280
|
queue;
|
|
@@ -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
|
};
|
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
|
/**
|