2026-07-19 16:26:10 -07:00
|
|
|
/**
|
|
|
|
|
* @module tests/integration/history-repacking
|
|
|
|
|
* @description The D1+D3 two-tier history lifecycle end-to-end on a real
|
|
|
|
|
* brain. Laws: (1) repacking is RE-REPRESENTATION — after folding, every
|
|
|
|
|
* asOf() read below the fold boundary answers exactly as before, across a
|
|
|
|
|
* cold reopen; (2) folded per-generation directories are physically gone
|
|
|
|
|
* (the file-count cure is real, not cosmetic); (3) repack + reclaim compose:
|
|
|
|
|
* bounded retention after repacking drops whole segments and asOf below the
|
|
|
|
|
* horizon throws GenerationCompactedError; (4) repackHistory is explicit
|
|
|
|
|
* API and time-bounded (spent budget = consistent no-op).
|
|
|
|
|
*
|
|
|
|
|
* Uses a tiny REPACK_LIVE_WINDOW override so a small history has a cold
|
|
|
|
|
* tier at all (the production window is 1024).
|
|
|
|
|
*/
|
|
|
|
|
import { describe, it, expect, afterEach } from 'vitest'
|
|
|
|
|
import * as fs from 'node:fs'
|
|
|
|
|
import * as path from 'node:path'
|
|
|
|
|
import * as os from 'node:os'
|
fix(generations): a sealed segment may only declare the generations it holds
Diagnosis of the "packed history is damaged" narration that fires on every
run of the affected stores. It is a WRITER defect, and the reader's refusal
was the symptom rather than the cause.
A sealed segment declares one contiguous range [firstGeneration,
lastGeneration], and every reader treats that range as containment:
coveringSegment is an interval test, hasGeneration returns true for anything
inside it, and open() seeds committedRanges from it.
repackHistory handed fold() a SPARSE batch. Three filters punch holes in its
candidate list mid-run — a generation absent from committedRanges never
appears, one still in the pending buffer is skipped, one whose tx.json will
not read is skipped — and fold() then computed the range from the first and
last survivor, claiming every generation in between. The next open merged
that mis-declared range back into committedRanges, re-admitting the hole as
committed history, so the following auto-compaction pass asked the packed
tier for a frame that was never written and failed. Re-merged at every open,
which is why it repeated on every run.
Confirmed against a forensic fixture: generation directories 1..2503 present
except exactly one, 1416; and its fact-log segment already showed the tell —
seg-...1410.bfl declaring 1410..1940 (531 generations) while recording 530
facts.
Three changes:
- repackHistory folds each contiguous RUN as its own segment
(`contiguousRuns`), so ranges describe exactly what the segments contain.
- fold() REFUSES a non-contiguous batch, naming the gap and its width. The
density law is now mechanical, so no future caller can reintroduce it. A
refusal loses nothing: the generations stay live and readable.
- Stores already carrying the damage heal instead of wedging. A segment
whose declared span exceeds its frame count is SPARSE; `actualRanges()`
reads the real generation list from its sidecar so open() never re-admits
the holes, and readFrame reports such a hole as unpacked with a narration
naming the segment, rather than throwing. A DENSE segment missing a frame
is still loud damage — that one means the manifest and sidecar disagree.
Pins: nine unit cases (refusal and its message, honest ranges for separately
folded runs, a reconstructed pre-fix sparse segment serving its real frames
while reporting holes as unpacked, holes excluded from actualRanges, and the
dense-segment damage path still throwing) plus an end-to-end case that
deletes a generation directory and drives the real sequence — ordinary
close()-time repacking folds over the hole, then reopen and compact must both
complete. Verified red without the fix: the segment declared an
11-generation span while holding 10 frames.
2026-08-31 09:13:42 -07:00
|
|
|
import * as zlib from 'node:zlib'
|
2026-07-19 16:26:10 -07:00
|
|
|
import { Brainy } from '../../src/brainy.js'
|
|
|
|
|
import { NounType } from '../../src/types/graphTypes.js'
|
|
|
|
|
import { GenerationStore } from '../../src/db/generationStore.js'
|
|
|
|
|
import { GenerationCompactedError } from '../../src/db/errors.js'
|
|
|
|
|
import { SEGMENTS_PREFIX } from '../../src/db/generationSegments.js'
|
|
|
|
|
|
|
|
|
|
const stub = async (text: string): Promise<number[]> => {
|
|
|
|
|
const h = text.split('').reduce((a, c) => a + c.charCodeAt(0), 0)
|
|
|
|
|
return new Array(384).fill(0).map((_, i) => Math.sin(h + i))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const openBrain = async (dir: string): Promise<Brainy> => {
|
|
|
|
|
const brain = new Brainy({
|
|
|
|
|
requireSubtype: false,
|
|
|
|
|
storage: { type: 'filesystem', path: dir },
|
|
|
|
|
embeddingFunction: stub
|
|
|
|
|
})
|
|
|
|
|
await brain.init()
|
|
|
|
|
return brain
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('history repacking — the two-tier lifecycle', () => {
|
|
|
|
|
const dirs: string[] = []
|
|
|
|
|
const tempDir = (): string => {
|
|
|
|
|
const d = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-repack-'))
|
|
|
|
|
dirs.push(d)
|
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
const originalWindow = GenerationStore.REPACK_LIVE_WINDOW
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
;(GenerationStore as any).REPACK_LIVE_WINDOW = originalWindow
|
|
|
|
|
for (const d of dirs.splice(0)) {
|
|
|
|
|
try {
|
|
|
|
|
fs.rmSync(d, { recursive: true, force: true })
|
|
|
|
|
} catch {
|
|
|
|
|
/* best effort */
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
fix(generations): a sealed segment may only declare the generations it holds
Diagnosis of the "packed history is damaged" narration that fires on every
run of the affected stores. It is a WRITER defect, and the reader's refusal
was the symptom rather than the cause.
A sealed segment declares one contiguous range [firstGeneration,
lastGeneration], and every reader treats that range as containment:
coveringSegment is an interval test, hasGeneration returns true for anything
inside it, and open() seeds committedRanges from it.
repackHistory handed fold() a SPARSE batch. Three filters punch holes in its
candidate list mid-run — a generation absent from committedRanges never
appears, one still in the pending buffer is skipped, one whose tx.json will
not read is skipped — and fold() then computed the range from the first and
last survivor, claiming every generation in between. The next open merged
that mis-declared range back into committedRanges, re-admitting the hole as
committed history, so the following auto-compaction pass asked the packed
tier for a frame that was never written and failed. Re-merged at every open,
which is why it repeated on every run.
Confirmed against a forensic fixture: generation directories 1..2503 present
except exactly one, 1416; and its fact-log segment already showed the tell —
seg-...1410.bfl declaring 1410..1940 (531 generations) while recording 530
facts.
Three changes:
- repackHistory folds each contiguous RUN as its own segment
(`contiguousRuns`), so ranges describe exactly what the segments contain.
- fold() REFUSES a non-contiguous batch, naming the gap and its width. The
density law is now mechanical, so no future caller can reintroduce it. A
refusal loses nothing: the generations stay live and readable.
- Stores already carrying the damage heal instead of wedging. A segment
whose declared span exceeds its frame count is SPARSE; `actualRanges()`
reads the real generation list from its sidecar so open() never re-admits
the holes, and readFrame reports such a hole as unpacked with a narration
naming the segment, rather than throwing. A DENSE segment missing a frame
is still loud damage — that one means the manifest and sidecar disagree.
Pins: nine unit cases (refusal and its message, honest ranges for separately
folded runs, a reconstructed pre-fix sparse segment serving its real frames
while reporting holes as unpacked, holes excluded from actualRanges, and the
dense-segment damage path still throwing) plus an end-to-end case that
deletes a generation directory and drives the real sequence — ordinary
close()-time repacking folds over the hole, then reopen and compact must both
complete. Verified red without the fix: the segment declared an
11-generation span while holding 10 frames.
2026-08-31 09:13:42 -07:00
|
|
|
/**
|
|
|
|
|
* THE HOLE, END TO END — the shape a real store carries.
|
|
|
|
|
*
|
|
|
|
|
* A forensic fixture was measured with generation directories 1..2503
|
|
|
|
|
* present except for exactly one: 1416. Its fact-log segment already showed
|
|
|
|
|
* the tell — `seg-...1410.bfl` declaring firstGeneration 1410, lastGeneration
|
|
|
|
|
* 1940 (531 generations) while recording only 530 facts.
|
|
|
|
|
*
|
|
|
|
|
* Before the fix, repacking such a store folded ACROSS that hole: the batch
|
|
|
|
|
* skipped 1416 (no readable delta) and the sealed segment declared a range
|
|
|
|
|
* spanning it anyway. The next open merged that declared range back into
|
|
|
|
|
* committedRanges, re-admitting 1416 as committed history, and every
|
|
|
|
|
* subsequent auto-compaction pass then asked the packed tier for a frame
|
|
|
|
|
* that was never written — producing, on EVERY run, the non-fatal narration
|
|
|
|
|
*
|
|
|
|
|
* Auto-compaction of generational history failed (non-fatal): generation
|
|
|
|
|
* N is inside sealed segment seg-....bgs's declared range but has no frame
|
|
|
|
|
* — packed history is damaged
|
|
|
|
|
*
|
|
|
|
|
* This pin removes a generation directory to make the same hole, then
|
|
|
|
|
* requires repack + reopen + compaction to complete cleanly.
|
|
|
|
|
*/
|
|
|
|
|
it('a missing generation directory does not poison the packed tier', async () => {
|
|
|
|
|
const dir = tempDir()
|
|
|
|
|
// `retention: 'all'` throughout: close() otherwise auto-compacts the
|
|
|
|
|
// history away, and this pin needs the cold generations still on disk so
|
|
|
|
|
// there is something to punch a hole in. The live window stays at its
|
|
|
|
|
// production default for the build phase, so nothing folds yet.
|
|
|
|
|
const archival = async (): Promise<Brainy> => {
|
|
|
|
|
const b = new Brainy({
|
|
|
|
|
requireSubtype: false,
|
|
|
|
|
storage: { type: 'filesystem', path: dir },
|
|
|
|
|
embeddingFunction: stub,
|
|
|
|
|
retention: 'all'
|
|
|
|
|
})
|
|
|
|
|
await b.init()
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
const brain = await archival()
|
|
|
|
|
|
|
|
|
|
const id = await brain.add({
|
|
|
|
|
data: 'holed-entity',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { v: 0 }
|
|
|
|
|
})
|
|
|
|
|
// One flush per update: single-op writes coalesce inside a flush window,
|
|
|
|
|
// so a history deep enough to have a middle needs the windows separated.
|
|
|
|
|
for (let v = 1; v <= 12; v++) {
|
|
|
|
|
await brain.update({ id, metadata: { v } })
|
|
|
|
|
await brain.flush()
|
|
|
|
|
}
|
|
|
|
|
await brain.close()
|
|
|
|
|
|
|
|
|
|
// Punch the hole: delete ONE generation directory in the middle of the
|
|
|
|
|
// cold range, exactly as the real store presents it.
|
|
|
|
|
const genRoot = path.join(dir, '_generations')
|
|
|
|
|
const numeric = fs
|
|
|
|
|
.readdirSync(genRoot, { withFileTypes: true })
|
|
|
|
|
.filter((e) => e.isDirectory() && /^\d+$/.test(e.name))
|
|
|
|
|
.map((e) => Number(e.name))
|
|
|
|
|
.sort((a, b) => a - b)
|
|
|
|
|
expect(numeric.length).toBeGreaterThan(6)
|
|
|
|
|
const victim = numeric[Math.floor(numeric.length / 2)]
|
|
|
|
|
fs.rmSync(path.join(genRoot, String(victim)), { recursive: true, force: true })
|
|
|
|
|
|
|
|
|
|
// Now shrink the live window and reopen. close() repacks automatically
|
|
|
|
|
// (brainy.ts phase 0b), so this is the production sequence exactly: a
|
|
|
|
|
// store with a hole in its history gets folded by ordinary housekeeping,
|
|
|
|
|
// with nobody asking for it.
|
|
|
|
|
;(GenerationStore as any).REPACK_LIVE_WINDOW = 3
|
|
|
|
|
const reopened = await archival()
|
|
|
|
|
const result = await reopened.repackHistory()
|
|
|
|
|
expect(result.foldedGenerations).toBeGreaterThan(0)
|
|
|
|
|
|
|
|
|
|
const segDir = path.join(dir, SEGMENTS_PREFIX)
|
|
|
|
|
const manifestPath = ['manifest.json', 'manifest.json.gz']
|
|
|
|
|
.map((f) => path.join(segDir, f))
|
|
|
|
|
.find((p) => fs.existsSync(p))!
|
|
|
|
|
const raw = manifestPath.endsWith('.gz')
|
|
|
|
|
? zlib.gunzipSync(fs.readFileSync(manifestPath)).toString('utf8')
|
|
|
|
|
: fs.readFileSync(manifestPath, 'utf8')
|
|
|
|
|
const manifest = JSON.parse(raw) as {
|
|
|
|
|
segments: Array<{ firstGeneration: number; lastGeneration: number; frames: number }>
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// THE LAW: every sealed segment declares exactly as many generations as it
|
|
|
|
|
// holds frames, and none of them spans the victim.
|
|
|
|
|
for (const s of manifest.segments) {
|
|
|
|
|
expect(s.lastGeneration - s.firstGeneration + 1).toBe(s.frames)
|
|
|
|
|
expect(victim >= s.firstGeneration && victim <= s.lastGeneration).toBe(false)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await reopened.close()
|
|
|
|
|
|
|
|
|
|
// And the pass that used to fail on every run now completes: reopen (which
|
|
|
|
|
// re-seeds committedRanges from the packed tier) then compact history.
|
|
|
|
|
const third = await openBrain(dir)
|
|
|
|
|
await expect(third.compactHistory({ maxGenerations: 2 })).resolves.toBeDefined()
|
|
|
|
|
await third.close()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-19 16:26:10 -07:00
|
|
|
it('repack preserves every historical read across cold reopen; folded dirs are gone', async () => {
|
|
|
|
|
;(GenerationStore as any).REPACK_LIVE_WINDOW = 3
|
|
|
|
|
const dir = tempDir()
|
|
|
|
|
const brain = await openBrain(dir)
|
|
|
|
|
|
|
|
|
|
const id = await brain.add({
|
|
|
|
|
data: 'versioned-entity',
|
|
|
|
|
type: NounType.Document,
|
|
|
|
|
metadata: { v: 0 }
|
|
|
|
|
})
|
|
|
|
|
for (let v = 1; v <= 10; v++) await brain.update({ id, metadata: { v } })
|
|
|
|
|
await brain.flush()
|
|
|
|
|
|
|
|
|
|
// Ground truth BEFORE repacking: capture asOf views for early generations.
|
|
|
|
|
const before: Record<number, number> = {}
|
|
|
|
|
for (const g of [2, 4, 6]) {
|
|
|
|
|
const db = await brain.asOf(g)
|
|
|
|
|
before[g] = (await db.get(id))?.metadata?.v as number
|
|
|
|
|
await db.release()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const result = await brain.repackHistory()
|
|
|
|
|
expect(result.foldedGenerations).toBeGreaterThan(0)
|
|
|
|
|
expect(result.segmentsCreated).toBeGreaterThan(0)
|
|
|
|
|
|
|
|
|
|
// The folded per-generation directories are PHYSICALLY gone…
|
|
|
|
|
const genDirs = fs
|
|
|
|
|
.readdirSync(path.join(dir, '_generations'), { withFileTypes: true })
|
|
|
|
|
.filter((e) => e.isDirectory() && /^\d+$/.test(e.name)).length
|
|
|
|
|
expect(genDirs).toBeLessThanOrEqual(4) // live window (3) + at most the newest
|
|
|
|
|
// …and the segment tier exists (the filesystem adapter stores objects
|
|
|
|
|
// gzipped, so the manifest may live at either spelling).
|
|
|
|
|
const segDir = path.join(dir, SEGMENTS_PREFIX)
|
|
|
|
|
expect(
|
|
|
|
|
fs.existsSync(path.join(segDir, 'manifest.json')) ||
|
|
|
|
|
fs.existsSync(path.join(segDir, 'manifest.json.gz'))
|
|
|
|
|
).toBe(true)
|
|
|
|
|
expect(fs.readdirSync(segDir).some((f) => f.endsWith('.bgs'))).toBe(true)
|
|
|
|
|
|
|
|
|
|
// Same asOf answers from the packed tier, same process…
|
|
|
|
|
for (const g of [2, 4, 6]) {
|
|
|
|
|
const db = await brain.asOf(g)
|
|
|
|
|
expect((await db.get(id))?.metadata?.v).toBe(before[g])
|
|
|
|
|
await db.release()
|
|
|
|
|
}
|
|
|
|
|
await brain.close()
|
|
|
|
|
|
|
|
|
|
// …and across a COLD REOPEN (manifest discovery, no live dirs to list).
|
|
|
|
|
const reopened = await openBrain(dir)
|
|
|
|
|
for (const g of [2, 4, 6]) {
|
|
|
|
|
const db = await reopened.asOf(g)
|
|
|
|
|
expect((await db.get(id))?.metadata?.v).toBe(before[g])
|
|
|
|
|
await db.release()
|
|
|
|
|
}
|
|
|
|
|
expect((await reopened.get(id))?.metadata?.v).toBe(10) // live state untouched
|
|
|
|
|
await reopened.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('repack + bounded reclaim compose: whole segments drop, horizon is loud', async () => {
|
|
|
|
|
;(GenerationStore as any).REPACK_LIVE_WINDOW = 2
|
|
|
|
|
const dir = tempDir()
|
|
|
|
|
const brain = await openBrain(dir)
|
|
|
|
|
const id = await brain.add({ data: 'reclaim-probe', type: NounType.Document, metadata: { v: 0 } })
|
|
|
|
|
for (let v = 1; v <= 8; v++) await brain.update({ id, metadata: { v } })
|
|
|
|
|
await brain.flush()
|
|
|
|
|
await brain.repackHistory()
|
|
|
|
|
|
|
|
|
|
// Reclaim down to the 3 newest generations — packed segments below the
|
|
|
|
|
// horizon drop whole; asOf below throws loudly.
|
|
|
|
|
const res = await brain.compactHistory({ maxGenerations: 3 })
|
|
|
|
|
expect(res.removedGenerations).toBeGreaterThan(0)
|
|
|
|
|
await expect(brain.asOf(1)).rejects.toBeInstanceOf(GenerationCompactedError)
|
|
|
|
|
expect((await brain.get(id))?.metadata?.v).toBe(8)
|
|
|
|
|
await brain.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('generationDigest: reopen-stable, divergence-sensitive, loud below the horizon', async () => {
|
|
|
|
|
;(GenerationStore as any).REPACK_LIVE_WINDOW = 2
|
|
|
|
|
const dir = tempDir()
|
|
|
|
|
const brain = await openBrain(dir)
|
|
|
|
|
const id = await brain.add({ data: 'digest-probe', type: NounType.Document, metadata: { v: 0 } })
|
|
|
|
|
for (let v = 1; v <= 6; v++) await brain.update({ id, metadata: { v } })
|
|
|
|
|
await brain.flush()
|
|
|
|
|
await brain.repackHistory()
|
|
|
|
|
|
|
|
|
|
const gen = brain.generation()
|
|
|
|
|
const atHead = await brain.generationDigest(gen)
|
|
|
|
|
const atMid = await brain.generationDigest(3)
|
|
|
|
|
expect(atHead).toMatch(/^[0-9a-f]{8}$/)
|
|
|
|
|
expect(atMid).not.toBe(atHead) // more history ⇒ different digest
|
|
|
|
|
await brain.close()
|
|
|
|
|
|
|
|
|
|
// Reopen-stable: same history, same digests (packed prefix stability).
|
|
|
|
|
const reopened = await openBrain(dir)
|
|
|
|
|
expect(await reopened.generationDigest(gen)).toBe(atHead)
|
|
|
|
|
expect(await reopened.generationDigest(3)).toBe(atMid)
|
|
|
|
|
|
|
|
|
|
// New history diverges the head digest.
|
|
|
|
|
await reopened.update({ id, metadata: { v: 7 } })
|
|
|
|
|
await reopened.flush()
|
|
|
|
|
expect(await reopened.generationDigest(reopened.generation())).not.toBe(atHead)
|
|
|
|
|
|
|
|
|
|
// Below the horizon: LOUD, never a silent pin of reclaimed history.
|
|
|
|
|
await reopened.compactHistory({ maxGenerations: 2 })
|
|
|
|
|
await expect(reopened.generationDigest(1)).rejects.toBeInstanceOf(GenerationCompactedError)
|
|
|
|
|
await reopened.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a spent time budget is a consistent no-op; the next pass resumes', async () => {
|
|
|
|
|
;(GenerationStore as any).REPACK_LIVE_WINDOW = 2
|
|
|
|
|
const dir = tempDir()
|
|
|
|
|
const brain = await openBrain(dir)
|
|
|
|
|
const id = await brain.add({ data: 'budget-probe', type: NounType.Document, metadata: { v: 0 } })
|
|
|
|
|
for (let v = 1; v <= 6; v++) await brain.update({ id, metadata: { v } })
|
|
|
|
|
await brain.flush()
|
|
|
|
|
|
|
|
|
|
const bounded = await brain.repackHistory({ timeBudgetMs: 0 })
|
|
|
|
|
expect(bounded).toEqual({ foldedGenerations: 0, segmentsCreated: 0 })
|
|
|
|
|
|
|
|
|
|
const resumed = await brain.repackHistory()
|
|
|
|
|
expect(resumed.foldedGenerations).toBeGreaterThan(0)
|
|
|
|
|
const db = await brain.asOf(3)
|
|
|
|
|
expect((await db.get(id))?.metadata?.v).toBeDefined()
|
|
|
|
|
await db.release()
|
|
|
|
|
await brain.close()
|
|
|
|
|
})
|
|
|
|
|
})
|