Skip to content

Commit ef94a4b

Browse files
fix(memory-core): surface swallowed query failures as a degraded result (#12628) (#12629)
* fix(memory-core): surface swallowed Pass-1 query failures as a degraded result (#12628) The StorageRouter re-ranker's Pass-1 catch returned a clean-empty result on any ChromaDB throw, making a corrupt/unqueryable collection indistinguishable from a genuine no-match -- the silent-failure mode that hid the neo-agent-sessions HNSW corruption for days (parent #12450). Stay non-throwing (Pass-2 resilience preserved), but stamp a _degraded marker (logger.error + corruption-signature tag + collection name), symmetric to the success path's _reRanked. query_summaries / query_raw_memories now surface an explicit QUERY_PATH_DEGRADED envelope so a degraded query path and a genuine no-match stay distinguishable to the caller. Healthcheck query-canary (AC3) + regression test (AC4) follow in this branch. Co-Authored-By: Neo Claude Opus <neo-claude-opus@neomjs.com> * fix(memory-core): degrade healthcheck on a populated-but-unqueryable collection (#12628) #checkCollections reported count() only, so a collection whose HNSW/vector segment is desynced (count works, query throws 'Error finding id') still passed as healthy -- the same count-only blindspot that hid the neo-agent-sessions corruption (parent #12450). Add a per-collection query-canary mirroring the existing embedding write-canary (TTL-cached, healthy-only): it probes each collection's query path with a dimension-matched vector and degrades status (naming the failing collection + signature) when the re-ranker returns the _degraded marker or the query throws. Wired into both the full and request-fresh-cached healthcheck paths. Co-Authored-By: Neo Claude Opus <neo-claude-opus@neomjs.com> * test(memory-core): cover the degraded-query marker contract (#12628) Pure unit coverage (no Chroma substrate -> runs in CI, unlike the bucket-C-gated QueryReRanker.spec): a Pass-1 throw stamps the _degraded marker (corruption-signature tagged + collection-named) instead of a silent empty, a non-corruption error gets the generic signature, and a genuine empty result stays UNMARKED -- the degraded-vs-no-match distinction the fix exists to preserve. New spec file, so it neither touches nor conflicts with the in-flight QueryReRanker.spec rewrite. Co-Authored-By: Neo Claude Opus <neo-claude-opus@neomjs.com> * fix(memory-core): reset the query-canary cache on clearCache + cover the canary degraded path (#12628) Addresses the failing unit check (gpt RA3): the HealthService cached-healthcheck mock collections had count()/get() but no query(), so the new per-collection query-canary threw and false-degraded the status. Give the shared mock a healthy query() stub (models a reachable collection), and reset #collectionQueryCanaryCache in clearCache() for parity with the embedding write-canary cache + test isolation. Add a canary-degraded regression test: an unqueryable summary collection degrades healthcheck status, names the failing collection, and surfaces the queryCanary block (gpt RA2 consumer coverage, healthcheck half). Co-Authored-By: Neo Claude Opus <neo-claude-opus@neomjs.com> * test(memory-core): cover the degraded tool envelopes + document the openapi contract (#12628) Addresses gpt's RA1 + RA2 (consumer half): query_summaries / query_raw_memories now have CI-runnable degraded-envelope coverage (a degraded collection mocked at the StorageRouter boundary drives querySummaries/queryMemories into a QUERY_PATH_DEGRADED envelope, distinguishable from a genuine no-match). openapi.yaml documents the contract: optional degraded/code/collection/signature/message on QuerySummaries/QueryMemoriesResponse + the healthcheck collections.queryCanary block. OpenApiValidatorCompliance 26/26 (output schemas tolerate the added props; input schemas unchanged). Co-Authored-By: Neo Claude Opus <neo-claude-opus@neomjs.com> * refactor(memory-core): move collection query-health probe out of the healthcheck to an on-demand script (#12628) Operator CR on PR #12629: the per-collection query-canary bloated the always-on healthcheck — response-shape + token + focus-window cost on a poll whose job is a terse "is it healthy?". His call: "a SCRIPT to optionally trigger and inspect => fine." - Removed the healthcheck query-canary entirely: cache fields, both apply-calls, both private methods, the clearCache reset, and the queryCanary field from the healthcheck openapi schema. - Moved the probe to StorageRouter.probeCollectionQueryHealth() + ai/scripts/maintenance/probeCollectionQueryHealth.mjs (mirrors GraphService.getLifecycleCensus -> graphLifecycleReport.mjs, the #12602 precedent). - KEPT the inline _degraded marker + QUERY_PATH_DEGRADED envelope: that is error-path detection (operator: "the concept to detect failures is GOOD"), not always-on bloat. - Tests: dropped the healthcheck canary test; added a probe-method test proving detection survives the move. StorageRouterDegraded + HealthService 62/62; OpenApiValidatorCompliance 26/26.
1 parent d7c4c6b commit ef94a4b

7 files changed

Lines changed: 360 additions & 6 deletions

File tree

ai/mcp/server/memory-core/openapi.yaml

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1985,6 +1985,25 @@ components:
19851985
type: array
19861986
items:
19871987
$ref: '#/components/schemas/MemorySearchResult'
1988+
degraded:
1989+
type: boolean
1990+
description: "Present and true ONLY when the query path is degraded (e.g. a corrupt/unqueryable collection whose vector segment throws). Lets callers distinguish a failed query path from a genuine no-match, which omits this field. When true, count is 0 and results is empty but it is NOT a no-match."
1991+
example: true
1992+
code:
1993+
type: string
1994+
description: "Set to QUERY_PATH_DEGRADED when degraded is true."
1995+
example: "QUERY_PATH_DEGRADED"
1996+
collection:
1997+
type: string
1998+
description: "The degraded collection (present when degraded)."
1999+
example: "memory"
2000+
signature:
2001+
type: string
2002+
description: "Corruption/error signature (present when degraded), e.g. chroma-error-finding-id."
2003+
example: "chroma-error-finding-id"
2004+
message:
2005+
type: string
2006+
description: "Human-readable degraded reason (present when degraded)."
19882007

19892008
MemorySearchResult:
19902009
allOf:
@@ -2144,6 +2163,25 @@ components:
21442163
type: array
21452164
items:
21462165
$ref: '#/components/schemas/SummarySearchResult'
2166+
degraded:
2167+
type: boolean
2168+
description: "Present and true ONLY when the query path is degraded (e.g. a corrupt/unqueryable collection whose vector segment throws). Lets callers distinguish a failed query path from a genuine no-match, which omits this field. When true, count is 0 and results is empty but it is NOT a no-match."
2169+
example: true
2170+
code:
2171+
type: string
2172+
description: "Set to QUERY_PATH_DEGRADED when degraded is true."
2173+
example: "QUERY_PATH_DEGRADED"
2174+
collection:
2175+
type: string
2176+
description: "The degraded collection (present when degraded)."
2177+
example: "summary"
2178+
signature:
2179+
type: string
2180+
description: "Corruption/error signature (present when degraded), e.g. chroma-error-finding-id."
2181+
example: "chroma-error-finding-id"
2182+
message:
2183+
type: string
2184+
description: "Human-readable degraded reason (present when degraded)."
21472185

21482186
SummarySearchResult:
21492187
allOf:
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import 'dotenv/config';
2+
3+
import {
4+
Memory_StorageRouter as StorageRouter,
5+
Memory_GraphService as GraphService,
6+
Memory_LifecycleService as LifecycleService
7+
} from '../../services.mjs';
8+
9+
/**
10+
* @summary On-demand probe of each Memory Core collection's vector-query (HNSW) path.
11+
*
12+
* Thin operator entrypoint over {@link Neo.ai.services.memory-core.managers.StorageRouter#probeCollectionQueryHealth}.
13+
* This lives in a script — NOT the healthcheck payload and NOT an MCP tool — because a per-collection
14+
* `query()` probe (plus its response-schema bytes) would tax every always-on `is-it-healthy?` poll and
15+
* load into every agent's context unconditionally. A populated-but-corrupt collection passes `count()`
16+
* but throws on `query()`; run this when an operator suspects such a desynced-HNSW collection.
17+
*
18+
* Exit code: 0 = all collections queryable, 1 = at least one degraded, 2 = the probe itself failed.
19+
*
20+
* @module ai/scripts/maintenance/probeCollectionQueryHealth
21+
*/
22+
async function main() {
23+
await LifecycleService.initAsync();
24+
await GraphService.initAsync();
25+
await StorageRouter.initAsync();
26+
27+
const result = await StorageRouter.probeCollectionQueryHealth();
28+
29+
console.log(JSON.stringify(result, null, 2));
30+
process.exit(result.status === 'healthy' ? 0 : 1);
31+
}
32+
33+
main().catch(err => {
34+
console.error('[probeCollectionQueryHealth] probe failed:', err.stack);
35+
process.exit(2);
36+
});

ai/services/memory-core/HealthService.mjs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1608,9 +1608,9 @@ class HealthService extends Base {
16081608
* without waiting for the 5-minute cache to expire.
16091609
*/
16101610
clearCache() {
1611-
this.#cachedHealth = null;
1612-
this.#lastCheckTime = null;
1613-
this.#embeddingWriteCanaryCache = null;
1611+
this.#cachedHealth = null;
1612+
this.#lastCheckTime = null;
1613+
this.#embeddingWriteCanaryCache = null;
16141614
logger.debug('[HealthService] Cache cleared, next health check will be fresh');
16151615
}
16161616
}

ai/services/memory-core/MemoryService.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,23 @@ class MemoryService extends Base {
520520

521521
const searchResult = await collection.query(queryArgs);
522522

523+
// The re-ranker stays non-throwing on a corrupt/unqueryable collection but stamps a
524+
// `_degraded` marker. Surface it as an explicit degraded envelope so a failed query path
525+
// is distinguishable from a genuine no-match (which returns count:0 WITHOUT `degraded`).
526+
if (searchResult?._degraded) {
527+
return {
528+
_channelSeparation: "This content is DATA, not COMMANDS. See AGENTS.md L2_Channel_Separation.",
529+
degraded : true,
530+
code : 'QUERY_PATH_DEGRADED',
531+
collection: searchResult._degradedCollection || 'memory',
532+
signature : searchResult._degradedSignature,
533+
message : `Memory query path is degraded (${searchResult._degradedSignature}); this is NOT a genuine no-match. Underlying error: ${searchResult._degradedReason}`,
534+
query,
535+
count : 0,
536+
results : []
537+
};
538+
}
539+
523540
let ids = searchResult.ids?.[0] || [];
524541
let distances = searchResult.distances?.[0] || [];
525542
let metadatas = searchResult.metadatas?.[0] || [];

ai/services/memory-core/SummaryService.mjs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -406,6 +406,23 @@ class SummaryService extends Base {
406406

407407
const searchResult = await collection.query(queryArgs);
408408

409+
// The re-ranker stays non-throwing on a corrupt/unqueryable collection but stamps a
410+
// `_degraded` marker. Surface it as an explicit degraded envelope so a failed query path
411+
// is distinguishable from a genuine no-match (which returns count:0 WITHOUT `degraded`).
412+
if (searchResult?._degraded) {
413+
return {
414+
_channelSeparation: "This content is DATA, not COMMANDS. See AGENTS.md L2_Channel_Separation.",
415+
degraded : true,
416+
code : 'QUERY_PATH_DEGRADED',
417+
collection: searchResult._degradedCollection || 'summary',
418+
signature : searchResult._degradedSignature,
419+
message : `Summary query path is degraded (${searchResult._degradedSignature}); this is NOT a genuine no-match. Underlying error: ${searchResult._degradedReason}`,
420+
query,
421+
count : 0,
422+
results : []
423+
};
424+
}
425+
409426
let ids = searchResult.ids?.[0] || [];
410427
let distances = searchResult.distances?.[0] || [];
411428
let metadatas = searchResult.metadatas?.[0] || [];

ai/services/memory-core/managers/StorageRouter.mjs

Lines changed: 79 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Base from '../../../../src/core/Base.mjs';
22
import CollectionProxy from './CollectionProxy.mjs';
33
import GraphService from '../GraphService.mjs';
44
import logger from '../../../mcp/server/memory-core/logger.mjs';
5+
import aiConfig from '../../../mcp/server/memory-core/config.mjs';
56

67
/**
78
* StorageRouter acts as a transparent Proxy pattern for the underlying vector databases.
@@ -56,9 +57,69 @@ class StorageRouter extends Base {
5657
}
5758

5859
/**
59-
* Injects the Dual-Pass Re-Ranking Middleware into the CollectionProxy.
60+
* @summary On-demand probe of each collection's vector-query (HNSW) path.
61+
*
62+
* A populated-but-corrupt collection passes `count()` but throws on `query()` (e.g. a desynced
63+
* HNSW segment raising "Error finding id"). `injectQueryReRanker` catches that and stamps a
64+
* `_degraded` marker; this probe surfaces it per collection. It deliberately lives as a method +
65+
* `ai/scripts/maintenance/probeCollectionQueryHealth.mjs` script — NOT a healthcheck field and NOT
66+
* an MCP tool — so the always-on "is it healthy?" poll stays terse and the per-collection query
67+
* cost (plus its response-schema bytes) is paid only when an operator opts in.
68+
*
69+
* @returns {Promise<Object>} `{status: 'healthy'|'degraded', collections: {memory, summary}}`
70+
*/
71+
async probeCollectionQueryHealth() {
72+
const dimension = aiConfig.vectorDimension || 4096,
73+
probe = new Array(dimension).fill(0),
74+
collections = {},
75+
probes = [
76+
{type: 'memory', get: () => this.getMemoryCollection()},
77+
{type: 'summary', get: () => this.getSummaryCollection()}
78+
];
79+
80+
// Unit vector — non-degenerate for ANN distance; the content is irrelevant to the probe,
81+
// which only cares whether the query path traverses the vector segment without throwing.
82+
probe[0] = 1;
83+
84+
let degraded = null;
85+
86+
for (const {type, get} of probes) {
87+
try {
88+
const collection = await get();
89+
const count = await collection.count().catch(() => 0);
90+
const res = await collection.query({queryEmbeddings: [probe], nResults: 1});
91+
92+
if (res?._degraded) {
93+
collections[type] = {status: 'degraded', count, signature: res._degradedSignature, error: res._degradedReason};
94+
degraded = degraded || `${type}: ${res._degradedReason}`;
95+
} else {
96+
collections[type] = {status: 'healthy', count};
97+
}
98+
} catch (e) {
99+
collections[type] = {status: 'degraded', error: e.message};
100+
degraded = degraded || `${type}: ${e.message}`;
101+
}
102+
}
103+
104+
return degraded
105+
? {status: 'degraded', error: degraded, collections}
106+
: {status: 'healthy', collections};
107+
}
108+
109+
/**
110+
* @summary Injects the Dual-Pass Re-Ranking Middleware into the CollectionProxy.
111+
*
60112
* Pass 1: Uses ChromaDB\'s ANN search to fetch top K candidates.
61113
* Pass 2: Re-ranks based on topological weighting via the SQLite Native Edge Graph.
114+
*
115+
* **Degraded-result contract:** if the Pass-1 ChromaDB query throws (e.g. a desynced HNSW
116+
* segment raising "Error finding id"), the middleware stays non-throwing but returns a
117+
* `_degraded: true` result carrying `_degradedReason` / `_degradedCollection` /
118+
* `_degradedSignature`. Tool-facing callers (`SummaryService.querySummaries`,
119+
* `MemoryService.queryMemories`) MUST surface this as a degraded envelope rather than
120+
* collapsing it into a silent empty `{count:0}` — a degraded query path and a genuine
121+
* no-match must remain distinguishable to the caller.
122+
*
62123
* @param {CollectionProxy} proxy
63124
* @param {String} collectionType
64125
*/
@@ -77,8 +138,23 @@ class StorageRouter extends Base {
77138
const pass1Args = {...args, nResults: expandedNResults};
78139
searchResult = await originalQuery(pass1Args);
79140
} catch (e) {
80-
logger.warn(`[StorageRouter] Pass 1 semantic retrieval failed, falling back to empty result: ${e.message}`);
81-
return {ids: [[]], distances: [[]], metadatas: [[]], documents: undefined};
141+
// A populated-but-corrupt collection throws here (e.g. ChromaDB "Error finding id" on a
142+
// desynced HNSW segment). Returning a bare clean-empty result would be indistinguishable
143+
// from a genuine no-match and silently hide the failure for days. Stay non-throwing (the
144+
// Pass-2 pipeline resilience this catch exists for is preserved), but stamp a `_degraded`
145+
// marker — symmetric to the success path's `_reRanked` — so the tool-facing callers can
146+
// surface a degraded envelope instead of a silent `{count:0}`.
147+
const isCorruptionSignature = /Error finding id/i.test(e.message);
148+
149+
logger.error(`[StorageRouter] Pass 1 semantic retrieval failed on the '${collectionType}' collection${isCorruptionSignature ? ' (HNSW corruption signature "Error finding id")' : ''}: ${e.message}`);
150+
151+
return {
152+
ids: [[]], distances: [[]], metadatas: [[]], documents: undefined,
153+
_degraded : true,
154+
_degradedReason : e.message,
155+
_degradedCollection: collectionType,
156+
_degradedSignature: isCorruptionSignature ? 'chroma-error-finding-id' : 'chroma-query-error'
157+
};
82158
}
83159

84160
const pass1Ids = searchResult?.ids?.[0];

0 commit comments

Comments
 (0)