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.
This commit is contained in:
parent
120205b69c
commit
4fcef7b8ed
7 changed files with 179 additions and 10 deletions
85
tests/integration/background-dedup-lifecycle.test.ts
Normal file
85
tests/integration/background-dedup-lifecycle.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
/**
|
||||
* @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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue