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.
265 lines
12 KiB
TypeScript
265 lines
12 KiB
TypeScript
/**
|
|
* @module tests/unit/db/generation-segments
|
|
* @description The generation-segment store (Stage-2 D1+D3 file format).
|
|
* Laws: (1) fold → read round-trips deltas and records byte-faithfully via
|
|
* sidecar point-reads; (2) the manifest is the ONLY discovery path — reopen
|
|
* reads one file, never a listing; (3) a lost/corrupt sidecar rebuilds from
|
|
* its segment loudly, a damaged SEGMENT fails loudly (never silent wrong
|
|
* data); (4) D3 reclaim drops whole segments only and bumps compactedBelow;
|
|
* (5) the packed digest is deterministic across reopen; (6) immutability —
|
|
* fold refuses overlap with sealed ranges.
|
|
*/
|
|
import { describe, it, expect, beforeEach } from 'vitest'
|
|
import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js'
|
|
import {
|
|
GenerationSegmentStore,
|
|
SEGMENTS_PREFIX,
|
|
type FoldGeneration
|
|
} from '../../../src/db/generationSegments.js'
|
|
|
|
const UUID = (n: number): string => `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
|
|
|
const gen = (g: number, recordCount = 2): FoldGeneration => ({
|
|
generation: g,
|
|
timestamp: 1_700_000_000_000 + g,
|
|
delta: { generation: g, nouns: [UUID(g)], verbs: [], bytes: 123 + g },
|
|
records: Array.from({ length: recordCount }, (_, i) => ({
|
|
kind: (i % 2 === 0 ? 'noun' : 'verb') as 'noun' | 'verb',
|
|
id: UUID(g * 100 + i),
|
|
record: { metadata: { noun: 'document', v: g }, vector: { v: [g, i] } }
|
|
}))
|
|
})
|
|
|
|
describe('db/GenerationSegmentStore — the D1+D3 packed tier', () => {
|
|
let storage: MemoryStorage
|
|
let store: GenerationSegmentStore
|
|
|
|
beforeEach(async () => {
|
|
storage = new MemoryStorage()
|
|
await storage.init()
|
|
store = new GenerationSegmentStore(storage as any)
|
|
await store.open()
|
|
})
|
|
|
|
it('fold → read round-trips deltas and records via sidecar point-reads', async () => {
|
|
const meta = await store.fold([gen(1), gen(2), gen(3)])
|
|
expect(meta).toMatchObject({ firstGeneration: 1, lastGeneration: 3, frames: 3 })
|
|
expect(meta.checksum).toBeGreaterThan(0)
|
|
|
|
expect(store.hasGeneration(2)).toBe(true)
|
|
expect(store.hasGeneration(4)).toBe(false)
|
|
|
|
const d2 = await store.readDelta(2)
|
|
expect(d2?.delta).toEqual({ generation: 2, nouns: [UUID(2)], verbs: [], bytes: 125 })
|
|
expect(d2?.timestamp).toBe(1_700_000_000_002)
|
|
|
|
const records = await store.readRecords(3)
|
|
expect(records).toHaveLength(2)
|
|
expect(records![0]).toEqual({
|
|
kind: 'noun',
|
|
id: UUID(300),
|
|
record: { metadata: { noun: 'document', v: 3 }, vector: { v: [3, 0] } }
|
|
})
|
|
// Point read by id, both kinds.
|
|
expect(await store.readRecord(3, 'verb', UUID(301))).toEqual({
|
|
metadata: { noun: 'document', v: 3 },
|
|
vector: { v: [3, 1] }
|
|
})
|
|
expect(await store.readRecord(3, 'noun', UUID(999))).toBeNull()
|
|
})
|
|
|
|
it('reopen discovers everything from the manifest alone — no listing', async () => {
|
|
await store.fold([gen(1), gen(2)])
|
|
await store.fold([gen(3), gen(4)])
|
|
|
|
const reopened = new GenerationSegmentStore(storage as any)
|
|
await reopened.open()
|
|
expect(reopened.segments()).toHaveLength(2)
|
|
expect(reopened.hasGeneration(4)).toBe(true)
|
|
expect((await reopened.readDelta(1))?.timestamp).toBe(1_700_000_000_001)
|
|
})
|
|
|
|
it('a lost sidecar rebuilds from its segment; a damaged segment fails LOUDLY', async () => {
|
|
const meta = await store.fold([gen(1), gen(2)])
|
|
const idxPath = `${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.idx`
|
|
await storage.deleteRawObject(idxPath)
|
|
|
|
const reopened = new GenerationSegmentStore(storage as any)
|
|
await reopened.open()
|
|
// Rebuild path: still serves correct data.
|
|
expect((await reopened.readRecords(2))!).toHaveLength(2)
|
|
|
|
// Now damage the SEGMENT itself: flip a payload byte → CRC mismatch, loud.
|
|
const segPath = `${SEGMENTS_PREFIX}/${meta.file}`
|
|
const bytes = (await storage.readRawBytes(segPath))!
|
|
bytes[bytes.length - 3] ^= 0xff
|
|
await storage.writeRawBytes(segPath, bytes)
|
|
const damaged = new GenerationSegmentStore(storage as any)
|
|
await damaged.open()
|
|
;(damaged as any).sidecars.clear()
|
|
await storage.deleteRawObject(idxPath) // force the sequential rebuild over damaged bytes
|
|
await expect(damaged.readRecords(2)).rejects.toThrow(/CRC mismatch|damaged/)
|
|
})
|
|
|
|
it('D3 reclaim drops whole segments only and bumps compactedBelow', async () => {
|
|
await store.fold([gen(1), gen(2)])
|
|
await store.fold([gen(3), gen(4)])
|
|
await store.fold([gen(5), gen(6)])
|
|
|
|
// Horizon mid-segment-2 (below 4): only segment 1 is FULLY below → drops.
|
|
const r1 = await store.dropSegmentsBelow(4)
|
|
expect(r1).toEqual({ dropped: 1, compactedBelow: 3 })
|
|
expect(store.hasGeneration(1)).toBe(false)
|
|
expect(store.hasGeneration(3)).toBe(true) // partial segment survives whole
|
|
|
|
// Bytes actually gone.
|
|
expect(await storage.readRawBytes(`${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.bgs`)).toBeNull()
|
|
|
|
// Horizon past everything: the rest drop; compactedBelow is durable.
|
|
const r2 = await store.dropSegmentsBelow(7)
|
|
expect(r2.dropped).toBe(2)
|
|
const reopened = new GenerationSegmentStore(storage as any)
|
|
await reopened.open()
|
|
expect(reopened.compactedBelow()).toBe(7)
|
|
expect(reopened.segments()).toHaveLength(0)
|
|
})
|
|
|
|
it('the packed digest is deterministic across reopen and changes with history', async () => {
|
|
await store.fold([gen(1), gen(2), gen(3)])
|
|
const atSeal = await store.digestThroughPacked(3)
|
|
const midSegment = await store.digestThroughPacked(2)
|
|
expect(atSeal).not.toBeNull()
|
|
expect(midSegment).not.toBeNull()
|
|
expect(midSegment).not.toBe(atSeal)
|
|
|
|
const reopened = new GenerationSegmentStore(storage as any)
|
|
await reopened.open()
|
|
expect(await reopened.digestThroughPacked(3)).toBe(atSeal)
|
|
expect(await reopened.digestThroughPacked(2)).toBe(midSegment)
|
|
|
|
await reopened.fold([gen(4)])
|
|
expect(await reopened.digestThroughPacked(4)).not.toBe(atSeal)
|
|
})
|
|
|
|
it('sealed segments are immutable — fold refuses overlap, requires ascending input', async () => {
|
|
await store.fold([gen(1), gen(2)])
|
|
await expect(store.fold([gen(2), gen(3)])).rejects.toThrow(/overlaps the packed tier/)
|
|
await expect(store.fold([gen(4), gen(4)])).rejects.toThrow(/strictly ascending/)
|
|
await expect(store.fold([])).rejects.toThrow(/at least one generation/)
|
|
})
|
|
|
|
// ==========================================================================
|
|
// THE DENSITY LAW
|
|
// ==========================================================================
|
|
//
|
|
// A sealed segment declares a CONTIGUOUS range and every reader treats that
|
|
// range as containment. Folding a sparse batch therefore makes the segment
|
|
// claim generations it does not hold — and because `open()` merges declared
|
|
// ranges back into committedRanges, the hole is re-admitted as committed
|
|
// history and every later maintenance pass fails asking for a frame that was
|
|
// never written. That is the "generation N is inside sealed segment
|
|
// seg-....bgs's declared range but has no frame — packed history is damaged"
|
|
// narration seen on every run of the affected stores.
|
|
|
|
it('fold REFUSES a batch with a hole — a dense range may not be declared over sparse input', async () => {
|
|
await expect(store.fold([gen(1), gen(2), gen(4)])).rejects.toThrow(
|
|
/not contiguous: 2 → 4 skips 1 generation/
|
|
)
|
|
// The refusal loses nothing: no segment was sealed, so the generations
|
|
// stay in the live tier and the next pass folds them correctly.
|
|
expect(store.segments()).toHaveLength(0)
|
|
expect(store.hasGeneration(1)).toBe(false)
|
|
})
|
|
|
|
it('a wider gap names how many generations it would have swallowed', async () => {
|
|
await expect(store.fold([gen(10), gen(20)])).rejects.toThrow(
|
|
/not contiguous: 10 → 20 skips 9 generation\(s\)/
|
|
)
|
|
})
|
|
|
|
it('two contiguous runs folded separately declare honest ranges', async () => {
|
|
// What the caller now does instead of folding across the gap.
|
|
const a = await store.fold([gen(1), gen(2), gen(3)])
|
|
const b = await store.fold([gen(7), gen(8)])
|
|
expect(a).toMatchObject({ firstGeneration: 1, lastGeneration: 3, frames: 3 })
|
|
expect(b).toMatchObject({ firstGeneration: 7, lastGeneration: 8, frames: 2 })
|
|
// The gap is honestly outside the packed tier.
|
|
for (const g of [4, 5, 6]) expect(store.hasGeneration(g)).toBe(false)
|
|
for (const g of [1, 2, 3, 7, 8]) expect(store.hasGeneration(g)).toBe(true)
|
|
expect(await store.actualRanges()).toEqual([
|
|
[1, 3],
|
|
[7, 8]
|
|
])
|
|
})
|
|
|
|
it('actualRanges() is exact and I/O-free for dense segments', async () => {
|
|
await store.fold([gen(1), gen(2)])
|
|
await store.fold([gen(3), gen(4)])
|
|
// Adjacent dense segments each contribute their declared range.
|
|
expect(await store.actualRanges()).toEqual([
|
|
[1, 2],
|
|
[3, 4]
|
|
])
|
|
})
|
|
|
|
// ---- pre-existing damage: a store sealed by the old writer ----------------
|
|
|
|
/**
|
|
* Seal a SPARSE segment the way the pre-fix writer did: write the bytes and
|
|
* sidecar for a contiguous run, then rewrite the manifest so the segment
|
|
* declares a wider range than the frames it holds. This reproduces on disk
|
|
* exactly what the affected stores carry, without needing the old code.
|
|
*/
|
|
const sealSparseSegment = async (): Promise<void> => {
|
|
await store.fold([gen(1), gen(2), gen(3)])
|
|
const manifest = (await storage.readRawObject(`${SEGMENTS_PREFIX}/manifest.json`)) as any
|
|
// Declare 1..5 while holding frames for 1..3 — generations 4 and 5 become
|
|
// holes inside a sealed range.
|
|
manifest.segments[0].lastGeneration = 5
|
|
await storage.writeRawObject(`${SEGMENTS_PREFIX}/manifest.json`, manifest)
|
|
}
|
|
|
|
it('a pre-existing sparse segment reports its holes as UNPACKED, not as damage', async () => {
|
|
await sealSparseSegment()
|
|
const reopened = new GenerationSegmentStore(storage as any)
|
|
await reopened.open()
|
|
|
|
// The frames it really holds still serve, byte-faithfully.
|
|
expect((await reopened.readDelta(2))?.timestamp).toBe(1_700_000_000_002)
|
|
expect(await reopened.readRecords(3)).toHaveLength(2)
|
|
|
|
// The holes answer "not packed" instead of throwing. This is the fix for
|
|
// the wedge: the old reader threw here on EVERY maintenance pass.
|
|
expect(await reopened.readDelta(4)).toBeNull()
|
|
expect(await reopened.readRecords(5)).toBeNull()
|
|
})
|
|
|
|
it('actualRanges() excludes the holes so they are never re-admitted as committed', async () => {
|
|
await sealSparseSegment()
|
|
const reopened = new GenerationSegmentStore(storage as any)
|
|
await reopened.open()
|
|
// Declared 1..5; actually holds 1..3. The store seeds committedRanges from
|
|
// THIS, so generations 4 and 5 never become committed history again.
|
|
expect(await reopened.actualRanges()).toEqual([[1, 3]])
|
|
})
|
|
|
|
it('a DENSE segment missing a frame is still loud damage', async () => {
|
|
// The other side of the branch: when the manifest claims a complete span,
|
|
// a missing frame means the manifest and sidecar disagree — real damage,
|
|
// and it must not be quietly downgraded to "unpacked".
|
|
await store.fold([gen(1), gen(2), gen(3)])
|
|
const idxPath = `${SEGMENTS_PREFIX}/seg-${String(1).padStart(20, '0')}.idx`
|
|
const raw = (await storage.readRawBytes(idxPath))!
|
|
const { decode, encode } = await import('@msgpack/msgpack')
|
|
const idx = decode(raw) as any
|
|
// Drop generation 2's entry while the manifest still declares 3 frames.
|
|
idx.generations = idx.generations.filter(([g]: [number]) => g !== 2)
|
|
await storage.writeRawBytes(idxPath, encode(idx))
|
|
|
|
const reopened = new GenerationSegmentStore(storage as any)
|
|
await reopened.open()
|
|
await expect(reopened.readDelta(2)).rejects.toThrow(
|
|
/manifest and the sidecar disagree; packed history is damaged/
|
|
)
|
|
})
|
|
})
|