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 */