fix: resolve v5.7.0 deadlock by restoring storage layer separation (v5.7.1)

CRITICAL BUG FIX - v5.7.0 caused complete production failure

PROBLEM:
v5.7.0 introduced circular dependency deadlock during GraphAdjacencyIndex initialization:
  GraphAdjacencyIndex.rebuild()
  → storage.getVerbs()
  → getVerbsBySource_internal()
  → getGraphIndex() [NEW in v5.7.0]
  → [waiting for rebuild to complete]
  → DEADLOCK

SYMPTOMS (Production Impact):
- ALL imports hung at "Reading Data Structure" for 760+ seconds
- brain.add() operations took 12+ seconds per entity (50x slower)
- Zero entities imported successfully
- 100% of Workshop users unable to import files
- No errors thrown - infinite wait
- Forced immediate rollback to v5.6.3

ROOT CAUSE:
v5.7.0 modified storage internal methods (getVerbsBySource_internal,
getVerbsByTarget_internal) to use GraphAdjacencyIndex optimization,
creating tight coupling where storage depends on index AND index depends
on storage. This violated separation of concerns and created deadlock.

SOLUTION (Architectural Fix):
Reverted storage internals to v5.6.3 implementation (lines 2320-2444):
- Storage layer simple, no index dependencies 
- GraphAdjacencyIndex can safely call storage.getVerbs() to rebuild 
- No circular dependency possible 
- Proper layered architecture restored 

LAYERS (Correct Architecture):
  Layer 3 (Brainy/Queries): CAN use GraphAdjacencyIndex
  Layer 2 (GraphAdjacencyIndex): Uses storage.getVerbs() to rebuild
  Layer 1 (Storage Internals): NO GraphAdjacencyIndex calls

IMPACT:
- Slightly slower GraphAdjacencyIndex.rebuild() (one-time init cost)
- High-level queries still use optimized index
- Import performance unaffected (writes don't trigger init)
- NO breaking changes to public API

TESTING:
- Added 4 regression tests (tests/regression/v5.7.0-deadlock.test.ts)
- All 1146 existing tests pass 
- Import + relationships complete in <1 second (not 760+)
- No 12+ second delays per entity 

FILES CHANGED:
- src/storage/baseStorage.ts (reverted lines 2320-2444 to v5.6.3)
- tests/regression/v5.7.0-deadlock.test.ts (new regression tests)
- CHANGELOG.md (comprehensive v5.7.1 entry with upgrade instructions)

VERIFICATION:
Workshop team should upgrade immediately:
  npm install @soulcraft/brainy@5.7.1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
David Snelling 2025-11-11 15:24:43 -08:00
parent 8be429870c
commit eb9af45bab
3 changed files with 317 additions and 57 deletions

View file

@ -2320,90 +2320,126 @@ export abstract class BaseStorage extends BaseStorageAdapter {
protected async getVerbsBySource_internal(
sourceId: string
): Promise<HNSWVerbWithMetadata[]> {
// v5.7.0: BILLION-SCALE OPTIMIZATION - Use GraphAdjacencyIndex for O(log n) lookup
// Previous: O(total_verbs) - scanned all 127 verb types
// Now: O(log n) LSM-tree lookup + O(verbs_for_source) load
// v5.7.1: Reverted to v5.6.3 implementation to fix circular dependency deadlock
// v5.7.0 called getGraphIndex() here, creating deadlock during initialization:
// GraphAdjacencyIndex.rebuild() → storage.getVerbs() → getVerbsBySource_internal() → getGraphIndex() → [deadlock]
// v5.4.0: Type-first implementation - scan across all verb types
// COW-aware: uses readWithInheritance for each verb
await this.ensureInitialized()
const startTime = performance.now()
// Get GraphAdjacencyIndex (lazy-initialized)
const graphIndex = await this.getGraphIndex()
// O(log n) lookup with bloom filter optimization
const verbIds = await graphIndex.getVerbIdsBySource(sourceId)
// Load each verb by ID (uses existing optimized getVerb())
const results: HNSWVerbWithMetadata[] = []
for (const verbId of verbIds) {
// Iterate through all verb types
for (let i = 0; i < VERB_TYPE_COUNT; i++) {
const type = TypeUtils.getVerbFromIndex(i)
const typeDir = `entities/verbs/${type}/vectors`
try {
const verb = await this.getVerb(verbId)
if (verb) {
results.push(verb)
// v5.4.0 FIX: List all verb files directly (not shard directories)
// listObjectsInBranch returns full paths to .json files, not directories
const verbFiles = await this.listObjectsInBranch(typeDir)
for (const verbPath of verbFiles) {
// Skip if not a .json file
if (!verbPath.endsWith('.json')) continue
try {
const verb = await this.readWithInheritance(verbPath)
if (verb && verb.sourceId === sourceId) {
// v5.4.0: Use proper path helper instead of string replacement
const metadataPath = getVerbMetadataPath(type, verb.id)
const metadata = await this.readWithInheritance(metadataPath)
// v5.4.0: Extract standard fields from metadata to top-level (like nouns)
results.push({
...verb,
weight: metadata?.weight,
confidence: metadata?.confidence,
createdAt: metadata?.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata?.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata?.service,
createdBy: metadata?.createdBy,
metadata: metadata || {} as VerbMetadata
})
}
} catch (error) {
// Skip verbs that fail to load
}
}
} catch (error) {
// Skip verbs that fail to load (handles deleted/corrupted verbs gracefully)
// Skip types that have no data
}
}
const elapsed = performance.now() - startTime
// Performance monitoring - should be 100-10,000x faster than old O(n) scan
if (elapsed > 50.0) {
prodLog.warn(
`getVerbsBySource_internal: Slow query for ${sourceId} ` +
`(${verbIds.length} verbs, ${elapsed.toFixed(2)}ms). ` +
`Expected <50ms with index optimization.`
)
}
return results
}
/**
* Get verbs by target (COW-aware implementation)
* v5.7.0: BILLION-SCALE OPTIMIZATION - Use GraphAdjacencyIndex for O(log n) lookup
* v5.7.1: Reverted to v5.6.3 implementation to fix circular dependency deadlock
* v5.4.0: Fixed to directly list verb files instead of directories
*/
protected async getVerbsByTarget_internal(
targetId: string
): Promise<HNSWVerbWithMetadata[]> {
// v5.7.0: BILLION-SCALE OPTIMIZATION - Use GraphAdjacencyIndex for O(log n) lookup
// Previous: O(total_verbs) - scanned all 127 verb types
// Now: O(log n) LSM-tree lookup + O(verbs_for_target) load
// v5.7.1: Reverted to v5.6.3 implementation to fix circular dependency deadlock
// v5.7.0 called getGraphIndex() here, creating deadlock during initialization
// v5.4.0: Type-first implementation - scan across all verb types
// COW-aware: uses readWithInheritance for each verb
await this.ensureInitialized()
const startTime = performance.now()
// Get GraphAdjacencyIndex (lazy-initialized)
const graphIndex = await this.getGraphIndex()
// O(log n) lookup with bloom filter optimization
const verbIds = await graphIndex.getVerbIdsByTarget(targetId)
// Load each verb by ID (uses existing optimized getVerb())
const results: HNSWVerbWithMetadata[] = []
for (const verbId of verbIds) {
// Iterate through all verb types
for (let i = 0; i < VERB_TYPE_COUNT; i++) {
const type = TypeUtils.getVerbFromIndex(i)
const typeDir = `entities/verbs/${type}/vectors`
try {
const verb = await this.getVerb(verbId)
if (verb) {
results.push(verb)
// v5.4.0 FIX: List all verb files directly (not shard directories)
// listObjectsInBranch returns full paths to .json files, not directories
const verbFiles = await this.listObjectsInBranch(typeDir)
for (const verbPath of verbFiles) {
// Skip if not a .json file
if (!verbPath.endsWith('.json')) continue
try {
const verb = await this.readWithInheritance(verbPath)
if (verb && verb.targetId === targetId) {
// v5.4.0: Use proper path helper instead of string replacement
const metadataPath = getVerbMetadataPath(type, verb.id)
const metadata = await this.readWithInheritance(metadataPath)
// v5.4.0: Extract standard fields from metadata to top-level (like nouns)
results.push({
...verb,
weight: metadata?.weight,
confidence: metadata?.confidence,
createdAt: metadata?.createdAt
? (typeof metadata.createdAt === 'number' ? metadata.createdAt : metadata.createdAt.seconds * 1000)
: Date.now(),
updatedAt: metadata?.updatedAt
? (typeof metadata.updatedAt === 'number' ? metadata.updatedAt : metadata.updatedAt.seconds * 1000)
: Date.now(),
service: metadata?.service,
createdBy: metadata?.createdBy,
metadata: metadata || {} as VerbMetadata
})
}
} catch (error) {
// Skip verbs that fail to load
}
}
} catch (error) {
// Skip verbs that fail to load (handles deleted/corrupted verbs gracefully)
// Skip types that have no data
}
}
const elapsed = performance.now() - startTime
// Performance monitoring - should be 100-10,000x faster than old O(n) scan
if (elapsed > 50.0) {
prodLog.warn(
`getVerbsByTarget_internal: Slow query for ${targetId} ` +
`(${verbIds.length} verbs, ${elapsed.toFixed(2)}ms). ` +
`Expected <50ms with index optimization.`
)
}
return results
}