2026-07-17 09:20:09 -07:00
|
|
|
/**
|
|
|
|
|
* @module tests/integration/aggregation-state-persistence
|
|
|
|
|
* @description The boot-order contract for the aggregation engine. Five laws:
|
|
|
|
|
* (1) STATE ADOPTION — a reopen with an unchanged defineAggregate() adopts the
|
|
|
|
|
* persisted state and performs NO store walk. (The pre-fix behavior: the
|
|
|
|
|
* synchronous define always beat the async init, flagged a backfill, and
|
|
|
|
|
* the first query wiped the just-loaded state and re-walked the whole
|
|
|
|
|
* store — every restart, forever.)
|
|
|
|
|
* (2) SINGLE-FLIGHT + BATCH — concurrent cold queries across multiple pending
|
|
|
|
|
* aggregates share exactly ONE store walk; a query never wipes another's
|
|
|
|
|
* partial progress and M pending aggregates cost one enumeration, not M.
|
|
|
|
|
* (3) CHANGED DEFINITION — a real definition change still backfills, exactly.
|
|
|
|
|
* (4) PERSISTED-ONLY DEFINITIONS — an app that does not re-define at boot can
|
|
|
|
|
* query a persisted aggregate without racing a spurious "not defined".
|
|
|
|
|
* (5) QUIET KEYS — the engine's persistence keys (__aggregation_*) are
|
|
|
|
|
* recognized system keys: no "Unknown key format" warning at boot.
|
|
|
|
|
*/
|
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
|
|
|
import * as fs from 'node:fs'
|
|
|
|
|
import * as os from 'node:os'
|
|
|
|
|
import * as path from 'node:path'
|
|
|
|
|
import { Brainy } from '../../src/index.js'
|
|
|
|
|
import { NounType } from '../../src/types/graphTypes.js'
|
|
|
|
|
import type { AggregateDefinition } from '../../src/types/brainy.types.js'
|
|
|
|
|
import { prodLog } from '../../src/utils/logger.js'
|
|
|
|
|
|
|
|
|
|
const SPENDING: AggregateDefinition = {
|
|
|
|
|
name: 'spending',
|
|
|
|
|
source: { type: NounType.Event, where: { domain: 'financial' } },
|
|
|
|
|
groupBy: ['category'],
|
|
|
|
|
metrics: {
|
|
|
|
|
total: { op: 'sum', field: 'amount' },
|
|
|
|
|
count: { op: 'count' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Same name, different metrics — a REAL definition change (hash differs). */
|
|
|
|
|
const SPENDING_CHANGED: AggregateDefinition = {
|
|
|
|
|
...SPENDING,
|
|
|
|
|
metrics: {
|
|
|
|
|
total: { op: 'sum', field: 'amount' },
|
|
|
|
|
count: { op: 'count' },
|
|
|
|
|
average: { op: 'avg', field: 'amount' }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
describe('aggregation state persistence — boot-order contract', () => {
|
|
|
|
|
let dir: string
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'brainy-agg-persist-'))
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(() => {
|
|
|
|
|
fs.rmSync(dir, { recursive: true, force: true })
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const open = async (): Promise<any> => {
|
|
|
|
|
const b: any = new Brainy({
|
|
|
|
|
requireSubtype: false,
|
|
|
|
|
storage: { type: 'filesystem', path: dir },
|
|
|
|
|
silent: true
|
|
|
|
|
})
|
|
|
|
|
await b.init()
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Count store walks by intercepting the storage adapter's getNouns. */
|
|
|
|
|
const countWalks = (brain: any): { count: () => number } => {
|
|
|
|
|
const storage = brain.storage
|
|
|
|
|
const orig = storage.getNouns.bind(storage)
|
|
|
|
|
let calls = 0
|
|
|
|
|
storage.getNouns = async (opts: unknown) => {
|
|
|
|
|
calls++
|
|
|
|
|
return orig(opts)
|
|
|
|
|
}
|
|
|
|
|
return { count: () => calls }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const seed = async (brain: any): Promise<void> => {
|
|
|
|
|
for (let i = 0; i < 12; i++) {
|
|
|
|
|
await brain.add({
|
|
|
|
|
data: `tx ${i}`,
|
|
|
|
|
type: NounType.Event,
|
|
|
|
|
metadata: {
|
|
|
|
|
domain: 'financial',
|
|
|
|
|
category: i % 2 === 0 ? 'food' : 'transport',
|
|
|
|
|
amount: 10 + i
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it('adopts persisted state on reopen with an unchanged definition — zero walks', async () => {
|
|
|
|
|
const brain1 = await open()
|
|
|
|
|
brain1.defineAggregate(SPENDING)
|
|
|
|
|
await seed(brain1)
|
|
|
|
|
const before = await brain1.queryAggregate('spending')
|
|
|
|
|
expect(before.length).toBe(2)
|
|
|
|
|
await brain1.close()
|
|
|
|
|
|
|
|
|
|
const brain2 = await open()
|
|
|
|
|
brain2.defineAggregate(SPENDING) // the standard declarative boot pattern
|
|
|
|
|
await brain2.getNounCount() // settle init paths before counting walks
|
|
|
|
|
const walks = countWalks(brain2)
|
|
|
|
|
|
|
|
|
|
const after = await brain2.queryAggregate('spending')
|
|
|
|
|
|
|
|
|
|
expect(walks.count()).toBe(0)
|
|
|
|
|
const key = (r: any) => r.groupKey.category
|
|
|
|
|
expect(
|
|
|
|
|
after.map((r: any) => [key(r), r.metrics.total, r.metrics.count]).sort()
|
|
|
|
|
).toEqual(
|
|
|
|
|
before.map((r: any) => [key(r), r.metrics.total, r.metrics.count]).sort()
|
|
|
|
|
)
|
|
|
|
|
await brain2.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('adopted state keeps accumulating: post-reopen writes land on top of it', async () => {
|
|
|
|
|
const brain1 = await open()
|
|
|
|
|
brain1.defineAggregate(SPENDING)
|
|
|
|
|
await seed(brain1)
|
|
|
|
|
await brain1.queryAggregate('spending')
|
|
|
|
|
await brain1.close()
|
|
|
|
|
|
|
|
|
|
const brain2 = await open()
|
|
|
|
|
brain2.defineAggregate(SPENDING)
|
|
|
|
|
// A write BEFORE the first query: if it lands before state adoption the
|
|
|
|
|
// engine must choose an exact rescan over adoption — either way the
|
|
|
|
|
// result must include all 13 entities.
|
|
|
|
|
await brain2.add({
|
|
|
|
|
data: 'late tx',
|
|
|
|
|
type: NounType.Event,
|
|
|
|
|
metadata: { domain: 'financial', category: 'food', amount: 100 }
|
|
|
|
|
})
|
|
|
|
|
const rows = await brain2.queryAggregate('spending')
|
|
|
|
|
const food = rows.find((r: any) => r.groupKey.category === 'food')
|
|
|
|
|
expect(food.metrics.count).toBe(7) // 6 seeded + 1 late
|
|
|
|
|
await brain2.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a changed definition still backfills — exactly once, with correct results', async () => {
|
|
|
|
|
const brain1 = await open()
|
|
|
|
|
brain1.defineAggregate(SPENDING)
|
|
|
|
|
await seed(brain1)
|
|
|
|
|
await brain1.queryAggregate('spending')
|
|
|
|
|
await brain1.close()
|
|
|
|
|
|
|
|
|
|
const brain2 = await open()
|
|
|
|
|
brain2.defineAggregate(SPENDING_CHANGED)
|
|
|
|
|
await brain2.getNounCount()
|
|
|
|
|
const walks = countWalks(brain2)
|
|
|
|
|
|
|
|
|
|
const rows = await brain2.queryAggregate('spending')
|
|
|
|
|
|
|
|
|
|
expect(walks.count()).toBe(1) // 12 entities = one page = one getNouns call
|
|
|
|
|
const food = rows.find((r: any) => r.groupKey.category === 'food')
|
|
|
|
|
expect(food.metrics.count).toBe(6)
|
|
|
|
|
expect(food.metrics.average).toBeCloseTo(food.metrics.total / 6)
|
|
|
|
|
await brain2.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('concurrent cold queries across two aggregates share exactly ONE walk', async () => {
|
|
|
|
|
const brain = await open()
|
|
|
|
|
brain.defineAggregate(SPENDING)
|
|
|
|
|
brain.defineAggregate({
|
|
|
|
|
...SPENDING,
|
|
|
|
|
name: 'by_category_count',
|
|
|
|
|
metrics: { count: { op: 'count' } }
|
|
|
|
|
})
|
|
|
|
|
await seed(brain)
|
|
|
|
|
await brain.getNounCount()
|
|
|
|
|
const walks = countWalks(brain)
|
|
|
|
|
|
|
|
|
|
const results = await Promise.all([
|
|
|
|
|
brain.queryAggregate('spending'),
|
|
|
|
|
brain.queryAggregate('by_category_count'),
|
|
|
|
|
brain.queryAggregate('spending'),
|
|
|
|
|
brain.queryAggregate('by_category_count'),
|
|
|
|
|
brain.queryAggregate('spending'),
|
|
|
|
|
brain.queryAggregate('by_category_count')
|
|
|
|
|
])
|
|
|
|
|
|
|
|
|
|
expect(walks.count()).toBe(1) // 12 entities = one page; one walk fills both
|
|
|
|
|
for (const rows of results) {
|
|
|
|
|
const total = rows.reduce((s: number, r: any) => s + r.metrics.count, 0)
|
|
|
|
|
expect(total).toBe(12)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Warm re-query: converged, no further walks.
|
|
|
|
|
await brain.queryAggregate('spending')
|
|
|
|
|
expect(walks.count()).toBe(1)
|
|
|
|
|
await brain.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('persisted-only definitions are queryable without re-defining at boot', async () => {
|
|
|
|
|
const brain1 = await open()
|
|
|
|
|
brain1.defineAggregate(SPENDING)
|
|
|
|
|
await seed(brain1)
|
|
|
|
|
await brain1.queryAggregate('spending')
|
|
|
|
|
await brain1.close()
|
|
|
|
|
|
|
|
|
|
const brain2 = await open()
|
|
|
|
|
// NO defineAggregate — the app relies on the persisted definition.
|
|
|
|
|
const walks = countWalks(brain2)
|
|
|
|
|
const rows = await brain2.queryAggregate('spending')
|
|
|
|
|
expect(walks.count()).toBe(0) // persisted state adopted here too
|
|
|
|
|
expect(rows.length).toBe(2)
|
|
|
|
|
await brain2.close()
|
|
|
|
|
})
|
|
|
|
|
|
fix: exception-safe aggregation backfill + generation-verified adoption + loud open-path guards
- Aggregation backfill walks build into a STAGING map and swap in atomically
on completion. A mid-walk failure drops the staging map — the previous live
state keeps serving, the aggregate stays flagged pending, and the storage
error surfaces to the failing query. Previously the walk wiped live state
before a scan that could throw, never cleared the pending flag on failure,
and re-ran a full walk on every subsequent query: a silent wipe/walk/throw
loop at the caller's retry rate.
- Failed walks are latched: retries within a 30s cooldown rethrow the recorded
error instantly instead of re-walking, so a tight caller-side retry loop
costs one loud error per query, never a full store walk per query.
- Persisted aggregation state is stamped with the store's committed generation
at flush; reopen adoption requires stamp equality. Stale state (unclean
shutdown) or over-counting state (a log truncation on a copied store pulled
the watermark back) triggers exactly one loud rescan, never a silent adopt.
- The backfill/adoption path narrates: adoption decisions, walk start/finish
with counts and duration, and failures all log by default.
- getNouns/getVerbs refuse a supplied-but-undecodable pagination cursor with a
loud error instead of silently restarting the walk at offset 0 (which
re-served page 1 forever to any while(hasMore) caller).
- The graph cold-load verb walk aborts loudly on a missing or non-advancing
cursor with hasMore=true.
- A versioned index provider whose generation is AHEAD of the committed
watermark (torn copy / crash-recovery truncation) is now named loudly at
open, alongside the existing behind-direction message.
2026-07-17 16:00:11 -07:00
|
|
|
it('generation-mismatched persisted state is rescanned once, loudly — never adopted', async () => {
|
|
|
|
|
const brain1 = await open()
|
|
|
|
|
brain1.defineAggregate(SPENDING)
|
|
|
|
|
await seed(brain1)
|
|
|
|
|
await brain1.queryAggregate('spending')
|
|
|
|
|
await brain1.close()
|
|
|
|
|
|
|
|
|
|
// Simulate the copied-store incident class: a fact-log truncation (or an
|
|
|
|
|
// unclean shutdown) leaves the committed watermark different from the
|
|
|
|
|
// generation the flushed state was stamped with.
|
|
|
|
|
const tamper: any = await open()
|
|
|
|
|
const key = '__aggregation_state_spending__'
|
|
|
|
|
const stored = await tamper.storage.getMetadata(key)
|
|
|
|
|
expect(typeof stored.sourceGeneration).toBe('number') // the stamp is really persisted
|
|
|
|
|
await tamper.storage.saveMetadata(key, {
|
|
|
|
|
...stored,
|
|
|
|
|
sourceGeneration: stored.sourceGeneration + 5
|
|
|
|
|
})
|
|
|
|
|
await tamper.close()
|
|
|
|
|
|
|
|
|
|
const warnSpy = vi.spyOn(prodLog, 'warn')
|
|
|
|
|
const brain2 = await open()
|
|
|
|
|
brain2.defineAggregate(SPENDING)
|
|
|
|
|
await brain2.getNounCount()
|
|
|
|
|
const walks = countWalks(brain2)
|
|
|
|
|
|
|
|
|
|
const rows = await brain2.queryAggregate('spending')
|
|
|
|
|
|
|
|
|
|
expect(walks.count()).toBe(1) // exactly ONE rescan — no silent adopt, no spin
|
|
|
|
|
const food = rows.find((r: any) => r.groupKey.category === 'food')
|
|
|
|
|
expect(food.metrics.count).toBe(6) // rescan produced exact results
|
|
|
|
|
expect(
|
|
|
|
|
warnSpy.mock.calls.some(args => String(args[0]).includes('rescanning instead of adopting'))
|
|
|
|
|
).toBe(true) // and it said so out loud
|
|
|
|
|
warnSpy.mockRestore()
|
|
|
|
|
await brain2.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a failing walk is loud, non-destructive, and latched — never a silent retry loop', async () => {
|
|
|
|
|
// Fresh define + seeded writes: the write hooks have populated LIVE state,
|
|
|
|
|
// and the first-query rescan is still pending. The incident shape
|
|
|
|
|
// (wipe-before-scan + no try/catch + per-query re-walk) would have wiped
|
|
|
|
|
// that live state and silently re-walked on every query.
|
|
|
|
|
const brain: any = await open()
|
|
|
|
|
brain.defineAggregate(SPENDING)
|
|
|
|
|
await seed(brain)
|
|
|
|
|
expect(brain._aggregationIndex.queryAggregate({ name: 'spending' }).length).toBe(2)
|
|
|
|
|
|
|
|
|
|
const storage = brain.storage
|
|
|
|
|
const origGetNouns = storage.getNouns.bind(storage)
|
|
|
|
|
let walkAttempts = 0
|
|
|
|
|
storage.getNouns = async () => {
|
|
|
|
|
walkAttempts++
|
|
|
|
|
throw new Error('injected storage failure')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// First query: the walk fails LOUDLY with the storage error.
|
|
|
|
|
await expect(brain.queryAggregate('spending')).rejects.toThrow('injected storage failure')
|
|
|
|
|
expect(walkAttempts).toBe(1)
|
|
|
|
|
|
|
|
|
|
// Live state was NOT destroyed by the failed walk (staging was dropped).
|
|
|
|
|
expect(brain._aggregationIndex.queryAggregate({ name: 'spending' }).length).toBe(2)
|
|
|
|
|
|
|
|
|
|
// Second query inside the cooldown: instant loud failure, NO new walk.
|
|
|
|
|
await expect(brain.queryAggregate('spending')).rejects.toThrow('failure cooldown')
|
|
|
|
|
expect(walkAttempts).toBe(1)
|
|
|
|
|
|
|
|
|
|
// Heal the storage + expire the cooldown: one fresh walk succeeds exactly.
|
|
|
|
|
storage.getNouns = origGetNouns
|
|
|
|
|
brain._aggregationBackfillFailure.at = Date.now() - 60_000
|
|
|
|
|
const rows = await brain.queryAggregate('spending')
|
|
|
|
|
const food = rows.find((r: any) => r.groupKey.category === 'food')
|
|
|
|
|
expect(food.metrics.count).toBe(6)
|
|
|
|
|
await brain.close()
|
|
|
|
|
})
|
|
|
|
|
|
2026-07-17 09:20:09 -07:00
|
|
|
it('aggregation persistence keys never log "Unknown key format"', async () => {
|
|
|
|
|
const warnSpy = vi.spyOn(prodLog, 'warn')
|
|
|
|
|
const brain1 = await open()
|
|
|
|
|
brain1.defineAggregate(SPENDING)
|
|
|
|
|
await seed(brain1)
|
|
|
|
|
await brain1.queryAggregate('spending')
|
|
|
|
|
await brain1.close()
|
|
|
|
|
|
|
|
|
|
const brain2 = await open()
|
|
|
|
|
brain2.defineAggregate(SPENDING)
|
|
|
|
|
await brain2.queryAggregate('spending')
|
|
|
|
|
await brain2.close()
|
|
|
|
|
|
|
|
|
|
const offenders = warnSpy.mock.calls
|
|
|
|
|
.map(args => String(args[0]))
|
|
|
|
|
.filter(
|
|
|
|
|
msg =>
|
|
|
|
|
msg.includes('Unknown key format') &&
|
|
|
|
|
(msg.includes('__aggregation_') || msg.includes('brainy:entityIdMapper'))
|
|
|
|
|
)
|
|
|
|
|
expect(offenders).toEqual([])
|
|
|
|
|
warnSpy.mockRestore()
|
|
|
|
|
})
|
|
|
|
|
})
|