brainy/tests/integration/background-dedup-lifecycle.test.ts
David Snelling 4fcef7b8ed fix: import dedup off-switch honesty + brain-owned lifecycle for the background pass
The post-import background deduplication pass (a merge-DELETE writer,
debounced ~5 minutes after import) had four lifecycle defects:

- enableDeduplication: false did not gate the background schedule — an
  import that explicitly opted out could still have entities auto-removed
  minutes later. The flag now gates both the inline and background passes.
- Each import() constructed its own coordinator-owned deduplicator, so the
  debounce never spanned imports (N imports = N delete timers). The brain
  now owns a single lazy instance (getBackgroundDeduplicator).
- close() never cancelled pending dedup; a delete pass could fire against
  a closed brain. close() now cancels it first.
- The 5-minute timer held the process open (exit-hang class); now unref'd.

Four regression tests pin the contract (background-dedup-lifecycle);
import guides document that false disables both passes.
2026-07-18 10:40:45 -07:00

85 lines
3.2 KiB
TypeScript

/**
* @module tests/integration/background-dedup-lifecycle
* @description The post-import background deduplication pass (a merge-DELETE
* writer) obeys the same contract as the inline pass. Laws:
* (1) enableDeduplication:false schedules NO background pass — the brain-owned
* deduplicator is never even constructed;
* (2) by default the pass IS scheduled, brain-owned, with an unref'd timer
* (a pending pass never holds the process open);
* (3) repeated imports debounce into ONE pending batch on ONE instance
* (per-coordinator instances used to arm one timer per import);
* (4) close() cancels pending work — no delete pass can fire after close.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { Brainy } from '../../src/brainy.js'
const ROWS = [
{ name: 'Alice Zephyr', role: 'engineer' },
{ name: 'Bob Quill', role: 'writer' }
]
// Keep imports fast and deterministic — dedup scheduling is what's under test.
const FAST = {
enableNeuralExtraction: false,
enableRelationshipInference: false,
enableConceptExtraction: false
} as const
// Deterministic stub embedder (hnsw-rebuild.test.ts pattern) — dedup
// scheduling never inspects vector CONTENT, so skip the WASM model load.
const stubEmbedding = async (text: string): Promise<number[]> => {
const hash = text.split('').reduce((acc, char) => acc + char.charCodeAt(0), 0)
const vector = new Array(384).fill(0).map((_, i) => Math.sin(hash + i))
return vector
}
describe('background dedup lifecycle', () => {
let brain: Brainy
beforeEach(async () => {
brain = new Brainy({
requireSubtype: false,
storage: { type: 'memory' as const },
embeddingFunction: stubEmbedding
})
await brain.init()
})
afterEach(async () => {
await brain.close()
})
it('enableDeduplication:false schedules no background pass at all', async () => {
await brain.import(ROWS, { ...FAST, enableDeduplication: false })
expect((brain as any)._backgroundDedup).toBeUndefined()
})
it('default schedules a brain-owned pass with an unref-ed timer', async () => {
await brain.import(ROWS, { ...FAST })
const dedup = (brain as any)._backgroundDedup
expect(dedup).toBeDefined()
expect(dedup.pendingImports.size).toBe(1)
const timer = dedup.debounceTimer
expect(timer).toBeDefined()
// Node timers expose hasRef(); an unref'd timer must not hold the process.
expect(typeof timer.hasRef).toBe('function')
expect(timer.hasRef()).toBe(false)
})
it('imports debounce into one pending batch on one brain-owned instance', async () => {
await brain.import(ROWS, { ...FAST })
const first = (brain as any)._backgroundDedup
await brain.import([{ name: 'Cara Vex', role: 'analyst' }], { ...FAST })
expect((brain as any)._backgroundDedup).toBe(first)
expect(first.pendingImports.size).toBe(2)
})
it('close() cancels pending background dedup', async () => {
await brain.import(ROWS, { ...FAST })
const dedup = (brain as any)._backgroundDedup
expect(dedup.debounceTimer).toBeDefined()
await brain.close()
expect(dedup.debounceTimer).toBeUndefined()
expect(dedup.pendingImports.size).toBe(0)
})
})