2026-06-20 16:55:02 -07:00
|
|
|
/**
|
|
|
|
|
* @module tests/unit/storage/verb-cursor-pagination
|
|
|
|
|
* @description Graph-perf #2 (8.0): cursor pagination over the verb shard walk.
|
|
|
|
|
*
|
|
|
|
|
* `getVerbsWithPagination` used to be offset-only — it re-scanned from shard 0 on
|
|
|
|
|
* every page and early-terminated at `offset+limit`, so a full edge walk was O(N²)
|
|
|
|
|
* (a consumer measured 19k edges → 27s paging at 900/page). It now accepts an
|
|
|
|
|
* opaque cursor that resumes immediately after the last returned verb, making a
|
|
|
|
|
* full walk O(N) at any page size. These tests pin the correctness guarantees a
|
|
|
|
|
* cursor MUST provide: every item exactly once, no duplicates, no skips, and the
|
|
|
|
|
* same set as an offset walk — plus graceful fallback for a foreign token.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
|
|
|
|
import { Brainy } from '../../../src/index.js'
|
|
|
|
|
import { NounType, VerbType } from '../../../src/types/graphTypes.js'
|
|
|
|
|
import { createTestConfig } from '../../helpers/test-factory.js'
|
|
|
|
|
|
|
|
|
|
describe('verb cursor pagination (graph-perf #2)', () => {
|
|
|
|
|
let brain: Brainy
|
|
|
|
|
// The storage layer is where the cursor lives; exercise the primitive directly.
|
|
|
|
|
let storage: {
|
|
|
|
|
getVerbs(opts: {
|
|
|
|
|
pagination?: { offset?: number; limit?: number; cursor?: string }
|
|
|
|
|
}): Promise<{ items: Array<{ id: string }>; hasMore: boolean; nextCursor?: string }>
|
|
|
|
|
}
|
|
|
|
|
const EDGE_COUNT = 40
|
|
|
|
|
|
|
|
|
|
beforeEach(async () => {
|
|
|
|
|
brain = new Brainy(createTestConfig())
|
|
|
|
|
await brain.init()
|
|
|
|
|
const ids: string[] = []
|
|
|
|
|
for (let i = 0; i <= EDGE_COUNT; i++) {
|
|
|
|
|
ids.push(await brain.add({ type: NounType.Person, subtype: 'employee', data: `N${i}` }))
|
|
|
|
|
}
|
|
|
|
|
// Hub-and-spoke: EDGE_COUNT edges from one node, spread across id-hash shards.
|
|
|
|
|
for (let i = 1; i <= EDGE_COUNT; i++) {
|
|
|
|
|
await brain.relate({ from: ids[0], to: ids[i], type: VerbType.RelatedTo, subtype: 'colleague' })
|
|
|
|
|
}
|
|
|
|
|
storage = (brain as unknown as { storage: typeof storage }).storage
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
afterEach(async () => {
|
|
|
|
|
await brain.close()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('a full cursored walk visits every edge exactly once (no dup, no skip)', async () => {
|
|
|
|
|
const all = await storage.getVerbs({ pagination: { limit: 10000 } })
|
|
|
|
|
const refIds = new Set(all.items.map((v) => v.id))
|
|
|
|
|
expect(refIds.size).toBe(EDGE_COUNT)
|
|
|
|
|
|
|
|
|
|
const seen: string[] = []
|
|
|
|
|
let cursor: string | undefined
|
|
|
|
|
let pages = 0
|
|
|
|
|
for (;;) {
|
|
|
|
|
const page = await storage.getVerbs({
|
|
|
|
|
pagination: cursor ? { limit: 7, cursor } : { limit: 7 }
|
|
|
|
|
})
|
|
|
|
|
pages++
|
|
|
|
|
for (const v of page.items) seen.push(v.id)
|
|
|
|
|
if (!page.hasMore) break
|
|
|
|
|
expect(page.nextCursor).toBeTruthy()
|
|
|
|
|
cursor = page.nextCursor
|
|
|
|
|
if (pages > 100) throw new Error('cursor walk failed to terminate')
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(pages).toBeGreaterThan(1) // genuinely paginated
|
|
|
|
|
expect(new Set(seen).size).toBe(seen.length) // no duplicates
|
|
|
|
|
expect(seen.length).toBe(EDGE_COUNT) // no skips
|
|
|
|
|
expect(new Set(seen)).toEqual(refIds) // exactly the full set
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
it('cursor and offset walks return the same set', async () => {
|
|
|
|
|
const offsetSeen: string[] = []
|
|
|
|
|
let offset = 0
|
|
|
|
|
for (;;) {
|
|
|
|
|
const page = await storage.getVerbs({ pagination: { limit: 9, offset } })
|
|
|
|
|
for (const v of page.items) offsetSeen.push(v.id)
|
|
|
|
|
if (!page.hasMore) break
|
|
|
|
|
offset += page.items.length
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cursorSeen: string[] = []
|
|
|
|
|
let cursor: string | undefined
|
|
|
|
|
for (;;) {
|
|
|
|
|
const page = await storage.getVerbs({
|
|
|
|
|
pagination: cursor ? { limit: 9, cursor } : { limit: 9 }
|
|
|
|
|
})
|
|
|
|
|
for (const v of page.items) cursorSeen.push(v.id)
|
|
|
|
|
if (!page.hasMore) break
|
|
|
|
|
cursor = page.nextCursor
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
expect(cursorSeen.length).toBe(EDGE_COUNT)
|
|
|
|
|
expect(new Set(cursorSeen)).toEqual(new Set(offsetSeen))
|
|
|
|
|
})
|
|
|
|
|
|
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('a foreign/malformed cursor FAILS LOUDLY — never a silent restart from page 1', async () => {
|
|
|
|
|
// The old behavior (decode-null → silent offset-0 fallback) re-served page 1
|
|
|
|
|
// forever to any while(hasMore) walker: an unbounded CPU loop with no log
|
|
|
|
|
// line. An undecodable resume token now refuses the walk instead.
|
|
|
|
|
await expect(
|
|
|
|
|
storage.getVerbs({ pagination: { limit: 5, cursor: 'not-a-cv1-token' } })
|
|
|
|
|
).rejects.toThrow('invalid pagination cursor')
|
|
|
|
|
await expect(
|
|
|
|
|
storage.getNouns({ pagination: { limit: 5, cursor: 'not-a-cv1-token' } })
|
|
|
|
|
).rejects.toThrow('invalid pagination cursor')
|
2026-06-20 16:55:02 -07:00
|
|
|
})
|
|
|
|
|
})
|