/** * @module tests/unit/db/pending-flush-durability * @description Finding 8: the async group-commit flush that persists single-op * generation HISTORY must not swallow persist failures. Before the fix a failed * background flush was a bare `prodLog.warn` — writes kept succeeding while their * before-images silently piled up in memory, never durable and unbounded. * * The contract now: * - a single transient flush failure does NOT trip the alarm (tolerance), and * - after PENDING_FLUSH_FAILURE_THRESHOLD consecutive failures the store LATCHES * and REFUSES further writes with a typed PendingFlushDurabilityError rather * than accumulate un-durable history, and * - it self-heals: once a flush finally succeeds the latch lifts and writes resume. * Live canonical data is never touched — only the immutable history is stuck. * * Fake timers keep the background retry/coalesce timers from firing, so the exact * number of flush attempts (and thus the latch point) is deterministic — the test * drives every flush explicitly. */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { MemoryStorage } from '../../../src/storage/adapters/memoryStorage.js' import { GenerationStore } from '../../../src/db/generationStore.js' import { PendingFlushDurabilityError } from '../../../src/db/errors.js' import { NounType } from '../../../src/types/graphTypes.js' const ID = (suffix: string): string => `00000000-0000-4000-8000-0000000000${suffix}` const meta = (v: number): Record => ({ noun: NounType.Document, subtype: 'note', data: `payload-v${v}`, version: v, _rev: v }) describe('db/GenerationStore pending-flush durability (finding 8)', () => { let storage: MemoryStorage let store: GenerationStore beforeEach(async () => { vi.useFakeTimers() storage = new MemoryStorage() await storage.init() store = new GenerationStore(storage) await store.open() // High size threshold so single-ops never auto-flush — the test owns every flush. ;(store as any).pendingFlushThreshold = 1000 }) afterEach(() => { vi.useRealTimers() }) /** One single-op write that buffers a pending generation. */ const singleOp = (id: string, v: number) => store.commitSingleOp({ touched: { nouns: [id], verbs: [] }, execute: async () => { await storage.saveNounMetadata(id, meta(v)) } }) it('latches after repeated flush failures, refuses writes, then self-heals', async () => { // Buffer a pending generation while storage is healthy. await singleOp(ID('aa'), 1) // Fault the HISTORY flush (raw generation-record writes) — NOT the live // canonical write, which uses a different path (saveNounMetadata). const fault = Object.assign(new Error('EIO simulated'), { code: 'EIO' }) const spy = vi.spyOn(storage as any, 'writeRawObject').mockRejectedValue(fault) // Each explicit flush fails and is accounted; the third trips the latch. for (let i = 0; i < 3; i++) { await expect(store.flushPendingSingleOps()).rejects.toThrow('EIO simulated') } // Writes are now refused LOUDLY rather than piling up un-durable history. await expect(singleOp(ID('bb'), 1)).rejects.toBeInstanceOf(PendingFlushDurabilityError) // The live counter did not advance for the refused write (no generation consumed). expect(store.generation()).toBe(1) // Storage recovers → an explicit flush drains the retained tier and lifts the latch. spy.mockRestore() await expect(store.flushPendingSingleOps()).resolves.toBeUndefined() // Writes resume; the next single-op commits normally. const receipt = await singleOp(ID('cc'), 1) expect(receipt.generation).toBe(2) }) it('a single transient flush failure does NOT latch — writes continue', async () => { await singleOp(ID('aa'), 1) const spy = vi.spyOn(storage as any, 'writeRawObject').mockRejectedValue(new Error('blip')) await expect(store.flushPendingSingleOps()).rejects.toThrow('blip') // failure #1, below threshold spy.mockRestore() // Below the latch threshold → the store still accepts writes. const receipt = await singleOp(ID('bb'), 1) expect(receipt.generation).toBe(2) // A subsequent healthy flush drains the tier and resets the failure counter. await expect(store.flushPendingSingleOps()).resolves.toBeUndefined() expect((store as any).pendingFlushFailures).toBe(0) expect((store as any).pendingFlushError).toBeNull() }) })