fix(storage): resolve count synchronization race condition across all storage adapters

Fixed critical bug where entity and relationship counts were not being tracked correctly
during add(), relate(), and import() operations. The root cause was a race condition where
count increment code tried to read metadata before it was saved to storage.

Core Fixes:
- Modified baseStorage.saveNounMetadata_internal to increment counts AFTER metadata is saved
- Modified baseStorage.saveVerbMetadata_internal to increment verb counts AFTER metadata is saved
- Added verb type to VerbMetadata to avoid circular dependency during count tracking
- Refactored verb count methods to prevent mutex deadlocks (synchronous base + async Safe wrapper)

Storage Adapter Cleanup:
- Removed broken count increment code from FileSystemStorage, GcsStorage, R2Storage, AzureBlobStorage
- Updated MemoryStorage comments to reflect centralized fix
- All count tracking now centralized in baseStorage (fixes ALL adapters automatically)

New Utilities:
- Added rebuildCounts utility to repair corrupted counts.json from actual storage data
- Added comprehensive integration tests for count synchronization across all operations

Verification:
- All 8 storage adapters verified (FileSystem, GCS, Memory, S3Compatible, R2, Azure, OPFS, TypeAware)
- All code paths verified (add, relate, import, batch, update, delete)
- 599 tests passing (no regressions)
- No deadlocks (tests complete in 6s vs 150s+)

Fixes #1 and #2 reported by Workshop team
This commit is contained in:
David Snelling 2025-10-21 10:58:44 -07:00
parent e5c56ed285
commit 798a6946d6
10 changed files with 598 additions and 76 deletions

View file

@ -1057,11 +1057,8 @@ export class AzureBlobStorage extends BaseStorage {
// Update cache
this.verbCacheManager.set(edge.id, edge)
// Increment verb count
const metadata = await this.getVerbMetadata(edge.id)
if (metadata && metadata.type) {
await this.incrementVerbCount(metadata.type as string)
}
// Count tracking happens in baseStorage.saveVerbMetadata_internal (v4.1.2)
// This fixes the race condition where metadata didn't exist yet
this.logger.trace(`Edge ${edge.id} saved successfully`)
this.releaseBackpressure(true, requestId)

View file

@ -1015,47 +1015,63 @@ export abstract class BaseStorageAdapter implements StorageAdapter {
}
/**
* Increment verb count - O(1) operation with mutex protection
* Increment verb count - O(1) operation (v4.1.2: now synchronous)
* Protected by storage-specific mechanisms (mutex, distributed consensus, etc.)
* @param type The verb type
*/
protected async incrementVerbCount(type: string): Promise<void> {
protected incrementVerbCount(type: string): void {
this.verbCounts.set(type, (this.verbCounts.get(type) || 0) + 1)
this.totalVerbCount++
// Update cache
this.countCache.set('verbs_count', {
count: this.totalVerbCount,
timestamp: Date.now()
})
}
/**
* Thread-safe increment for verb counts (v4.1.2)
* Uses mutex for single-node, distributed consensus for multi-node
* @param type The verb type
*/
protected async incrementVerbCountSafe(type: string): Promise<void> {
const mutex = getGlobalMutex()
await mutex.runExclusive(`count-verb-${type}`, async () => {
this.verbCounts.set(type, (this.verbCounts.get(type) || 0) + 1)
this.totalVerbCount++
// Update cache
this.countCache.set('verbs_count', {
count: this.totalVerbCount,
timestamp: Date.now()
})
this.incrementVerbCount(type)
// Smart batching (v3.32.3+): Adapts to storage type
await this.scheduleCountPersist()
})
}
/**
* Decrement verb count - O(1) operation with mutex protection
* Decrement verb count - O(1) operation (v4.1.2: now synchronous)
* @param type The verb type
*/
protected async decrementVerbCount(type: string): Promise<void> {
protected decrementVerbCount(type: string): void {
const current = this.verbCounts.get(type) || 0
if (current > 1) {
this.verbCounts.set(type, current - 1)
} else {
this.verbCounts.delete(type)
}
if (this.totalVerbCount > 0) {
this.totalVerbCount--
}
// Update cache
this.countCache.set('verbs_count', {
count: this.totalVerbCount,
timestamp: Date.now()
})
}
/**
* Thread-safe decrement for verb counts (v4.1.2)
* @param type The verb type
*/
protected async decrementVerbCountSafe(type: string): Promise<void> {
const mutex = getGlobalMutex()
await mutex.runExclusive(`count-verb-${type}`, async () => {
const current = this.verbCounts.get(type) || 0
if (current > 1) {
this.verbCounts.set(type, current - 1)
} else {
this.verbCounts.delete(type)
}
if (this.totalVerbCount > 0) {
this.totalVerbCount--
}
// Update cache
this.countCache.set('verbs_count', {
count: this.totalVerbCount,
timestamp: Date.now()
})
this.decrementVerbCount(type)
// Smart batching (v3.32.3+): Adapts to storage type
await this.scheduleCountPersist()
})

View file

@ -253,9 +253,6 @@ export class FileSystemStorage extends BaseStorage {
protected async saveNode(node: HNSWNode): Promise<void> {
await this.ensureInitialized()
// Check if this is a new node to update counts
const isNew = !(await this.fileExists(this.getNodePath(node.id)))
// Convert connections Map to a serializable format
// CRITICAL: Only save lightweight vector data (no metadata)
// Metadata is saved separately via saveNounMetadata() (2-file system)
@ -276,18 +273,8 @@ export class FileSystemStorage extends BaseStorage {
JSON.stringify(serializableNode, null, 2)
)
// Update counts for new nodes (v4.0.0: load metadata separately)
if (isNew) {
// v4.0.0: Get type from separate metadata storage
const metadata = await this.getNounMetadata(node.id)
const type = metadata?.noun || 'default'
this.incrementEntityCount(type)
// Persist counts periodically (every 10 operations for efficiency)
if (this.totalNounCount % 10 === 0) {
await this.persistCounts()
}
}
// Count tracking happens in baseStorage.saveNounMetadata_internal (v4.1.2)
// This fixes the race condition where metadata didn't exist yet
}
/**
@ -460,9 +447,6 @@ export class FileSystemStorage extends BaseStorage {
protected async saveEdge(edge: Edge): Promise<void> {
await this.ensureInitialized()
// Check if this is a new edge to update counts
const isNew = !(await this.fileExists(this.getVerbPath(edge.id)))
// Convert connections Map to a serializable format
// ARCHITECTURAL FIX (v3.50.1): Include core relational fields in verb vector file
// These fields are essential for 90% of operations - no metadata lookup needed
@ -489,15 +473,8 @@ export class FileSystemStorage extends BaseStorage {
JSON.stringify(serializableEdge, null, 2)
)
// Update verb count for new edges (production-scale optimizations)
if (isNew) {
this.totalVerbCount++
// Persist counts periodically (every 10 operations for efficiency)
if (this.totalVerbCount % 10 === 0) {
this.persistCounts() // Async persist, don't await
}
}
// Count tracking happens in baseStorage.saveVerbMetadata_internal (v4.1.2)
// This fixes the race condition where metadata didn't exist yet
}
/**

View file

@ -488,11 +488,8 @@ export class GcsStorage extends BaseStorage {
}
// Note: Empty vectors are intentional during HNSW lazy mode - not logged
// Increment noun count
const metadata = await this.getNounMetadata(node.id)
if (metadata && metadata.type) {
await this.incrementEntityCountSafe(metadata.type as string)
}
// Count tracking happens in baseStorage.saveNounMetadata_internal (v4.1.2)
// This fixes the race condition where metadata didn't exist yet
this.logger.trace(`Node ${node.id} saved successfully`)
this.releaseBackpressure(true, requestId)
@ -858,11 +855,8 @@ export class GcsStorage extends BaseStorage {
// Update cache
this.verbCacheManager.set(edge.id, edge)
// Increment verb count
const metadata = await this.getVerbMetadata(edge.id)
if (metadata && metadata.type) {
await this.incrementVerbCount(metadata.type as string)
}
// Count tracking happens in baseStorage.saveVerbMetadata_internal (v4.1.2)
// This fixes the race condition where metadata didn't exist yet
this.logger.trace(`Edge ${edge.id} saved successfully`)
this.releaseBackpressure(true, requestId)

View file

@ -79,7 +79,8 @@ export class MemoryStorage extends BaseStorage {
// Save the noun directly in the nouns map
this.nouns.set(noun.id, nounCopy)
// Note: Count tracking happens in saveNounMetadata since type info is in metadata now
// Count tracking happens in baseStorage.saveNounMetadata_internal (v4.1.2)
// This fixes the race condition where metadata didn't exist yet
}
/**

View file

@ -771,10 +771,8 @@ export class R2Storage extends BaseStorage {
this.verbCacheManager.set(edge.id, edge)
const metadata = await this.getVerbMetadata(edge.id)
if (metadata && metadata.type) {
await this.incrementVerbCount(metadata.type as string)
}
// Count tracking happens in baseStorage.saveVerbMetadata_internal (v4.1.2)
// This fixes the race condition where metadata didn't exist yet
this.releaseBackpressure(true, requestId)
} catch (error: any) {