-
Notifications
You must be signed in to change notification settings - Fork 2.4k
fix(state): reclaim orphaned index shards and add index diagnostics/healing #1282
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,7 +10,6 @@ import type { | |
| Insight, | ||
| Lease, | ||
| Lesson, | ||
| Checkpoint, | ||
| Crystal, | ||
| ProceduralMemory, | ||
| SemanticMemory, | ||
|
|
@@ -38,6 +37,7 @@ const ALL_CATEGORIES = [ | |
| "crystals", | ||
| "insights", | ||
| "mesh", | ||
| "index", | ||
| ]; | ||
|
|
||
| const TWENTY_FOUR_HOURS_MS = 24 * 60 * 60 * 1000; | ||
|
|
@@ -620,6 +620,200 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void { | |
| } | ||
| } | ||
|
|
||
| if (categories.includes("index")) { | ||
| const [bm25Settled, vectorSettled, registrySettled] = | ||
| await Promise.allSettled([ | ||
| kv.get<{ | ||
| v: number; | ||
| generation?: string; | ||
| shards?: unknown[]; | ||
| chars?: number; | ||
| }>(KV.bm25Index, "data:manifest"), | ||
| kv.get<{ | ||
| v: number; | ||
| generation?: string; | ||
| shards?: unknown[]; | ||
| chars?: number; | ||
| }>(KV.bm25Index, "vectors:manifest"), | ||
| kv.get<{ | ||
| v: number; | ||
| generations?: Record< | ||
| string, | ||
| { | ||
| type: "bm25" | "vector"; | ||
| createdAt: string; | ||
| shardScopes: string[]; | ||
| } | ||
| >; | ||
| }>(KV.bm25Index, "generations:registry"), | ||
| ]); | ||
|
|
||
| let bm25Eligible = false; | ||
| let bm25Manifest: { | ||
| v: number; | ||
| generation?: string; | ||
| shards?: unknown[]; | ||
| chars?: number; | ||
| } | null = null; | ||
| if (bm25Settled.status === "fulfilled") { | ||
| const m = bm25Settled.value; | ||
| if (m === null || m === undefined) { | ||
| bm25Eligible = true; | ||
| bm25Manifest = null; | ||
| checks.push({ | ||
| name: "index-manifest-bm25", | ||
| category: "index", | ||
| status: "warn", | ||
| message: | ||
| "BM25 index manifest not found (index not yet persisted or empty)", | ||
| fixable: false, | ||
| }); | ||
| } else if (m && m.v === 1 && Array.isArray(m.shards)) { | ||
| bm25Eligible = true; | ||
| bm25Manifest = m; | ||
| checks.push({ | ||
| name: "index-manifest-bm25", | ||
| category: "index", | ||
| status: "pass", | ||
| message: `BM25 index manifest is valid (${m.shards.length} shards)`, | ||
| fixable: false, | ||
| }); | ||
| } else { | ||
| checks.push({ | ||
| name: "index-manifest-bm25", | ||
| category: "index", | ||
| status: "fail", | ||
| message: "BM25 index manifest is corrupt", | ||
| fixable: false, | ||
| }); | ||
| } | ||
| } else { | ||
| checks.push({ | ||
| name: "index-manifest-bm25", | ||
| category: "index", | ||
| status: "fail", | ||
| message: "BM25 index manifest read failed", | ||
| fixable: false, | ||
| }); | ||
| } | ||
|
|
||
| let vectorEligible = false; | ||
| let vectorManifest: { | ||
| v: number; | ||
| generation?: string; | ||
| shards?: unknown[]; | ||
| chars?: number; | ||
| } | null = null; | ||
| if (vectorSettled.status === "fulfilled") { | ||
| const m = vectorSettled.value; | ||
| if (m === null || m === undefined) { | ||
| vectorEligible = true; | ||
| vectorManifest = null; | ||
| checks.push({ | ||
| name: "index-manifest-vectors", | ||
| category: "index", | ||
| status: "warn", | ||
| message: | ||
| "Vector index manifest not found (index not yet persisted or empty)", | ||
| fixable: false, | ||
| }); | ||
| } else if (m && m.v === 1 && Array.isArray(m.shards)) { | ||
| vectorEligible = true; | ||
| vectorManifest = m; | ||
| checks.push({ | ||
| name: "index-manifest-vectors", | ||
| category: "index", | ||
| status: "pass", | ||
| message: `Vector index manifest is valid (${m.shards.length} shards)`, | ||
| fixable: false, | ||
| }); | ||
| } else { | ||
| checks.push({ | ||
| name: "index-manifest-vectors", | ||
| category: "index", | ||
| status: "fail", | ||
| message: "Vector index manifest is corrupt", | ||
| fixable: false, | ||
| }); | ||
| } | ||
| } else { | ||
| checks.push({ | ||
| name: "index-manifest-vectors", | ||
| category: "index", | ||
| status: "fail", | ||
| message: "Vector index manifest read failed", | ||
| fixable: false, | ||
| }); | ||
| } | ||
|
|
||
| const registry = | ||
| registrySettled.status === "fulfilled" ? registrySettled.value : null; | ||
| const activeBm25Gen = | ||
| bm25Eligible && | ||
| bm25Manifest && | ||
| typeof bm25Manifest.generation === "string" | ||
| ? bm25Manifest.generation | ||
| : null; | ||
| const activeVectorGen = | ||
| vectorEligible && | ||
| vectorManifest && | ||
| typeof vectorManifest.generation === "string" | ||
| ? vectorManifest.generation | ||
| : null; | ||
|
|
||
| const INDEX_GRACE_PERIOD_MS = 60_000; | ||
| let orphanGenCount = 0; | ||
| let orphanShardCount = 0; | ||
|
|
||
| if ( | ||
| registry && | ||
| registry.v === 1 && | ||
| registry.generations && | ||
| typeof registry.generations === "object" | ||
| ) { | ||
| for (const [genId, genInfo] of Object.entries(registry.generations)) { | ||
| if (genInfo.type === "bm25") { | ||
| if (!bm25Eligible) continue; | ||
| if (activeBm25Gen && genId === activeBm25Gen) continue; | ||
| } else if (genInfo.type === "vector") { | ||
| if (!vectorEligible) continue; | ||
| if (activeVectorGen && genId === activeVectorGen) continue; | ||
| } else { | ||
| continue; | ||
| } | ||
|
|
||
| const createdAtMs = Date.parse(genInfo.createdAt); | ||
| if ( | ||
| Number.isNaN(createdAtMs) || | ||
| now - createdAtMs > INDEX_GRACE_PERIOD_MS | ||
| ) { | ||
| orphanGenCount++; | ||
| if (Array.isArray(genInfo.shardScopes)) { | ||
| orphanShardCount += genInfo.shardScopes.length; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (orphanGenCount > 0) { | ||
| checks.push({ | ||
| name: "index-orphan-shards", | ||
| category: "index", | ||
| status: "fail", | ||
| message: `Found ${orphanGenCount} orphan generations (${orphanShardCount} shards) in index registry`, | ||
| fixable: true, | ||
| }); | ||
| } else { | ||
| checks.push({ | ||
| name: "index-orphan-shards", | ||
| category: "index", | ||
| status: "pass", | ||
| message: "Index shard generations are clean (no orphan shards)", | ||
| fixable: false, | ||
| }); | ||
| } | ||
|
Comment on lines
+806
to
+814
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win Do not report "clean" when the generation registry is unreadable or corrupt. Line 750 sets Both cases are failures, not clean states. The BM25 and vector manifests each get an explicit Add a registry check and reserve the 🐛 Proposed fix+ const registryValid =
+ registry !== null &&
+ registry.v === 1 &&
+ !!registry.generations &&
+ typeof registry.generations === "object";
+
+ if (registrySettled.status === "rejected") {
+ checks.push({
+ name: "index-generation-registry",
+ category: "index",
+ status: "fail",
+ message: "Index generation registry read failed",
+ fixable: false,
+ });
+ } else if (registrySettled.value != null && !registryValid) {
+ checks.push({
+ name: "index-generation-registry",
+ category: "index",
+ status: "fail",
+ message: "Index generation registry is corrupt",
+ fixable: false,
+ });
+ }
+
if (orphanGenCount > 0) {
checks.push({
name: "index-orphan-shards",
category: "index",
status: "fail",
message: `Found ${orphanGenCount} orphan generations (${orphanShardCount} shards) in index registry`,
fixable: true,
});
- } else {
+ } else if (registryValid || registrySettled.value == null) {
checks.push({
name: "index-orphan-shards",
category: "index",
status: "pass",
message: "Index shard generations are clean (no orphan shards)",
fixable: false,
});
}Note that 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| const summary = { | ||
| pass: checks.filter((c) => c.status === "pass").length, | ||
| warn: checks.filter((c) => c.status === "warn").length, | ||
|
|
@@ -1057,6 +1251,166 @@ export function registerDiagnosticsFunction(sdk: ISdk, kv: StateKV): void { | |
| } | ||
| } | ||
|
|
||
| if (categories.includes("index")) { | ||
| const [bm25Settled, vectorSettled, registrySettled] = | ||
| await Promise.allSettled([ | ||
| kv.get<{ | ||
| v: number; | ||
| generation?: string; | ||
| shards?: unknown[]; | ||
| chars?: number; | ||
| }>(KV.bm25Index, "data:manifest"), | ||
| kv.get<{ | ||
| v: number; | ||
| generation?: string; | ||
| shards?: unknown[]; | ||
| chars?: number; | ||
| }>(KV.bm25Index, "vectors:manifest"), | ||
| kv.get<{ | ||
| v: number; | ||
| generations?: Record< | ||
| string, | ||
| { | ||
| type: "bm25" | "vector"; | ||
| createdAt: string; | ||
| shardScopes: string[]; | ||
| } | ||
| >; | ||
| }>(KV.bm25Index, "generations:registry"), | ||
| ]); | ||
|
|
||
| let bm25Eligible = false; | ||
| let bm25Manifest: { | ||
| v: number; | ||
| generation?: string; | ||
| shards?: unknown[]; | ||
| chars?: number; | ||
| } | null = null; | ||
| if (bm25Settled.status === "fulfilled") { | ||
| const m = bm25Settled.value; | ||
| if (m === null || m === undefined) { | ||
| bm25Eligible = true; | ||
| bm25Manifest = null; | ||
| } else if (m && m.v === 1 && Array.isArray(m.shards)) { | ||
| bm25Eligible = true; | ||
| bm25Manifest = m; | ||
| } | ||
| } | ||
|
|
||
| let vectorEligible = false; | ||
| let vectorManifest: { | ||
| v: number; | ||
| generation?: string; | ||
| shards?: unknown[]; | ||
| chars?: number; | ||
| } | null = null; | ||
| if (vectorSettled.status === "fulfilled") { | ||
| const m = vectorSettled.value; | ||
| if (m === null || m === undefined) { | ||
| vectorEligible = true; | ||
| vectorManifest = null; | ||
| } else if (m && m.v === 1 && Array.isArray(m.shards)) { | ||
| vectorEligible = true; | ||
| vectorManifest = m; | ||
| } | ||
| } | ||
|
|
||
| const registry = | ||
| registrySettled.status === "fulfilled" ? registrySettled.value : null; | ||
| const activeBm25Gen = | ||
| bm25Eligible && | ||
| bm25Manifest && | ||
| typeof bm25Manifest.generation === "string" | ||
| ? bm25Manifest.generation | ||
| : null; | ||
| const activeVectorGen = | ||
| vectorEligible && | ||
| vectorManifest && | ||
| typeof vectorManifest.generation === "string" | ||
| ? vectorManifest.generation | ||
| : null; | ||
|
|
||
| const INDEX_GRACE_PERIOD_MS = 60_000; | ||
| const orphanGens: Array<{ | ||
| id: string; | ||
| type: "bm25" | "vector"; | ||
| createdAt: string; | ||
| shardScopes: string[]; | ||
| }> = []; | ||
| let orphanShardCount = 0; | ||
|
|
||
| if ( | ||
| registry && | ||
| registry.v === 1 && | ||
| registry.generations && | ||
| typeof registry.generations === "object" | ||
| ) { | ||
| for (const [genId, genInfo] of Object.entries(registry.generations)) { | ||
| if (genInfo.type === "bm25") { | ||
| if (!bm25Eligible) continue; | ||
| if (activeBm25Gen && genId === activeBm25Gen) continue; | ||
| } else if (genInfo.type === "vector") { | ||
| if (!vectorEligible) continue; | ||
| if (activeVectorGen && genId === activeVectorGen) continue; | ||
| } else { | ||
| continue; | ||
| } | ||
|
|
||
| const createdAtMs = Date.parse(genInfo.createdAt); | ||
| if ( | ||
| Number.isNaN(createdAtMs) || | ||
| now - createdAtMs > INDEX_GRACE_PERIOD_MS | ||
| ) { | ||
| const scopes = Array.isArray(genInfo.shardScopes) | ||
| ? genInfo.shardScopes | ||
| : []; | ||
| orphanGens.push({ | ||
| id: genId, | ||
| type: genInfo.type, | ||
| createdAt: genInfo.createdAt, | ||
| shardScopes: scopes, | ||
| }); | ||
| orphanShardCount += scopes.length; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if (orphanGens.length > 0) { | ||
| if (dryRun) { | ||
| details.push( | ||
| `[dry-run] Would delete ${orphanShardCount} orphan shards across ${orphanGens.length} unreferenced generations`, | ||
| ); | ||
| fixed++; | ||
| } else { | ||
| const deletePromises: Promise<void>[] = []; | ||
| for (const gen of orphanGens) { | ||
| for (const scope of gen.shardScopes) { | ||
| deletePromises.push(kv.delete(scope, "data")); | ||
| } | ||
| } | ||
| await Promise.allSettled(deletePromises); | ||
|
|
||
| for (const gen of orphanGens) { | ||
| delete registry!.generations![gen.id]; | ||
| } | ||
| await kv.set(KV.bm25Index, "generations:registry", registry); | ||
|
|
||
| for (const gen of orphanGens) { | ||
| await recordAudit(kv, "heal", "mem::heal", [gen.id], { | ||
| entityType: "index_shard", | ||
| reason: "orphan-shard-gc", | ||
| action: "delete", | ||
| }); | ||
| } | ||
|
|
||
| details.push( | ||
| `Deleted ${orphanShardCount} orphan shards from ${orphanGens.length} unreferenced generations`, | ||
| ); | ||
| fixed++; | ||
| } | ||
| } | ||
| } | ||
|
|
||
| return { success: true, fixed, skipped, details }; | ||
| }, | ||
| ); | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
Share the orphan-classification logic and the KV key constants with
index-persistence.ts.This block, the heal block at Lines 1255-1376, and
IndexPersistence.sweepOrphanShards()insrc/state/index-persistence.tsLines 154-247 each implement the same algorithm: read both manifests, validate their shape, derive eligibility and the active generation, then classify registry generations against a 60-second grace period.The three copies have already diverged. The sweep treats a generation whose age equals the grace period as an orphan (
now - createdAtMs < gracePeriodskips), while both blocks here requirenow - createdAtMs > INDEX_GRACE_PERIOD_MS. The key names"data:manifest","vectors:manifest","generations:registry", the shard key"data", and the 60000 ms grace period are re-declared as literals in all three places.Export the registry type, the key constants, and a single classification helper from
src/state/index-persistence.ts, then call it from both diagnostics blocks. That removes the drift and keeps the reported orphans identical to the ones the sweep deletes.🤖 Prompt for AI Agents