iit-mcp-hudu 0.6.0 → 0.6.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 +6 -0
- package/README.md +32 -14
- package/dist/search-budget.js +90 -27
- package/dist/tools/search-policy.js +7 -4
- package/dist/tools/server.js +27 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.6.1
|
|
4
|
+
|
|
5
|
+
**Upgrading from 0.6.0:** no configuration change. Each scoped client's body index is deeper: a read-only http deployment gives one credential a third of the pool instead of an eighth. A write-enabled deployment on a small heap gets a shallower index than 0.6.0 (a ninth of the pool), because the eviction margin and the per-mode caches are both counted.
|
|
6
|
+
|
|
7
|
+
- Fix: a read-only http deployment now gives each scoped client a THIRD of the pooled index text budget instead of an eighth (the pool is divided by two clients per cache plus one eviction of margin), so the body index is deep enough to search the articles it holds. MEASURED on the production tenant (2026-10-26) with the former division by eight at 1 GiB: a scope retained 4,666 assets and 1,478 of 3,204 articles; 1,036 article bodies were indexed, 317 had been evicted by the text budget and 3 truncated (the counts do not decompose the 1,478 — some retained articles carry no body text). The cause is the SDK's document cap, which keeps the freshest `updated_at` across ALL resources, so newer assets displaced older articles. A read-only deployment at a ~816 MB V8 ceiling now gets 17 MiB of text and 17,408 documents per scope instead of 6 MiB and 6,144 (INFERRED to keep all 3,204 articles and about 2,800 of their bodies, from the measured article density at 6,144; the SDK's cap is global, so this is an expectation, not a guarantee); a 512 MiB container gets 5.33 MiB and 5,461 instead of 2 MiB and 2,048. The document cap lost its 1,500-document floor and now follows the share (one document per KiB): the smallest pool with writes (an 8 MiB pool, 932,067 bytes per client) gets 910 documents instead of a 1,500-document cap above its own share. The pool is also divided by the number of per-mode caches: a read-only deployment creates one (`read`), a write-enabled deployment three (`read`, `write`, `delete`), because each mode entry keeps its own cache of scoped clients, and each cache's clients are counted with one extra eviction transient (the scope the LRU just dropped while an in-flight request finishes). So a write-enabled deployment gets 5.67 MiB and 5,802 per scope at ~816 MB, and a write-enabled 512 MiB container gets LESS than before (1.78 MiB and 1,820 instead of 2 MiB and 2,048) because the eviction margin is now counted, and in every profile the share is sized so the cache capacity (clients x caches) plus one eviction per cache stays within the pool; concurrent evictions beyond that margin can exceed it until their requests finish, because the SDK cannot revoke a live index. This also corrects a pre-existing oversubscription, since with eight shares three caches could hold 144 MiB of text against a 51 MiB pool. The trade is breadth for depth: at most two (origin, credential) pairs stay warm per mode cache, and a credential occupies one client per cache (read, plus write and delete when writes are enabled), so a deployment serving more credentials also loses each evicted scope's read-through TTL cache and re-fetches more from Hudu, and rebuilds an evicted index on demand (searches answer from the vendor tier meanwhile, unless `HUDU_SEARCH_TIER_POLICY=index` makes the call wait on that rebuild, with progress and cancellation), and a deployment needing both breadth and depth needs a larger heap. `MAX_SCOPED_CLIENTS` still equals `SCOPED_INDEX_BUDGET_DIVISOR` (now 2); the per-mode cache count (3: read, write, delete) is the second divisor, and `SCOPED_INDEX_EVICTION_TRANSIENT` (1) is added to the per-cache count before multiplying — it is a margin, not a third multiplicand.
|
|
8
|
+
|
|
3
9
|
## 0.6.0
|
|
4
10
|
|
|
5
11
|
**Upgrading from 0.5.1:** under `MCP_TRANSPORT=http` the served `hudu_search` schema no longer offers `tier` or `refresh`, and the tier is server-side policy: `auto` by default, so a call answers from the vendor tier while the index builds. Set `HUDU_SEARCH_TIER_POLICY=index` for an http deployment that should wait. The body-index text budget and document cap are now derived from the process heap (a sixteenth of the V8 ceiling) instead of the SDK's 64 MiB / 50,000 defaults; no configuration change is needed. Under `stdio` the default tier stays `index` and both fields are still served.
|
package/README.md
CHANGED
|
@@ -158,8 +158,10 @@ gate on who can use it. It is only ever presented to the default
|
|
|
158
158
|
`HUDU_BASE_URL`: a request that names some other origin and brings no key of its own gets no
|
|
159
159
|
credential and fails closed, so a caller cannot aim the server's key at a host it controls. (A
|
|
160
160
|
blank value, as a copied `.env.example` leaves it, is treated as unset.) Each (origin, credential)
|
|
161
|
-
pair gets its own scoped client (its own search index) cached by the pair's hash and capped
|
|
162
|
-
|
|
161
|
+
pair gets its own scoped client (its own search index) cached by the pair's hash and capped — at most
|
|
162
|
+
two pairs stay warm in EACH mode cache, so a credential occupies one client per cache (read, plus
|
|
163
|
+
write and delete when writes are enabled) and a further scope rebuilds its index on demand — so one
|
|
164
|
+
caller's indexed article bodies can never answer another's search.
|
|
163
165
|
|
|
164
166
|
`HUDU_ALLOWED_BASE_HOSTS` (`http` only; required unless `HUDU_ALLOW_ANY_BASE_HOST=true`) restricts which origins a caller may name in
|
|
165
167
|
`X-Hudu-Base-Url`: a comma-separated list of exact hostnames or `*.example.com` subdomain suffixes
|
|
@@ -298,7 +300,7 @@ surface headroom); the meta vocabulary is enforced by the tests that publish it
|
|
|
298
300
|
|
|
299
301
|
The body index is bounded by TWO caps derived from the same heap estimate: a text budget and a
|
|
300
302
|
document cap. The text budget is a SIXTEENTH of the process's V8 heap ceiling, clamped to 8-64 MiB;
|
|
301
|
-
the document cap is one document per KiB of that budget (`maxDocs`),
|
|
303
|
+
the document cap is one document per KiB of that budget (`maxDocs`), capped at 50,000. Both
|
|
302
304
|
travel the SDK's `search` options, so neither is an env knob. The divisor moved from 10 to 16 in
|
|
303
305
|
round 11 because the tenth left no usable production margin (the arithmetic below).
|
|
304
306
|
|
|
@@ -308,28 +310,42 @@ of heap and died with `Reached heap limit` mid-search. It measured the ~90 MB ba
|
|
|
308
310
|
4 bytes of index structures per byte of text ACTUALLY HELD, not per byte of budget: that index held
|
|
309
311
|
~41 MB of text out of its 64 MiB budget, and the peak reconciles as ~90 + 4 x 41 = ~254 MB. Read
|
|
310
312
|
against the budget instead of the held text the same ratio would predict 90 + 4 x 64 = ~346 MB, which
|
|
311
|
-
the trace never saw. A 512 MiB container (roughly a 259 MB heap ceiling) now gets a 16 MiB pool
|
|
312
|
-
|
|
313
|
+
the trace never saw. A 512 MiB container (roughly a 259 MB heap ceiling) now gets a 16 MiB pool,
|
|
314
|
+
divided THREE ways for one read-only cache (5.33 MiB per scoped client) or nine ways when writes are
|
|
315
|
+
enabled, because read, write and delete each keep their own cache and each counts one eviction of
|
|
316
|
+
margin (1.78 MiB per client). The division is an exact byte quotient, so no pool space is lost to
|
|
317
|
+
rounding. The share is SIZED so the cache capacity plus one eviction of margin per cache fits the
|
|
318
|
+
pool; it is not an enforced peak. A scope evicted by the LRU stays resident while an in-flight
|
|
319
|
+
request that resolved it finishes, and because the SDK cannot revoke a live index, concurrent
|
|
320
|
+
evictions of distinct credentials can exceed the pool until those requests settle. A standalone walk of a synthetic 25,000-asset / 3,300-article tenant through
|
|
313
321
|
the real SDK client under a ~262 MB V8 ceiling (an uncommitted one-off probe; `--max-semi-space-size=1`
|
|
314
322
|
brings V8's reported limit to ~262 MB, while `--max-old-space-size=259` alone reports ~451 MB in
|
|
315
323
|
Node 24) peaked at ~136 MiB with the ROUND-10 tenth settings; the same walk with the OLD
|
|
316
324
|
64 MiB / 50,000 settings peaked at ~147 MiB. Both survived. That probe's baseline is lower than the
|
|
317
|
-
server's ~90 MB. A 1 GiB container's ~816 MB V8 ceiling — an ASSUMED ~80% container-to-heap ratio; the MEASURED ratio for the 512 MiB container is ~50.6% (259 MB) — gets a 51 MiB pool and
|
|
325
|
+
server's ~90 MB. A 1 GiB container's ~816 MB V8 ceiling — an ASSUMED ~80% container-to-heap ratio; the MEASURED ratio for the 512 MiB container is ~50.6% (259 MB) — gets a 51 MiB pool and 17 MiB per scoped client (5.67 MiB with writes enabled); a 1024 MB heap ceiling is
|
|
326
|
+
what reaches the 64 MiB cap.
|
|
318
327
|
|
|
319
328
|
The document cap exists because the text budget bounds index TEXT only: a document whose text was
|
|
320
329
|
evicted keeps its record and postings, the SDK's own default is 50,000 documents, and a walk can
|
|
321
330
|
reach 250 pages x 100 rows x 2 body resources. Pairing the caps bounds that overhead — a 512 MiB
|
|
322
|
-
container indexes up to
|
|
323
|
-
|
|
331
|
+
container indexes up to 5,461 documents per scoped client read-only, 1,820 with writes (a stdio
|
|
332
|
+
parent carries the whole pool instead: 16,384 documents; writes enabled builds one parent per mode,
|
|
333
|
+
so up to three of them carry it), a 1 GiB container's ~816 MB ceiling (assumed ~80%) 17,408 per
|
|
334
|
+
client read-only, 5,802 with writes (50,000 in a stdio parent), and only a 1024 MB ceiling 21,845
|
|
335
|
+
read-only, 7,281 with writes. The per-document cost is an ESTIMATE (~1 KB of record
|
|
324
336
|
plus postings per document, on top of the text ratio), so the pair bounds growth without proving a
|
|
325
337
|
peak. HONEST SCOPE: the SDK upserts every walked document and calls its cap only AFTER the walk, so
|
|
326
338
|
`maxDocs` bounds the RETAINED index, not the build-time peak; the peak during a walk is bounded by
|
|
327
339
|
the heap ceiling and the text budget's eviction, not by `maxDocs`. A capped index reports index state
|
|
328
|
-
`partial`, exactly as it already does when the text budget bites.
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
340
|
+
`partial`, exactly as it already does when the text budget bites. WHICH documents the cap keeps is
|
|
341
|
+
the SDK's choice: the freshest `updated_at` across ALL resources, so assets can outrank older
|
|
342
|
+
articles. MEASURED on the production tenant with the former division by eight (6,144 documents):
|
|
343
|
+
4,666 assets and 1,478 of 3,204 articles retained; 1,036 article bodies indexed, 317 evicted by the
|
|
344
|
+
text budget and 3 truncated (the counts do not decompose the 1,478 — some retained articles carry no
|
|
345
|
+
body text). The same measurement is why a read-only scope now gets a THIRD of the pool instead of an
|
|
346
|
+
eighth: 17 MiB and 17,408 documents at 1 GiB are expected (INFERRED, not measured at this division)
|
|
347
|
+
to keep all 3,204 articles and about 2,800 of their bodies, while at 512 MiB (5,461 documents) 3,204
|
|
348
|
+
articles leave only 2,257 slots for assets and the loss remains.
|
|
333
349
|
|
|
334
350
|
BEFORE THE DIVISOR CHANGE. The round-10 end-to-end run measured the real server (`dist/main.js`) under
|
|
335
351
|
a 259 MiB V8 ceiling: peak heapUsed 187.96 MB with a 24.6 MB baseline, so the INDEX STRUCTURES cost
|
|
@@ -430,7 +446,9 @@ Every client the server builds — the parent, the fail-closed placeholders and
|
|
|
430
446
|
blindly.
|
|
431
447
|
- `cache: 'recommended'` — fixed on: the
|
|
432
448
|
per-entity read-through TTL preset (1 h structural / 5 min mutable / 1 min base). Caches are
|
|
433
|
-
per client, so per scope
|
|
449
|
+
per client, so per scope — and a scope's client survives only while it stays in its mode cache,
|
|
450
|
+
which holds two pairs: a deployment serving more than two credentials therefore re-fetches more
|
|
451
|
+
from Hudu after an eviction, the same breadth-for-depth trade the body index makes. The write and delete clients have their own caches, so after every `hudu_write` /
|
|
434
452
|
`hudu_delete` attempt that is not a dry run (including a failed or timed-out one, which may have
|
|
435
453
|
applied) the server clears the read client's cache for the same scope: a record read (get, getSection, fetch_many, article context) after a write in
|
|
436
454
|
the same server process is fresh. A write made by another process or deployment can still be served
|
package/dist/search-budget.js
CHANGED
|
@@ -7,12 +7,47 @@ export const MIN_INDEX_TEXT_BYTES = 8 * MIB;
|
|
|
7
7
|
/** 64 MiB: the SDK's own default text bound, and the most a single scope should ever hold. */
|
|
8
8
|
export const MAX_INDEX_TEXT_BYTES = 64 * MIB;
|
|
9
9
|
/**
|
|
10
|
-
* How many scoped clients
|
|
10
|
+
* How many scoped clients ONE per-mode cache keeps warm (the cache capacity in `tools/server.ts`).
|
|
11
11
|
*
|
|
12
12
|
* The budget below is per PROCESS, and every scoped client materialises its own index, so the pool
|
|
13
|
-
* must be divided
|
|
13
|
+
* must be divided rather than handed to each client whole — by this number, by the number of
|
|
14
|
+
* per-mode caches the factory builds (`read`, plus `write` and `delete` when writes are enabled),
|
|
15
|
+
* and by one eviction transient per cache.
|
|
16
|
+
*
|
|
17
|
+
* TWO, not eight, because a division by eight made the index shallower than the corpus it exists to
|
|
18
|
+
* search: on the production tenant at 1 GiB each scope got 6 MiB of text and 6,144 documents, and
|
|
19
|
+
* because the SDK's document cap keeps the freshest `updated_at` GLOBALLY (it has no per-resource
|
|
20
|
+
* cap), 4,666 newer assets displaced 1,726 of the 3,204 articles, leaving 1,036 article bodies
|
|
21
|
+
* indexed (MEASURED on the live server, 2026-10-26).
|
|
22
|
+
*
|
|
23
|
+
* RESULTING DEPTH: a read-only deployment (one cache, `HUDU_WRITE_POLICY=deny`) gives each scope
|
|
24
|
+
* 17 MiB and 17,408 documents at a ~816 MB ceiling; that is INFERRED to cover the tenant's article
|
|
25
|
+
* bodies almost entirely (about 2,800 of its 3,204 articles) and to keep all 3,204 articles, because
|
|
26
|
+
* the measured run retained articles at roughly a quarter of the freshest-document density. A
|
|
27
|
+
* write-enabled deployment has three caches, so the same pool is divided by nine and each scope gets
|
|
28
|
+
* 5.67 MiB and 5,802 documents.
|
|
29
|
+
*
|
|
30
|
+
* SIZED FOR, NOT ENFORCED: the share is the pool divided by `(capacity + 1) x caches`, where the +1
|
|
31
|
+
* leaves one eviction of margin per cache — the scope the LRU just dropped, which an in-flight
|
|
32
|
+
* request keeps alive until it finishes (`ScopedClientCache.for()`). That makes the steady-state
|
|
33
|
+
* bound `capacity x caches x share <= pool` hold, with one eviction per cache on top. It cannot
|
|
34
|
+
* enforce a peak: k distinct credentials arriving at once can each evict a different scope, and the
|
|
35
|
+
* SDK has no way to revoke a live index, so up to `capacity + k` scopes per cache can be alive at
|
|
36
|
+
* once. The pool is a sizing target, as it has been since #51, not a hard cap.
|
|
37
|
+
*
|
|
38
|
+
* This also CORRECTS a pre-existing oversubscription: with eight shares, three caches could hold
|
|
39
|
+
* 3 x 8 x 6 = 144 MiB of text against a 51 MiB pool. The trade is still breadth for depth: an
|
|
40
|
+
* evicted scope rebuilds on demand, and answers fall back to the vendor tier meanwhile — unless the
|
|
41
|
+
* deployment forces `HUDU_SEARCH_TIER_POLICY=index`, where the call waits on that rebuild (with
|
|
42
|
+
* progress and cancellation). A deployment needing both breadth and depth needs a larger heap.
|
|
14
43
|
*/
|
|
15
|
-
export const SCOPED_INDEX_BUDGET_DIVISOR =
|
|
44
|
+
export const SCOPED_INDEX_BUDGET_DIVISOR = 2;
|
|
45
|
+
/**
|
|
46
|
+
* One eviction of margin per cache: a scope evicted by the LRU stays reachable while an in-flight
|
|
47
|
+
* request that resolved it finishes, so the cache can briefly hold capacity + 1 shares. Concurrent
|
|
48
|
+
* evictions (k distinct credentials at once) can exceed this; see the divisor note.
|
|
49
|
+
*/
|
|
50
|
+
export const SCOPED_INDEX_EVICTION_TRANSIENT = 1;
|
|
16
51
|
/**
|
|
17
52
|
* The total extracted text the PROCESS's body indexes may hold, from the V8 heap ceiling in bytes.
|
|
18
53
|
*
|
|
@@ -39,29 +74,47 @@ export const SCOPED_INDEX_BUDGET_DIVISOR = 8;
|
|
|
39
74
|
* walk.
|
|
40
75
|
*
|
|
41
76
|
* POOL FRACTION: the budget is per PROCESS, not per client. Up to
|
|
42
|
-
* {@link SCOPED_INDEX_BUDGET_DIVISOR} scoped clients stay warm
|
|
43
|
-
* handed only {@link scopedIndexTextBudgetBytes}
|
|
44
|
-
*
|
|
45
|
-
*
|
|
77
|
+
* {@link SCOPED_INDEX_BUDGET_DIVISOR} scoped clients stay warm in each per-mode cache and each owns a
|
|
78
|
+
* body index, so each is handed only {@link scopedIndexTextBudgetBytes}: the pool divided by
|
|
79
|
+
* ({@link SCOPED_INDEX_BUDGET_DIVISOR} clients + {@link SCOPED_INDEX_EVICTION_TRANSIENT} eviction of
|
|
80
|
+
* margin) times the number of caches. The trade: several credentials build shallower indexes (a
|
|
81
|
+
* scope that hits its share reports index state "partial"); needing depth means raising the
|
|
82
|
+
* container memory or serving over stdio, where the serving clients own the whole pool (one parent
|
|
83
|
+
* per mode there, so writes enabled can hold it in three parents; `hudu_search` uses the read one).
|
|
84
|
+
*
|
|
85
|
+
* MEASURED on the production tenant (2026-10-26), with the former division by eight: a scope retained
|
|
86
|
+
* 4,666 assets and 1,478 of 3,204 articles, indexed 1,036 article bodies, and had evicted 317 bodies
|
|
87
|
+
* by the text budget and truncated 3 (the counts do not decompose the 1,478: some retained articles
|
|
88
|
+
* carry no body text). The SDK's document cap keeps the freshest documents across ALL resources,
|
|
89
|
+
* which is the shallowness this divisor fixes.
|
|
46
90
|
*/
|
|
47
91
|
export function indexTextBudgetBytes(heapLimitBytes) {
|
|
48
92
|
const sixteenthInMib = Math.floor(heapLimitBytes / 16 / MIB) * MIB;
|
|
49
93
|
return Math.min(MAX_INDEX_TEXT_BYTES, Math.max(MIN_INDEX_TEXT_BYTES, sixteenthInMib));
|
|
50
94
|
}
|
|
51
95
|
/**
|
|
52
|
-
* The text budget for ONE scoped client: the process pool divided by the
|
|
96
|
+
* The text budget for ONE scoped client: the process pool divided by the clients the design expects
|
|
97
|
+
* it to fund — (cache capacity + one eviction of margin) x per-mode caches.
|
|
53
98
|
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
99
|
+
* `caches` is the number of scoped-client caches the process creates: 1 under
|
|
100
|
+
* `HUDU_WRITE_POLICY=deny` (read only), 3 otherwise (read, write, delete). It is required, not
|
|
101
|
+
* defaulted: a default of 1 would size every client for a read-only process and oversubscribe a
|
|
102
|
+
* write-enabled one threefold.
|
|
103
|
+
*
|
|
104
|
+
* The share is an exact byte quotient, so `clients x share` never exceeds the pool and no pool space
|
|
105
|
+
* is lost to rounding — whole-MiB rounding WOULD have discarded 7 of 16 MiB (44%) in the 512 MiB
|
|
106
|
+
* write-enabled profile, and up to 8 of 17 MiB in the worst case. Small pools get a share below the single-client
|
|
107
|
+
* {@link MIN_INDEX_TEXT_BYTES} on purpose — the clients cannot each hold the single-client floor out
|
|
108
|
+
* of an 8 MiB pool — and the SDK accepts any positive bound (a probe with 932,067 bytes built and
|
|
109
|
+
* answered `warm`).
|
|
57
110
|
*/
|
|
58
|
-
export function scopedIndexTextBudgetBytes(heapLimitBytes) {
|
|
111
|
+
export function scopedIndexTextBudgetBytes(heapLimitBytes, caches) {
|
|
59
112
|
const total = indexTextBudgetBytes(heapLimitBytes);
|
|
60
|
-
const
|
|
61
|
-
return Math.
|
|
113
|
+
const clients = (SCOPED_INDEX_BUDGET_DIVISOR + SCOPED_INDEX_EVICTION_TRANSIENT) * Math.max(1, caches);
|
|
114
|
+
return Math.max(1, Math.floor(total / clients));
|
|
62
115
|
}
|
|
63
|
-
/** 1
|
|
64
|
-
export const MIN_INDEX_MAX_DOCS =
|
|
116
|
+
/** 1: the cap follows the share's KiB count, so it never exceeds the text budget it is paired with. */
|
|
117
|
+
export const MIN_INDEX_MAX_DOCS = 1;
|
|
65
118
|
/** 50,000: the SDK's own default doc count, and the most one index should ever hold. */
|
|
66
119
|
export const MAX_INDEX_MAX_DOCS = 50_000;
|
|
67
120
|
/** ~1 KB of record + postings per indexed document, on top of the ~4x text ratio. ESTIMATE. */
|
|
@@ -81,22 +134,32 @@ const INDEX_DOC_BYTES = 1024;
|
|
|
81
134
|
* reach 250 pages x 100 rows x 2 body resources, and a document whose text the budget evicted keeps
|
|
82
135
|
* its record and postings, so the record overhead grows even when no text is added. This caps the
|
|
83
136
|
* count from the same pool: ~1 KB of record + postings per document, i.e. one document per KiB of
|
|
84
|
-
* text budget,
|
|
137
|
+
* text budget, capped at 50,000 and never below one document.
|
|
85
138
|
*
|
|
86
139
|
* ESTIMATE, and it bounds growth rather than proving a peak: the pairing says "this index may hold X
|
|
87
140
|
* MiB of text and Y documents", not "the process cannot exceed its heap". The SDK upserts every
|
|
88
141
|
* walked document and applies the cap only AFTER the walk, so this bounds what the index RETAINS,
|
|
89
142
|
* not the walk's peak (the heap ceiling and the text budget's eviction bound that). A capped index
|
|
90
|
-
* reports index state "partial", as it already does when the text budget bites. Resulting numbers
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
143
|
+
* reports index state "partial", as it already does when the text budget bites. Resulting numbers,
|
|
144
|
+
* read-only profile / write-enabled profile (one cache / three caches):
|
|
145
|
+
* a 512 MiB container (MEASURED ~50.6% container-to-heap ratio: ~259 MB) gets a 16 MiB pool, so
|
|
146
|
+
* 5.33 MiB / 5,461 documents per scoped client read-only, 1.78 MiB / 1,820 with writes (a stdio
|
|
147
|
+
* parent carries the whole pool: 16,384 documents, and writes enabled builds one such parent per
|
|
148
|
+
* mode); a 1 GiB container's ~816 MB V8 ceiling — an ASSUMED ~80% ratio, not a measurement — gets a
|
|
149
|
+
* 51 MiB pool, so 17 MiB / 17,408 read-only, 5.67 MiB / 5,802 with writes (50,000 in a stdio parent);
|
|
150
|
+
* a 1024 MB ceiling reaches the 64 MiB cap, so 21.33 MiB / 21,845 read-only, 7.11 MiB / 7,281 with
|
|
151
|
+
* writes.
|
|
152
|
+
*
|
|
153
|
+
* WHICH documents the cap truncates is the SDK's choice, not this module's: it keeps the freshest
|
|
154
|
+
* `updated_at` across ALL resources, so assets can outrank older articles. MEASURED on the production
|
|
155
|
+
* tenant at the former division by eight (6,144 documents): 4,666 assets and 1,478 of the 3,204
|
|
156
|
+
* articles were retained; 1,036 article bodies were indexed, 317 had been evicted by the text budget
|
|
157
|
+
* and 3 truncated (the counts do not decompose the 1,478 — some retained articles carry no body
|
|
158
|
+
* text). At the 1 GiB read-only figures above (17 MiB and 17,408 documents) the measured article
|
|
159
|
+
* density (1,478 of the freshest 6,144) implies all 3,204 articles now fall inside the cap, so the
|
|
160
|
+
* article loss is expected to disappear (INFERRED, not measured at this division); at 512 MiB
|
|
161
|
+
* (5,461 documents) 3,204 articles would leave only 2,257 slots for assets, so the loss remains. The
|
|
162
|
+
* order is the SDK's.
|
|
100
163
|
*
|
|
101
164
|
* Named for its scoped use because that is where it matters: the pool is divided per client, and the
|
|
102
165
|
* cap travels the same `search` object as `maxIndexTextBytes`.
|
|
@@ -174,10 +174,13 @@ export function applySearchPolicyToInvoke(ctx, input) {
|
|
|
174
174
|
throw configError(`hudu_read / hudu_write / hudu_delete: operations.searchKnowledge scope ${JSON.stringify(excluded)} is excluded by HUDU_SEARCH_RESOURCES; this deployment may search: ${searchableResources.join(', ')}. Use hudu_search, which applies the same policy.`);
|
|
175
175
|
}
|
|
176
176
|
}
|
|
177
|
-
// Under `http` the tier is deployment policy, so this path takes the same
|
|
178
|
-
// `hudu_search`: a caller-supplied `tier` or `refresh` cannot
|
|
179
|
-
//
|
|
180
|
-
//
|
|
177
|
+
// Under `http` the tier is deployment policy, so this path takes the same server-side route as
|
|
178
|
+
// `hudu_search`: a caller-supplied `tier` or `refresh` cannot widen it. Whether that route waits
|
|
179
|
+
// for a corpus walk is policy, not the caller: `auto` (the default) answers from the index when
|
|
180
|
+
// it is warm and from the vendor tier while it builds, never waiting; `HUDU_SEARCH_TIER_POLICY=index`
|
|
181
|
+
// waits for a complete index on both paths. That override is
|
|
182
|
+
// REPORTED, never silent: the dispatcher adds `meta.policyOverride` (`{ stripped, appliedTier }`)
|
|
183
|
+
// and a clause to the response text.
|
|
181
184
|
if (searchTierIsPolicy) {
|
|
182
185
|
return {
|
|
183
186
|
bag: { ...input, opts: { ...opts, scope, tier: toolDefaultTier, refresh: false } },
|
package/dist/tools/server.js
CHANGED
|
@@ -22,12 +22,13 @@ import { slimToolSchemas } from './slim.js';
|
|
|
22
22
|
const SERVER_NAME = 'iit-mcp-hudu';
|
|
23
23
|
const SERVER_VERSION = createRequire(import.meta.url)('../../package.json').version;
|
|
24
24
|
/**
|
|
25
|
-
* The maximum number of per-(origin, credential) scoped clients
|
|
25
|
+
* The maximum number of per-(origin, credential) scoped clients ONE mode cache keeps warm in a process.
|
|
26
26
|
*
|
|
27
27
|
* Each scoped client owns its own Hudu knowledge index. An unbounded map would let enough distinct
|
|
28
28
|
* callers grow the heap past the container limit, so the cache is capped and evicts
|
|
29
|
-
* least-recently-used first. The cap is the
|
|
30
|
-
* divided by (`search-budget.ts`)
|
|
29
|
+
* least-recently-used first. The cap is the per-cache client count the process-wide index text
|
|
30
|
+
* budget is divided by (`search-budget.ts`); the factory builds one such cache per mode entry, and
|
|
31
|
+
* the budget is divided by that count too, so the caches together stay inside the pool.
|
|
31
32
|
*/
|
|
32
33
|
export const MAX_SCOPED_CLIENTS = SCOPED_INDEX_BUDGET_DIVISOR;
|
|
33
34
|
/**
|
|
@@ -404,7 +405,11 @@ export function createMcpServerFactory(requested, log) {
|
|
|
404
405
|
// The body index's text budget follows the process's own heap ceiling, so a small container
|
|
405
406
|
// reports index state "partial" instead of dying at the V8 heap limit (#51). This value is the
|
|
406
407
|
// PROCESS-wide pool: the scoped clients below each take a share of it, since each materialises
|
|
407
|
-
// its own index. The parent
|
|
408
|
+
// its own index. The parent keeps the pool because under `stdio` it is the serving client and
|
|
409
|
+
// depth is the point of serving that way; with writes enabled there is one parent per mode
|
|
410
|
+
// (`read`, `write`, `delete`), so a raw `operations.searchKnowledge` invoked through the write or
|
|
411
|
+
// delete parent could build a second full-pool index (the `hudu_search` path uses the read
|
|
412
|
+
// parent only).
|
|
408
413
|
// HUDU_SEARCH_INDEX_TTL, when set, is the body index's TTL (the SDK default is 10 minutes).
|
|
409
414
|
search: {
|
|
410
415
|
maxIndexTextBytes: poolTextBytes,
|
|
@@ -443,11 +448,17 @@ export function createMcpServerFactory(requested, log) {
|
|
|
443
448
|
? [...reads, ...config.HUDU_WRITE_ALLOW.filter((key) => getCapability(key)?.effect === (mode === 'delete' ? 'destructive' : 'write')).flatMap((key) => withWriteDependencies(key, mode === 'delete' ? 'destructive' : 'write'))]
|
|
444
449
|
: undefined;
|
|
445
450
|
const scopedOptions = { ...options, mode, reveal: false, ...(allowOperations ? { allowOperations } : {}) };
|
|
446
|
-
// ONE scoped client's share of the process pool:
|
|
447
|
-
//
|
|
451
|
+
// ONE scoped client's share of the process pool: each per-mode cache holds MAX_SCOPED_CLIENTS
|
|
452
|
+
// clients plus one eviction transient, and the process builds one cache per mode (`modes`,
|
|
453
|
+
// below), so handing each the whole pool would let it hold that many times the text it was
|
|
454
|
+
// sized for (#51). Divide by both factors, using the cache count the policy implies rather than
|
|
455
|
+
// how many are warm.
|
|
448
456
|
// Only the per-(origin, credential) clients are divided — the parent keeps the pool, because
|
|
449
|
-
// under `stdio` it is the
|
|
450
|
-
|
|
457
|
+
// under `stdio` it is the serving client and a deeper index is the point of serving that way
|
|
458
|
+
// (one parent per mode there, so up to three pool-sized clients with writes enabled; the
|
|
459
|
+
// `hudu_search` path uses the read parent only).
|
|
460
|
+
const indexCaches = modes.length;
|
|
461
|
+
const scopedTextBytes = scopedIndexTextBudgetBytes(heapLimitBytes, indexCaches);
|
|
451
462
|
const scopedSearch = { ...options.search, maxIndexTextBytes: scopedTextBytes, maxDocs: scopedIndexMaxDocs(scopedTextBytes) };
|
|
452
463
|
const parent = config.MCP_TRANSPORT === 'stdio'
|
|
453
464
|
? new HuduClient({ ...scopedOptions, apiKey: config.HUDU_API_KEY })
|
|
@@ -475,8 +486,14 @@ export function createMcpServerFactory(requested, log) {
|
|
|
475
486
|
scopes: new ScopedClientCache((baseUrl, apiKey) => new HuduClient({ ...scopedOptions, baseUrl, apiKey, blockPrivateHosts: true, search: scopedSearch }), MAX_SCOPED_CLIENTS, log),
|
|
476
487
|
};
|
|
477
488
|
};
|
|
478
|
-
|
|
479
|
-
|
|
489
|
+
// The per-mode caches this factory builds. ONE list drives both the caches built and the pool
|
|
490
|
+
// division (`indexCaches` above), so a change to the policy mapping cannot leave the budget
|
|
491
|
+
// describing a different number of caches than the process creates.
|
|
492
|
+
const writeModes = config.HUDU_WRITE_POLICY === 'deny' ? [] : ['write', 'delete'];
|
|
493
|
+
const modes = ['read', ...writeModes];
|
|
494
|
+
const built = new Map(modes.map((mode) => [mode, build(mode)]));
|
|
495
|
+
const read = built.get('read');
|
|
496
|
+
const mutations = writeModes.length === 0 ? undefined : { write: built.get('write'), delete: built.get('delete') };
|
|
480
497
|
return (ctx) => {
|
|
481
498
|
const resolve = (entry) => resolveHuduClient(entry, config, ctx, log);
|
|
482
499
|
// Per serving unit (one stdio connection, or one HTTP request): the anomalies of the credential
|