brainy/tests/unit/storage/getNouns-cursor-pagination.test.ts
David Snelling 6721c52ad7 fix: getNouns cursor pagination re-scanned the first page forever (permanent CPU loop)
The shard-scan pagination adapter is offset-based and ignored the cursor, so
getNouns({ pagination: { cursor } }) re-returned the first page on every cursor
call. Harmless until 7.32.1 made totalCount the true dataset total — after which
hasMore correctly stays true until a caller has paged through everything. A
caller that paginates by cursor (cursor = page.nextCursor) — notably aggregate
backfill over an already-populated store — then looped forever, re-walking the
entire entity shard tree each iteration and pegging 1-2 CPU cores permanently on
the JS main thread, with zero queries or traffic. (Pre-7.32.1 the same loop
ended after one page, silently backfilling only the first 500 entities — an
incomplete aggregate.)

getNouns() now treats the cursor as an opaque, advancing offset token, so cursor
pagination advances and terminates exactly like offset pagination — and an
aggregate backfill streams the whole corpus exactly once (no longer truncated,
no longer looping). Hardened the backfill loop to pure offset pagination as
defense in depth.

Regression (reproduces the infinite loop, fails fast on any re-scan):
tests/unit/storage/getNouns-cursor-pagination.test.ts
2026-06-22 18:00:01 -07:00

125 lines
5 KiB
TypeScript

/**
* @module tests/unit/storage/getNouns-cursor-pagination
* @description Regression for the 7.33.0 permanent-CPU-loop (a production
* deployment pegged 1-2 cores forever after init). Root cause: the shard-scan
* pagination (`getNounsWithPagination`) is offset-based and IGNORES the cursor,
* while 7.32.1 (correctly) made `totalCount` the true dataset total — so
* `hasMore` stays `true` until fully paginated. A caller paginating by CURSOR
* (`cursor = page.nextCursor`, e.g. aggregate backfill) therefore re-fetched
* offset 0 every iteration — `hasMore` never went false — an infinite re-scan,
* each pass a full O(N) shard-tree walk that pegged the JS main thread.
*
* The fix makes the `getNouns()` cursor an opaque, advancing offset token, so
* cursor pagination behaves exactly like offset pagination (advances + ends).
* The hard iteration guard below makes a regression FAIL fast instead of hang.
*/
import { describe, it, expect } from 'vitest'
import { mkdtempSync, rmSync } from 'fs'
import { tmpdir } from 'os'
import { join } from 'path'
import { FileSystemStorage } from '../../../src/storage/adapters/fileSystemStorage.js'
import type { NounMetadata } from '../../../src/coreTypes.js'
const DIM = 8
/** A deterministic non-zero vector so the noun record is well-formed. */
function vec(seed: number): number[] {
return Array.from({ length: DIM }, (_, i) => ((seed + i) % 7) / 7 - 0.5)
}
/** Storage shards by UUID, so ids must be 32 hex chars. */
function uuid(i: number): string {
return i.toString(16).padStart(32, '0')
}
/** Seed `n` unique nouns through the real metadata+record save path. */
async function seed(storage: FileSystemStorage, n: number): Promise<void> {
for (let i = 0; i < n; i++) {
const id = uuid(i)
await storage.saveNounMetadata(id, {
noun: 'thing',
createdAt: Date.now(),
updatedAt: Date.now()
} as NounMetadata)
await storage.saveNoun({ id, vector: vec(i), connections: new Map(), level: 0 })
}
}
describe('FileSystemStorage.getNouns cursor pagination (7.33.0 CPU-loop regression)', () => {
it('cursor pagination ADVANCES across pages and terminates (no infinite re-scan)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'brainy-cursor-'))
try {
const storage = new FileSystemStorage(dir)
await storage.init()
const TOTAL = 120
const PAGE = 40 // → 3 full pages, so the cursor MUST advance to finish
await seed(storage, TOTAL)
// The exact loop shape the aggregate backfill uses: drive purely off the
// returned cursor. Pre-fix this never terminated; the guard turns a
// regression into a fast failure instead of a hung suite.
const seen = new Set<string>()
let cursor: string | undefined
let iterations = 0
for (;;) {
if (++iterations > 1000) {
throw new Error(
'getNouns cursor pagination did not terminate — the 7.33.0 infinite re-scan regressed'
)
}
const page = await storage.getNouns({
pagination: cursor ? { limit: PAGE, cursor } : { limit: PAGE }
})
for (const n of page.items) seen.add(n.id)
if (!page.hasMore || page.items.length === 0) break
expect(page.nextCursor).toBeDefined()
cursor = page.nextCursor
}
// Complete (every entity once) AND bounded (≈ TOTAL/PAGE pages, not ∞).
expect(seen.size).toBe(TOTAL)
expect(iterations).toBeLessThanOrEqual(Math.ceil(TOTAL / PAGE) + 1)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('the final page reports hasMore=false and no nextCursor', async () => {
const dir = mkdtempSync(join(tmpdir(), 'brainy-cursor-last-'))
try {
const storage = new FileSystemStorage(dir)
await storage.init()
await seed(storage, 30)
// One page that covers the whole (tiny) corpus.
const page = await storage.getNouns({ pagination: { limit: 100 } })
expect(page.items.length).toBe(30)
expect(page.hasMore).toBe(false)
expect(page.nextCursor).toBeUndefined()
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
it('a cursor and the equivalent offset return the same page', async () => {
const dir = mkdtempSync(join(tmpdir(), 'brainy-cursor-eq-'))
try {
const storage = new FileSystemStorage(dir)
await storage.init()
await seed(storage, 100)
const first = await storage.getNouns({ pagination: { limit: 40 } })
const viaCursor = await storage.getNouns({
pagination: { limit: 40, cursor: first.nextCursor }
})
const viaOffset = await storage.getNouns({ pagination: { limit: 40, offset: 40 } })
expect(viaCursor.items.map((n) => n.id)).toEqual(viaOffset.items.map((n) => n.id))
// And the cursor page does NOT repeat the first page (the loop's failure mode).
const firstIds = new Set(first.items.map((n) => n.id))
expect(viaCursor.items.some((n) => firstIds.has(n.id))).toBe(false)
} finally {
rmSync(dir, { recursive: true, force: true })
}
})
})