brainy/tests/integration/count-synchronization.test.ts

335 lines
12 KiB
TypeScript
Raw Normal View History

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
2025-10-21 10:58:44 -07:00
/**
* Count Synchronization Integration Tests (v4.1.2)
*
* Tests for Bug #1 and Bug #2: Count synchronization failures during add() and relate()
* This validates that counts.json is properly updated when entities and relationships are created
*
* NO MOCKS - Real integration tests with actual storage
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import { NounType, VerbType } from '../../src/types/graphTypes.js'
import { rebuildCounts } from '../../src/utils/rebuildCounts.js'
import * as fs from 'fs/promises'
import * as path from 'path'
describe('Count Synchronization (Bug Fix v4.1.2)', () => {
let brain: Brainy
const testPath = './test-brainy-counts-sync'
beforeEach(async () => {
// Clean up test directory
try {
await fs.rm(testPath, { recursive: true, force: true })
} catch {
// Ignore if doesn't exist
}
// Create fresh Brainy instance
feat(8.0)!: flip requireSubtype default to true (BRAINY-8.0-SUBTYPE-CONTRACT § C-1) Brainy 8.0 makes subtype required by default on every public write path (`add`, `addMany`, `update`, `relate`, `relateMany`, `updateRelation`, import). Per the locked C-1 contract, every entity and relation gets a non-empty subtype string by the time the storage layer sees it. OPT-OUT REMAINS FULLY SUPPORTED The runtime flag is still consumer-controlled. Three opt-out paths cover migration / legacy fixtures / typed escape: - `new Brainy({ requireSubtype: false })` — last-resort: turn off the contract entirely. Recommended only for migration windows or test fixtures that legitimately can't supply a subtype. - `new Brainy({ requireSubtype: { except: [NounType.Thing, ...] } })` — per-type allowlist: strict everywhere except the listed types. - `brain.requireSubtype(type, options)` — per-type registration with optional vocabulary. Composes with the brain-wide flag. Default is now `true`. Opt-out is explicit and documented; nothing silently degrades. TEST SWEEP Bulk-applied `requireSubtype: false` to every `new Brainy({...})` call site across 120 test files. Three sed patterns covered the shapes: - `new Brainy({` → `new Brainy({ requireSubtype: false,` - `new Brainy<T>({` → `new Brainy<T>({ requireSubtype: false,` - `new Brainy()` → `new Brainy({ requireSubtype: false })` tests/helpers/test-factory.ts → createTestConfig() defaults `requireSubtype: false` so test files using the helper inherit the opt-out without per-site edits. The test sites that DO exercise subtype semantics (the subtype-and-facets suite, the strict-mode-self-test suite, the verb- subtype-and-enforcement suite, etc.) already pass real subtypes — they were the 7.30.x acceptance tests for this contract. Those tests continue to pass unchanged. CHANGES src/brainy.ts - normalizeConfig() — `requireSubtype` default `false` → `true`. Comment refreshed to document the three opt-out paths. tests/* (120 files) - Bulk-edited brain construction sites. No functional test changes; the opt-out preserves the test author's original intent. tests/helpers/test-factory.ts - createTestConfig() base config gains `requireSubtype: false`. NO-OP for consumers who were already passing subtype on every write. For consumers who weren't, the upgrade path is one of the three opt-out forms above. Migration recipe documented in 8.0 release notes (next commit). VERIFICATION - npx tsc --noEmit: clean - npm test: 1408 / 1409 (same pre-existing race-condition outstanding; no other regressions from the flip)
2026-06-09 14:58:25 -07:00
brain = new Brainy({ requireSubtype: false,
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
2025-10-21 10:58:44 -07:00
storage: { type: 'filesystem', path: testPath }
})
await brain.init()
})
afterEach(async () => {
// Clean up test directory
try {
await fs.rm(testPath, { recursive: true, force: true })
} catch {
// Ignore cleanup errors
}
})
describe('Noun Count Synchronization', () => {
it('should update counts.json when adding a single entity', async () => {
// Add entity
const id = await brain.add({
data: 'Test Entity',
type: NounType.Document
})
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify counts
expect(counts.totalNounCount).toBe(1)
expect(counts.entityCounts[NounType.Document]).toBe(1)
})
it('should update counts.json when adding multiple entities of same type', async () => {
// Add 3 entities of same type
await brain.add({ data: 'Entity 1', type: NounType.Document })
await brain.add({ data: 'Entity 2', type: NounType.Document })
await brain.add({ data: 'Entity 3', type: NounType.Document })
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify counts
expect(counts.totalNounCount).toBe(3)
expect(counts.entityCounts[NounType.Document]).toBe(3)
})
it('should update counts.json when adding multiple entities of different types', async () => {
// Add entities of different types
await brain.add({ data: 'Document 1', type: NounType.Document })
await brain.add({ data: 'Person 1', type: NounType.Person })
await brain.add({ data: 'Document 2', type: NounType.Document })
await brain.add({ data: 'Organization 1', type: NounType.Organization })
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify counts
expect(counts.totalNounCount).toBe(4)
expect(counts.entityCounts[NounType.Document]).toBe(2)
expect(counts.entityCounts[NounType.Person]).toBe(1)
expect(counts.entityCounts[NounType.Organization]).toBe(1)
})
it('should match find() results with counts.json', async () => {
// Add entities
await brain.add({ data: 'Entity 1', type: NounType.Document })
await brain.add({ data: 'Entity 2', type: NounType.Person })
await brain.add({ data: 'Entity 3', type: NounType.Document })
// Flush
await brain.flush()
// Get all entities using find with high limit
const entities = await brain.find({ limit: 1000 })
// Read counts
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// CRITICAL: Counts must match actual data
expect(counts.totalNounCount).toBe(entities.length)
expect(counts.totalNounCount).toBe(3)
})
})
describe('Verb Count Synchronization', () => {
it('should update counts.json when creating a single relationship', async () => {
// Create entities
const id1 = await brain.add({ data: 'Person 1', type: NounType.Person })
const id2 = await brain.add({ data: 'Person 2', type: NounType.Person })
// Create relationship
await brain.relate({
from: id1,
to: id2,
type: VerbType.FriendOf
})
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify verb counts
expect(counts.totalVerbCount).toBe(1)
expect(counts.verbCounts[VerbType.FriendOf]).toBe(1)
})
it('should update counts.json when creating multiple relationships of same type', async () => {
// Create entities
const id1 = await brain.add({ data: 'Person 1', type: NounType.Person })
const id2 = await brain.add({ data: 'Person 2', type: NounType.Person })
const id3 = await brain.add({ data: 'Person 3', type: NounType.Person })
// Create relationships
await brain.relate({ from: id1, to: id2, type: VerbType.FriendOf })
await brain.relate({ from: id2, to: id3, type: VerbType.FriendOf })
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify verb counts
expect(counts.totalVerbCount).toBe(2)
expect(counts.verbCounts[VerbType.FriendOf]).toBe(2)
})
it('should update counts.json when creating multiple relationships of different types', async () => {
// Create entities
const personId = await brain.add({ data: 'John', type: NounType.Person })
const docId = await brain.add({ data: 'Resume', type: NounType.Document })
const orgId = await brain.add({ data: 'Company', type: NounType.Organization })
// Create relationships of different types
await brain.relate({ from: personId, to: orgId, type: VerbType.WorksWith })
await brain.relate({ from: personId, to: docId, type: VerbType.Creates })
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
2025-10-21 10:58:44 -07:00
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify verb counts
expect(counts.totalVerbCount).toBe(2)
expect(counts.verbCounts[VerbType.WorksWith]).toBe(1)
expect(counts.verbCounts[VerbType.Creates]).toBe(1)
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
2025-10-21 10:58:44 -07:00
})
})
describe('Batch Operations', () => {
it('should update counts.json during addMany()', async () => {
// Add multiple entities via batch
await brain.addMany({
items: [
{ data: 'Entity 1', type: NounType.Document },
{ data: 'Entity 2', type: NounType.Person },
{ data: 'Entity 3', type: NounType.Document }
]
})
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify counts
expect(counts.totalNounCount).toBe(3)
expect(counts.entityCounts[NounType.Document]).toBe(2)
expect(counts.entityCounts[NounType.Person]).toBe(1)
})
it('should update counts.json during relateMany()', async () => {
// Create entities
const id1 = await brain.add({ data: 'Person 1', type: NounType.Person })
const id2 = await brain.add({ data: 'Person 2', type: NounType.Person })
const id3 = await brain.add({ data: 'Person 3', type: NounType.Person })
// Create multiple relationships via batch
await brain.relateMany({
items: [
{ from: id1, to: id2, type: VerbType.FriendOf },
{ from: id2, to: id3, type: VerbType.FriendOf }
]
})
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify verb counts
expect(counts.totalVerbCount).toBe(2)
expect(counts.verbCounts[VerbType.FriendOf]).toBe(2)
})
})
describe('rebuildCounts Utility', () => {
it('should rebuild counts from corrupted state', async () => {
// Add entities
await brain.add({ data: 'Entity 1', type: NounType.Document })
await brain.add({ data: 'Entity 2', type: NounType.Person })
await brain.add({ data: 'Entity 3', type: NounType.Document })
// Flush to disk
await brain.flush()
// Manually corrupt counts.json (simulate the bug)
const countsPath = path.join(testPath, '_system', 'counts.json')
const corruptedCounts = {
entityCounts: {},
verbCounts: {},
totalNounCount: 0,
totalVerbCount: 0,
lastUpdated: new Date().toISOString()
}
await fs.writeFile(countsPath, JSON.stringify(corruptedCounts, null, 2))
// Force storage to reload corrupted counts by updating in-memory counts
// This simulates the scenario where counts are out of sync
;(brain.storage as any).totalNounCount = 0
;(brain.storage as any).totalVerbCount = 0
;(brain.storage as any).entityCounts = new Map()
;(brain.storage as any).verbCounts = new Map()
// Rebuild counts
const result = await rebuildCounts(brain.storage)
// Verify rebuild results
expect(result.nounCount).toBe(3)
expect(result.entityCounts.get(NounType.Document)).toBe(2)
expect(result.entityCounts.get(NounType.Person)).toBe(1)
// Verify counts.json was updated
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
expect(counts.totalNounCount).toBe(3)
expect(counts.entityCounts[NounType.Document]).toBe(2)
expect(counts.entityCounts[NounType.Person]).toBe(1)
})
})
describe('Import Operations', () => {
it('should update counts.json during import', async () => {
// Create simple test data as JSON
const testData = {
entities: [
{ id: '1', name: 'Entity 1', type: 'document' },
{ id: '2', name: 'Entity 2', type: 'person' },
{ id: '3', name: 'Entity 3', type: 'document' }
]
}
// Import data
const result = await brain.import(testData, {
format: 'json',
createEntities: true,
createRelationships: false
})
// Flush to disk
await brain.flush()
// Read counts.json
const countsPath = path.join(testPath, '_system', 'counts.json')
const countsRaw = await fs.readFile(countsPath, 'utf8')
const counts = JSON.parse(countsRaw)
// Verify counts are updated (import may create additional VFS entities)
// So we check that totalNounCount >= graphNodesCreated
expect(counts.totalNounCount).toBeGreaterThanOrEqual(result.stats.graphNodesCreated)
expect(counts.totalNounCount).toBeGreaterThan(0)
})
})
})