brainy/tests/integration/clear-persistence.test.ts

235 lines
7.6 KiB
TypeScript
Raw Normal View History

/**
* Integration tests for clear() bug fix (v5.10.4)
*
fix: recalibrate find({ limit }) cap + two-tier enforcement + caller location Brainy 7.30.0 introduced a memory-derived synchronous cap on `find({ limit })` to prevent OOM. The cap was sound in intent but ~4x too conservative in calibration: assumed 100 KB per result while typical entity footprint is 7-10 KB (384-dim float32 vector ≈ 1.5 KB + standard fields + metadata). On a 900 MB free-memory box the cap derived to 9000 — breaking common safety-cap patterns like `find({ type, where, limit: 10_000 })` that typically return 10-500 entities. Surfaced as a runtime regression with cascading 500s degrading production dashboards. Three concurrent fixes: A. RECALIBRATE THE FORMULA - src/utils/paramValidation.ts:175,196,212 — the three memory-derived priorities (reservedQueryMemory / containerMemory / freeMemory) all divided by 100 * 1024 * 1024 (100 KB per result, ~10-15x over conservative). Replaced with a new MAX_LIMIT_KB_PER_RESULT = 25 constant that matches observed entity size. - Result: 4 GB container cap goes 10_000 → 40_000; 2 GB cap goes 5_000 → 20_000; 900 MB free-memory cap goes 9_000 → ~36_000. 100k hard ceiling unchanged. `maxQueryLimit` / `reservedQueryMemory` constructor overrides unchanged in behavior. B. TWO-TIER ENFORCEMENT (warn-then-throw) - Below cap (limit <= maxLimit): silent pass, unchanged. - Soft tier (maxLimit < limit <= 2 * maxLimit): NEW — one-time warning per call site (dedup keyed on caller stack frame + limit value), query proceeds. Pre-7.30.2 code that relied on the cap silently allowing typical safety-cap limits keeps working; the warning teaches the recipe so consumers can fix it intentionally. - Hard tier (limit > 2 * maxLimit): throw with the same teaching message format. Real OOM territory; the cap stops being a recommendation and becomes a guardrail. - The 2x soft margin absorbs typical safety-cap patterns (limit: 10_000 against a 9 K-cap box) without disabling OOM protection. Real OOM territory on a JS in-memory brain is hundreds of thousands of results, not 10x the safety cap. C. IMPROVED ERROR / WARNING MESSAGE - Same shape as the 7.30.1 enforcement-error messages: state the problem, name the three escape valves (maxQueryLimit / reservedQueryMemory / pagination), include caller location, link to docs. - Extracted findCallerLocation() helper from brainy.ts to a new src/utils/callerLocation.ts so both the subtype enforcement (7.30.1) and the limit enforcement (7.30.2) share one implementation without circular imports. DOCS - New docs/guides/find-limits.md (public: true) — full reference: why the cap exists, the four memory sources the auto-config considers, the three escape valves with when-to-use-which guidance, and an explicit "pagination is the future-proof pattern" callout (8.0 may tighten the cap further; pagination keeps working unchanged). - docs/api/README.md find() entry gets a one-paragraph `limit` tip + pointer to the new guide. - RELEASES.md v7.30.2 entry. TESTS - New tests/integration/find-limits.test.ts (9 tests): below-cap silent pass; soft-tier warns once per call site (dedup verified by exercising same vs. different source lines via wrapper closures); soft-tier message format (names all three escape valves + docs link); soft-tier message includes caller location; hard-tier throws; hard-tier message format same as soft-tier; consumer maxQueryLimit override raises the cap and shifts both tiers accordingly; pre-7.30.2 regression scenario explicitly covered. - tests/unit/utils/memoryLimits.test.ts — 4 tests updated for the recalibrated cap values (hardcoded expected numbers bumped 4x to match new 25 KB/result assumption). - tests/unit/utils/paramValidation.test.ts — auto-limit test extended to cover the three-tier semantics (below-cap pass / soft-tier silent / hard-tier throw). - Existing suites unchanged: subtype-and-facets 26/26, verb-subtype-and- enforcement 30/30, strict-mode-self-test 13/13. Unit 1468/1468. CORTEX COMPATIBILITY - Zero Cortex changes required. Every change is JS-side: formula recalibration runs in ValidationConfig.constructor(), two-tier enforcement runs in validateFindParams(), both fire before any storage / index / Cortex call. - The new guide notes that Brainy 8.0's Datomic-style Db.find() may tighten per-call limits to keep snapshot semantics cheap; pagination remains the pattern that's guaranteed to keep working. REPO-WIDE CLEANUP Brainy is the only Soulcraft project that is open source. This commit also scrubs closed-source product names and product-specific class/field references from every tracked file in the repo (src/, docs/, tests/, RELEASES.md, CHANGELOG.md). Consumer-reported bugs, regression scenarios, and release notes now refer to "a consumer", "a downstream application", "a production deployment", or "an internal report" — never to the named product. Two product-named test files renamed to neutral diagnostic names. CLAUDE.md gains a project-level guard rule documenting the policy and an example list of the identifiers that may not appear in tracked code. Verification - npx tsc --noEmit: clean - npm test: 1468 / 1468 unit - All four integration subtype + verb + strict + find-limits suites: 78/78 - npm run build: clean - Closed-source product reference audit: clean
2026-06-08 12:34:05 -07:00
* Bug report: a consumer team reported that brain.clear() doesn't fully delete persistent storage.
* After calling clear() and creating a new Brainy instance, all data was restored from _cow/ directory.
*
* Root cause: Setting cowEnabled = false on old instance doesn't affect new instances.
* Fix: Create persistent marker file that survives instance restarts.
*
* These tests verify:
* 1. clear() actually deletes all data
* 2. New instances don't restore data from _cow/
* 3. Marker file persists across restarts
* 4. Works for all storage adapters
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
import * as fs from 'fs'
import * as path from 'path'
describe('Clear Persistence Bug Fix (v5.10.4)', () => {
const testStoragePath = './test-clear-persistence-' + Date.now()
afterEach(async () => {
// Cleanup test storage
try {
if (fs.existsSync(testStoragePath)) {
await fs.promises.rm(testStoragePath, { recursive: true, force: true })
}
} catch (error) {
console.warn('Failed to cleanup test storage:', error)
}
})
fix: recalibrate find({ limit }) cap + two-tier enforcement + caller location Brainy 7.30.0 introduced a memory-derived synchronous cap on `find({ limit })` to prevent OOM. The cap was sound in intent but ~4x too conservative in calibration: assumed 100 KB per result while typical entity footprint is 7-10 KB (384-dim float32 vector ≈ 1.5 KB + standard fields + metadata). On a 900 MB free-memory box the cap derived to 9000 — breaking common safety-cap patterns like `find({ type, where, limit: 10_000 })` that typically return 10-500 entities. Surfaced as a runtime regression with cascading 500s degrading production dashboards. Three concurrent fixes: A. RECALIBRATE THE FORMULA - src/utils/paramValidation.ts:175,196,212 — the three memory-derived priorities (reservedQueryMemory / containerMemory / freeMemory) all divided by 100 * 1024 * 1024 (100 KB per result, ~10-15x over conservative). Replaced with a new MAX_LIMIT_KB_PER_RESULT = 25 constant that matches observed entity size. - Result: 4 GB container cap goes 10_000 → 40_000; 2 GB cap goes 5_000 → 20_000; 900 MB free-memory cap goes 9_000 → ~36_000. 100k hard ceiling unchanged. `maxQueryLimit` / `reservedQueryMemory` constructor overrides unchanged in behavior. B. TWO-TIER ENFORCEMENT (warn-then-throw) - Below cap (limit <= maxLimit): silent pass, unchanged. - Soft tier (maxLimit < limit <= 2 * maxLimit): NEW — one-time warning per call site (dedup keyed on caller stack frame + limit value), query proceeds. Pre-7.30.2 code that relied on the cap silently allowing typical safety-cap limits keeps working; the warning teaches the recipe so consumers can fix it intentionally. - Hard tier (limit > 2 * maxLimit): throw with the same teaching message format. Real OOM territory; the cap stops being a recommendation and becomes a guardrail. - The 2x soft margin absorbs typical safety-cap patterns (limit: 10_000 against a 9 K-cap box) without disabling OOM protection. Real OOM territory on a JS in-memory brain is hundreds of thousands of results, not 10x the safety cap. C. IMPROVED ERROR / WARNING MESSAGE - Same shape as the 7.30.1 enforcement-error messages: state the problem, name the three escape valves (maxQueryLimit / reservedQueryMemory / pagination), include caller location, link to docs. - Extracted findCallerLocation() helper from brainy.ts to a new src/utils/callerLocation.ts so both the subtype enforcement (7.30.1) and the limit enforcement (7.30.2) share one implementation without circular imports. DOCS - New docs/guides/find-limits.md (public: true) — full reference: why the cap exists, the four memory sources the auto-config considers, the three escape valves with when-to-use-which guidance, and an explicit "pagination is the future-proof pattern" callout (8.0 may tighten the cap further; pagination keeps working unchanged). - docs/api/README.md find() entry gets a one-paragraph `limit` tip + pointer to the new guide. - RELEASES.md v7.30.2 entry. TESTS - New tests/integration/find-limits.test.ts (9 tests): below-cap silent pass; soft-tier warns once per call site (dedup verified by exercising same vs. different source lines via wrapper closures); soft-tier message format (names all three escape valves + docs link); soft-tier message includes caller location; hard-tier throws; hard-tier message format same as soft-tier; consumer maxQueryLimit override raises the cap and shifts both tiers accordingly; pre-7.30.2 regression scenario explicitly covered. - tests/unit/utils/memoryLimits.test.ts — 4 tests updated for the recalibrated cap values (hardcoded expected numbers bumped 4x to match new 25 KB/result assumption). - tests/unit/utils/paramValidation.test.ts — auto-limit test extended to cover the three-tier semantics (below-cap pass / soft-tier silent / hard-tier throw). - Existing suites unchanged: subtype-and-facets 26/26, verb-subtype-and- enforcement 30/30, strict-mode-self-test 13/13. Unit 1468/1468. CORTEX COMPATIBILITY - Zero Cortex changes required. Every change is JS-side: formula recalibration runs in ValidationConfig.constructor(), two-tier enforcement runs in validateFindParams(), both fire before any storage / index / Cortex call. - The new guide notes that Brainy 8.0's Datomic-style Db.find() may tighten per-call limits to keep snapshot semantics cheap; pagination remains the pattern that's guaranteed to keep working. REPO-WIDE CLEANUP Brainy is the only Soulcraft project that is open source. This commit also scrubs closed-source product names and product-specific class/field references from every tracked file in the repo (src/, docs/, tests/, RELEASES.md, CHANGELOG.md). Consumer-reported bugs, regression scenarios, and release notes now refer to "a consumer", "a downstream application", "a production deployment", or "an internal report" — never to the named product. Two product-named test files renamed to neutral diagnostic names. CLAUDE.md gains a project-level guard rule documenting the policy and an example list of the identifiers that may not appear in tracked code. Verification - npx tsc --noEmit: clean - npm test: 1468 / 1468 unit - All four integration subtype + verb + strict + find-limits suites: 78/78 - npm run build: clean - Closed-source product reference audit: clean
2026-06-08 12:34:05 -07:00
it('should fully clear persistent storage (the reported scenario)', async () => {
// Step 1: Create and populate instance
const brain1 = new Brainy({
storage: {
type: 'filesystem',
path: testStoragePath,
enableCompression: true
}
})
await brain1.init()
// Add data (using only entities, not VFS)
const entityId = await brain1.add({ data: 'Test Entity', type: 'concept' })
// Verify data exists (filter by type to exclude VFS directories)
const entityCount1 = (await brain1.find({ type: 'concept' })).length
expect(entityCount1).toBe(1)
// Step 2: Clear all data
await brain1.clear()
// Verify cleared in same instance (filter by type to exclude VFS directories)
const entityCount2 = (await brain1.find({ type: 'concept' })).length
expect(entityCount2).toBe(0)
// Step 3: Create NEW instance (simulate server restart)
const brain2 = new Brainy({
storage: {
type: 'filesystem',
path: testStoragePath,
enableCompression: true
}
})
await brain2.init()
// CRITICAL: Verify data is NOT restored (THE BUG)
const entityCount3 = (await brain2.find({ type: 'concept' })).length
expect(entityCount3).toBe(0) // Should be 0, not 1
})
it('should create cow-disabled marker file', async () => {
const brain = new Brainy({
storage: {
type: 'filesystem',
path: testStoragePath
}
})
await brain.init()
// Add some data to ensure COW is initialized
await brain.add({ data: 'Test', type: 'concept' })
// Clear
await brain.clear()
// Check for marker file
const markerPath = path.join(testStoragePath, '_system', 'cow-disabled')
const markerExists = fs.existsSync(markerPath)
expect(markerExists).toBe(true)
})
it('should delete _cow/ directory', async () => {
const brain = new Brainy({
storage: {
type: 'filesystem',
path: testStoragePath
}
})
await brain.init()
// Add data to create COW commits
await brain.add({ data:'Test', type: 'concept' })
// Verify _cow/ exists
const cowPath = path.join(testStoragePath, '_cow')
const cowExistsBefore = fs.existsSync(cowPath)
expect(cowExistsBefore).toBe(true)
// Clear
await brain.clear()
// Verify _cow/ is deleted
const cowExistsAfter = fs.existsSync(cowPath)
expect(cowExistsAfter).toBe(false)
})
it('should prevent COW reinitialization after clear()', async () => {
// Create and clear
const brain1 = new Brainy({
storage: { type: 'filesystem', path: testStoragePath }
})
await brain1.init()
await brain1.add({ data:'Test', type: 'concept' })
await brain1.clear()
// Create new instance
const brain2 = new Brainy({
storage: { type: 'filesystem', path: testStoragePath }
})
await brain2.init()
// Add new data - should NOT recreate _cow/
// (COW stays disabled because marker exists)
await brain2.add({ data:'New Data', type: 'concept' })
// Verify _cow/ still doesn't exist
const cowPath = path.join(testStoragePath, '_cow')
const cowExists = fs.existsSync(cowPath)
expect(cowExists).toBe(false)
})
it('should work across multiple clear() calls', async () => {
const storagePath = testStoragePath + '-multi'
try {
// Iteration 1
const brain1 = new Brainy({ storage: { type: 'filesystem', path: storagePath }})
await brain1.init()
await brain1.add({ data:'Entity 1', type: 'concept' })
expect((await brain1.find({ type: 'concept' })).length).toBe(1)
await brain1.clear()
// Iteration 2
const brain2 = new Brainy({ storage: { type: 'filesystem', path: storagePath }})
await brain2.init()
expect((await brain2.find({ type: 'concept' })).length).toBe(0)
await brain2.add({ data:'Entity 2', type: 'concept' })
expect((await brain2.find({ type: 'concept' })).length).toBe(1)
await brain2.clear()
// Iteration 3
const brain3 = new Brainy({ storage: { type: 'filesystem', path: storagePath }})
await brain3.init()
expect((await brain3.find({ type: 'concept' })).length).toBe(0)
} finally {
// Cleanup
if (fs.existsSync(storagePath)) {
await fs.promises.rm(storagePath, { recursive: true, force: true })
}
}
})
it('should clear both entities and relations', async () => {
const brain1 = new Brainy({ storage: { type: 'filesystem', path: testStoragePath }})
await brain1.init()
// Add graph data
const entity1Id = await brain1.add({ data:'Entity 1', type: 'person' })
const entity2Id = await brain1.add({ data:'Entity 2', type: 'concept' })
await brain1.relate({ from: entity1Id, to: entity2Id, type: 'relatedTo' })
// Verify data exists
expect((await brain1.find({ type: 'person' })).length).toBe(1)
expect((await brain1.find({ type: 'concept' })).length).toBe(1)
expect((await brain1.getRelations({})).length).toBe(1)
// Clear
await brain1.clear()
// Create new instance
const brain2 = new Brainy({ storage: { type: 'filesystem', path: testStoragePath }})
await brain2.init()
// Verify everything is cleared
expect((await brain2.find({ type: 'person' })).length).toBe(0)
expect((await brain2.find({ type: 'concept' })).length).toBe(0)
expect((await brain2.getRelations({})).length).toBe(0)
})
it('should handle clear() on empty storage', async () => {
const brain = new Brainy({ storage: { type: 'filesystem', path: testStoragePath }})
await brain.init()
// Clear without adding any data
await expect(brain.clear()).resolves.not.toThrow()
// Should create marker even if no data was present
const markerPath = path.join(testStoragePath, '_system', 'cow-disabled')
const markerExists = fs.existsSync(markerPath)
expect(markerExists).toBe(true)
})
})
describe('Clear() works for MemoryStorage', () => {
it('should clear memory storage completely', async () => {
const brain1 = new Brainy({ storage: { type: 'memory' }})
await brain1.init()
await brain1.add({ data:'Test', type: 'concept' })
expect((await brain1.find({ type: 'concept' })).length).toBe(1)
await brain1.clear()
expect((await brain1.find({ type: 'concept' })).length).toBe(0)
// Note: MemoryStorage doesn't persist, so we can't test instance restart
// The marker methods are no-ops for MemoryStorage
})
})