From c15892ef048e2b9c325e7190d6a8abbf268e8f5a Mon Sep 17 00:00:00 2001 From: David Snelling Date: Thu, 4 Dec 2025 12:55:23 -0800 Subject: [PATCH] fix(architecture): singleton GraphAdjacencyIndex via storage.getGraphIndex() (v6.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING: This is a critical architectural fix for the VFS tree corruption bug reported by Soulcraft Workshop team. The fix addresses the root cause: dual ownership of GraphAdjacencyIndex causing verbIdSet to be out of sync. ## Root Cause Analysis The bug was caused by TWO separate GraphAdjacencyIndex instances: 1. Storage.graphIndex (created in BaseStorage.init()) 2. Brainy.graphIndex (created in Brainy.init()) When verbs were saved, both instances were updated. But if Storage's graphIndex was recreated (via ensureInitialized()), the new instance had an empty verbIdSet. Queries filtered through this empty verbIdSet returned nothing - making data appear lost even though it existed in the LSM-trees. ## Fix Summary 1. **GraphAdjacencyIndex Singleton Pattern** - Removed direct creation from BaseStorage.init() - Brainy now uses `storage.getGraphIndex()` instead of creating its own - getGraphIndex() has proper singleton pattern with concurrent access protection - Added `invalidateGraphIndex()` for branch switches 2. **Auto-rebuild verbIdSet Defense** - Added check in ensureInitialized(): if LSM-trees have data but verbIdSet is empty, automatically populate verbIdSet from storage - This is a safety net for edge cases 3. **Removed Double-Add Bug** - Removed graphIndex.addVerb() from saveVerb_internal() - Graph index updates now happen ONLY via AddToGraphIndexOperation in Brainy.relate() transaction system - This prevents duplicate counting in relationshipCountsByType 4. **PathResolver Cache Invalidation** - Added invalidateAllCaches() method to PathResolver and SemanticPathResolver - checkout() now clears VFS caches before recreating VFS for new branch ## Files Changed - src/storage/baseStorage.ts: Removed graphIndex creation from init(), added invalidateGraphIndex(), removed addVerb from saveVerb_internal() - src/brainy.ts: Use storage.getGraphIndex() in init/fork/checkout - src/graph/graphAdjacencyIndex.ts: Auto-rebuild verbIdSet in ensureInitialized() - src/vfs/PathResolver.ts: Added invalidateAllCaches() - src/vfs/semantic/SemanticPathResolver.ts: Added invalidateAllCaches() ## Testing All VFS tests pass (7/7), including: - mkdir() should not corrupt VFS index - Delete and recreate folder cycles - Contains relationship queries 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- src/brainy.ts | 32 ++++++++--- src/graph/graphAdjacencyIndex.ts | 54 +++++++++++++++++++ src/storage/baseStorage.ts | 68 +++++++++++------------- src/vfs/PathResolver.ts | 24 +++++++++ src/vfs/semantic/SemanticPathResolver.ts | 12 +++++ 5 files changed, 148 insertions(+), 42 deletions(-) diff --git a/src/brainy.ts b/src/brainy.ts index 8067c85b..91d3632d 100644 --- a/src/brainy.ts +++ b/src/brainy.ts @@ -231,8 +231,11 @@ export class Brainy implements BrainyInterface { this.metadataIndex = new MetadataIndexManager(this.storage) await this.metadataIndex.init() - // Initialize core graph index - this.graphIndex = new GraphAdjacencyIndex(this.storage) + // v6.3.0: Get GraphAdjacencyIndex from storage (SINGLETON pattern) + // Storage owns the single instance, Brainy accesses it via getGraphIndex() + // This fixes the dual-ownership bug where Brainy and Storage had separate instances + // causing verbIdSet to be out of sync and VFS tree queries to fail + this.graphIndex = await (this.storage as any).getGraphIndex() // Rebuild indexes if needed for existing data await this.rebuildIndexesIfNeeded() @@ -2636,8 +2639,14 @@ export class Brainy implements BrainyInterface { clone.metadataIndex = new MetadataIndexManager(clone.storage) await clone.metadataIndex.init() - clone.graphIndex = new GraphAdjacencyIndex(clone.storage) - await clone.graphIndex.rebuild() + // v6.3.0: GraphAdjacencyIndex SINGLETON pattern for fork() + // Object.create() causes prototype inheritance, so clone.storage.graphIndex + // would point to parent's graphIndex. We must break this inheritance and + // create a fresh instance for the clone's branch. + ;(clone.storage as any).graphIndex = undefined + ;(clone.storage as any).graphIndexPromise = undefined + clone.graphIndex = await (clone.storage as any).getGraphIndex() + // getGraphIndex() will rebuild automatically if data exists (via _initializeGraphIndex) // Setup augmentations clone.augmentationRegistry = this.setupAugmentations() @@ -2742,7 +2751,12 @@ export class Brainy implements BrainyInterface { this.index = this.setupIndex() this.metadataIndex = new (MetadataIndexManager as any)(this.storage) await this.metadataIndex.init() - this.graphIndex = new GraphAdjacencyIndex(this.storage) + + // v6.3.0: GraphAdjacencyIndex SINGLETON pattern for checkout() + // Invalidate the old graphIndex (it has data from the old branch) + // and get a fresh instance for the new branch + ;(this.storage as any).invalidateGraphIndex() + this.graphIndex = await (this.storage as any).getGraphIndex() // v5.7.7: Reset lazy loading state when switching branches // Indexes contain data from previous branch, must rebuild for new branch @@ -2751,8 +2765,14 @@ export class Brainy implements BrainyInterface { // Rebuild indexes from new branch data (force=true to override disableAutoRebuild) await this.rebuildIndexesIfNeeded(true) - // Re-initialize VFS for new branch + // v6.3.0: Clear VFS caches before recreating VFS for new branch + // UnifiedCache is global, so old branch's VFS path cache entries would persist if (this._vfs) { + // Clear old PathResolver's caches including UnifiedCache entries + if ((this._vfs as any).pathResolver?.invalidateAllCaches) { + (this._vfs as any).pathResolver.invalidateAllCaches() + } + // Recreate VFS for new branch this._vfs = new VirtualFileSystem(this) await this._vfs.init() } diff --git a/src/graph/graphAdjacencyIndex.ts b/src/graph/graphAdjacencyIndex.ts index af58c5cf..b130c291 100644 --- a/src/graph/graphAdjacencyIndex.ts +++ b/src/graph/graphAdjacencyIndex.ts @@ -117,6 +117,7 @@ export class GraphAdjacencyIndex { /** * Initialize the graph index (lazy initialization) + * v6.3.0: Added defensive auto-rebuild check for verbIdSet consistency */ private async ensureInitialized(): Promise { if (this.initialized) { @@ -128,12 +129,65 @@ export class GraphAdjacencyIndex { await this.lsmTreeVerbsBySource.init() await this.lsmTreeVerbsByTarget.init() + // v6.3.0: Defensive check - if LSM-trees have data but verbIdSet is empty, + // the index was created without proper rebuild (shouldn't happen with singleton + // pattern but protects against edge cases and future refactoring) + const lsmTreeSize = this.lsmTreeVerbsBySource.size() + if (lsmTreeSize > 0 && this.verbIdSet.size === 0) { + prodLog.warn( + `GraphAdjacencyIndex: LSM-trees have ${lsmTreeSize} relationships but verbIdSet is empty. ` + + `Triggering auto-rebuild to restore consistency.` + ) + // Note: We don't await rebuild() here to avoid infinite loop + // (rebuild calls ensureInitialized). Instead, we'll populate verbIdSet + // by loading all verb IDs from storage. + await this.populateVerbIdSetFromStorage() + } + // Start auto-flush timer after initialization this.startAutoFlush() this.initialized = true } + /** + * Populate verbIdSet from storage without full rebuild (v6.3.0) + * Lighter weight than full rebuild - only loads verb IDs, not all verb data + * @private + */ + private async populateVerbIdSetFromStorage(): Promise { + prodLog.info('GraphAdjacencyIndex: Populating verbIdSet from storage...') + const startTime = Date.now() + + // Use pagination to load all verb IDs + let hasMore = true + let cursor: string | undefined = undefined + let count = 0 + + while (hasMore) { + const result = await this.storage.getVerbs({ + pagination: { limit: 10000, cursor } + }) + + for (const verb of result.items) { + this.verbIdSet.add(verb.id) + // Also update counts + const verbType = verb.verb || 'unknown' + this.relationshipCountsByType.set( + verbType, + (this.relationshipCountsByType.get(verbType) || 0) + 1 + ) + count++ + } + + hasMore = result.hasMore + cursor = result.nextCursor + } + + const elapsed = Date.now() - startTime + prodLog.info(`GraphAdjacencyIndex: Populated verbIdSet with ${count} verb IDs in ${elapsed}ms`) + } + /** * Core API - Neighbor lookup with LSM-tree storage * diff --git a/src/storage/baseStorage.ts b/src/storage/baseStorage.ts index 475a2cd7..a7f7d370 100644 --- a/src/storage/baseStorage.ts +++ b/src/storage/baseStorage.ts @@ -268,17 +268,12 @@ export abstract class BaseStorage extends BaseStorageAdapter { // Load type statistics from storage (if they exist) await this.loadTypeStatistics() - // v6.0.0: Create GraphAdjacencyIndex (lazy-loaded, no rebuild) - // LSM-trees are initialized on first use via ensureInitialized() - // Index is populated incrementally as verbs are added via addVerb() - try { - prodLog.debug('[BaseStorage] Creating GraphAdjacencyIndex...') - this.graphIndex = new GraphAdjacencyIndex(this) - prodLog.debug(`[BaseStorage] GraphAdjacencyIndex instantiated (lazy-loaded), graphIndex=${!!this.graphIndex}`) - } catch (error) { - prodLog.error('[BaseStorage] Failed to create GraphAdjacencyIndex:', error) - throw error - } + // v6.3.0: GraphAdjacencyIndex is now SINGLETON via getGraphIndex() + // - Removed direct creation here to fix dual-ownership bug + // - GraphAdjacencyIndex will be created lazily on first getGraphIndex() call + // - This ensures there's only ONE instance per storage adapter + // - See: https://github.com/soulcraftlabs/brainy/issues/vfs-corruption + prodLog.debug('[BaseStorage] init() complete - GraphAdjacencyIndex will be created via getGraphIndex()') } catch (error) { // Reset flag on failure to allow retry this.isInitialized = false @@ -292,14 +287,30 @@ export abstract class BaseStorage extends BaseStorageAdapter { * @public */ public async rebuildGraphIndex(): Promise { - if (!this.graphIndex) { - throw new Error('GraphAdjacencyIndex not initialized') - } + const index = await this.getGraphIndex() prodLog.info('[BaseStorage] Rebuilding graph index from existing data...') - await this.graphIndex.rebuild() + await index.rebuild() prodLog.info('[BaseStorage] Graph index rebuild complete') } + /** + * Invalidate GraphAdjacencyIndex (v6.3.0) + * Call this when switching branches or clearing data to force re-creation + * The next getGraphIndex() call will create a fresh instance and rebuild + * @public + */ + public invalidateGraphIndex(): void { + if (this.graphIndex) { + prodLog.info('[BaseStorage] Invalidating GraphAdjacencyIndex for branch switch/clear') + // Stop any pending operations + if (typeof (this.graphIndex as any).stopAutoFlush === 'function') { + (this.graphIndex as any).stopAutoFlush() + } + this.graphIndex = undefined + this.graphIndexPromise = undefined + } + } + /** * Ensure the storage adapter is initialized */ @@ -2823,27 +2834,12 @@ export abstract class BaseStorage extends BaseStorageAdapter { // COW-aware write (v5.0.1): Use COW helper for branch isolation await this.writeObjectToBranch(path, verb) - // v6.0.0: Update GraphAdjacencyIndex incrementally (always available after init()) - // GraphAdjacencyIndex.addVerb() calls ensureInitialized() automatically - if (this.graphIndex) { - prodLog.debug(`[BaseStorage] Updating GraphAdjacencyIndex with verb ${verb.id}`) - await this.graphIndex.addVerb({ - id: verb.id, - sourceId: verb.sourceId, - targetId: verb.targetId, - vector: verb.vector, - source: verb.sourceId, - target: verb.targetId, - verb: verb.verb, - type: verb.verb, - createdAt: { seconds: Math.floor(Date.now() / 1000), nanoseconds: 0 }, - updatedAt: { seconds: Math.floor(Date.now() / 1000), nanoseconds: 0 }, - createdBy: { augmentation: 'storage', version: '6.0.0' } - }) - prodLog.debug(`[BaseStorage] GraphAdjacencyIndex updated successfully`) - } else { - prodLog.warn(`[BaseStorage] graphIndex is null, cannot update index for verb ${verb.id}`) - } + // v6.3.0: GraphAdjacencyIndex updates are now handled EXCLUSIVELY by Brainy.relate() + // via AddToGraphIndexOperation in the transaction system. This provides: + // 1. Singleton pattern - only one graphIndex instance exists (via getGraphIndex()) + // 2. Transaction rollback - if relate() fails, index update is rolled back + // 3. No double-counting - prevents duplicate addVerb() calls + // REMOVED: Direct graphIndex.addVerb() call that caused dual-ownership bugs // Periodically save statistics // v6.2.9: Also save on first verb of each type to ensure low-count types are tracked diff --git a/src/vfs/PathResolver.ts b/src/vfs/PathResolver.ts index ae9b06a2..fc43c182 100644 --- a/src/vfs/PathResolver.ts +++ b/src/vfs/PathResolver.ts @@ -343,6 +343,30 @@ export class PathResolver { } } + /** + * Invalidate ALL caches (v6.3.0) + * Call this when switching branches (checkout), clearing data (clear), or forking + * This ensures no stale data from previous branch/state remains in cache + */ + invalidateAllCaches(): void { + // Clear all local caches + this.pathCache.clear() + this.parentCache.clear() + this.hotPaths.clear() + + // Clear all VFS entries from UnifiedCache + getGlobalCache().deleteByPrefix('vfs:path:') + + // Reset statistics (optional but helpful for debugging) + this.cacheHits = 0 + this.cacheMisses = 0 + this.metadataIndexHits = 0 + this.metadataIndexMisses = 0 + this.graphTraversalFallbacks = 0 + + prodLog.info('[PathResolver] All caches invalidated') + } + /** * Invalidate cache entries for a path and its children * v6.2.9 FIX: Also invalidates UnifiedCache to prevent stale entity IDs diff --git a/src/vfs/semantic/SemanticPathResolver.ts b/src/vfs/semantic/SemanticPathResolver.ts index 639798b0..cfedf257 100644 --- a/src/vfs/semantic/SemanticPathResolver.ts +++ b/src/vfs/semantic/SemanticPathResolver.ts @@ -316,6 +316,18 @@ export class SemanticPathResolver { this.cache.clear() } + /** + * Invalidate ALL caches (v6.3.0) + * Clears both traditional path cache AND semantic cache + * Call this when switching branches, clearing data, or forking + */ + invalidateAllCaches(): void { + // Clear traditional PathResolver caches (including UnifiedCache VFS entries) + this.pathResolver.invalidateAllCaches() + // Clear semantic cache + this.cache.clear() + } + /** * Cleanup resources */