Files
gbrain/test/op-checkpoint.test.ts
T
9bf96db807 v0.42.51.0 fix(sync): contention-free clock + checkpoint integrity + honest sync freshness (#2255)
* fix(sync): contention-free page-generation clock — sequence swap

The page-generation clock backed the query-cache Layer-1 bookmark via a
FOR EACH STATEMENT trigger running `UPDATE page_generation_clock SET
value=value+1 WHERE id=1`. That took a transaction-length RowExclusiveLock
on one tuple, so every concurrent page writer serialized on the prior
writer's COMMIT — sync ran at ~0.8 cores regardless of worker count.

Swap to a SEQUENCE bumped by nextval() (a microsecond LWLock, never a row
lock). The clock's only contract is monotonic advancement on any page
INSERT/UPDATE/DELETE; last_value is non-transactional, so rolled-back or
concurrent-uncommitted writers only OVER-invalidate the cache (lose a hit),
never serve stale.

- migration v118: CREATE SEQUENCE + load-bearing 2-arg setval (is_called=
  true, floor 1, seeded >= old clock and MAX(generation)) + repoint the
  trigger function body + DELETE query_cache so no old-clock bookmark
  survives the swap. v107 left immutable.
- query-cache-gate.ts: 3 readers -> SELECT last_value FROM page_generation_clock_seq.
- schema.sql + pglite-schema.ts (+ regenerated schema-embedded.ts) ship the
  sequence on fresh install; table + trigger names retained.
- tests: clockValue reads last_value; mechanism proof (trigger fn uses
  nextval not the row UPDATE); rollback-advances-clock safety pin; real
  PGLite sequence round-trip (is_called gotcha); shape test requires _seq.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): op_checkpoints array-shape guard — CHECK + repair + defensive loader

completed_keys is JSONB and the checkpoint loader runs
jsonb_array_elements_text over it. A non-array (scalar) value makes that
throw "cannot extract elements from a scalar", which takes down the whole
UNION load — including the valid op_checkpoint_paths child rows — and loses
all checkpoint progress for that key. No current writer produces a scalar,
but an older binary / external script / future bug could.

Make the corruption class structurally impossible and self-healing:

- migration v119: LOCK TABLE (so an out-of-band scalar can't land between
  repair and constrain; no-op on single-connection PGLite), repair any
  pre-existing scalar to '[]' (op_checkpoint_paths child rows are the
  append-only source of truth, so the reset loses nothing), then add the
  named CHECK (jsonb_typeof(completed_keys) = 'array') via a pg_constraint
  IF NOT EXISTS guard. A DB-enforced always-on guard — the correct pattern
  vs a migration verify-hook, which never runs on already-stamped brains.
- schema.sql + pglite-schema.ts (+ regenerated schema-embedded.ts) ship the
  same NAMED inline CHECK so fresh installs match migrated brains and v119
  skips the duplicate.
- op-checkpoint.ts loader: gate the legacy arm on jsonb_typeof = 'array' so
  a scalar parent is skipped (children still load) instead of throwing the
  whole union, and log a specific corruption warning when one is seen.
- tests: CHECK rejects a scalar (exactly one constraint, no blob+migration
  dupe); loader survives a scalar parent and returns the children; v119
  repair converts a scalar to '[]'.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doctor): report actively-running sync via live lock, not stale freshness

A slow source that makes partial progress every cycle but never fully
completes used to read as permanently "stale" / "never synced" because
last_sync_at only advances on a full successful sync. The naive fix
(treat recent checkpoint banking as "in progress") is unsafe: a blocked
sync banks the good files then writes no anchor, so banking can't tell
in-progress from wedged.

Use the only honest signal: a LIVE, non-expired per-source sync lock
(inspectLock + syncLockId against gbrain_cycle_locks). Every non-skipLock
sync holds it and refreshes it; a blocked/failed sync's process has exited
(no lock row) and a wedged holder stops refreshing (TTL lapses), so either
correctly falls through to the stale path and is NEVER masked. An
actively-syncing source (including a never-synced source doing its first
sync) counts as synced_recently, preserving the pinned 3-bucket invariant.
The lock lookup reuses doctor's existing dynamic db-lock import and swallows
any throw (stub engine, pre-lock-table brain) to false, so it can only ADD
an in-progress verdict, never suppress a real stale one.

Tests (real PGLiteEngine + real lock rows): stale+no-lock -> fail;
stale+live-lock -> ok; never-synced+live-lock -> ok; never-synced+no-lock
-> fail; expired-TTL lock -> fail (wedged not masked); blocked source with
banked checkpoint rows but no lock -> still fail.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): honest --force-break-lock diagnostic when no lock is held

--force-break-lock used to emit the same terse "Lock ... is not held
(nothing to break)" line and exit 0 even when a sync was genuinely wedged,
sending the operator down a dead end — the wedge was not a held lock. Keep
rc=0 (breaking a non-existent lock is idempotently successful; flipping the
exit code would break automation), but under --force say plainly that
nothing was broken and point at the real next step (gbrain sync / gbrain
doctor) plus a `wedge_hint` field in --json output. The non-force path is
byte-for-byte unchanged.

runBreakLock is exported for the test. Tests: force+no-lock -> wedge_hint
JSON + human hint, rc 0; non-force+no-lock -> unchanged terse line, no hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doctor): surface the in-progress sync holder in the freshness message

Plan-completion follow-up to the BUG 4 live-lock signal: when a source is
actively syncing, name the holder (pid + host) in the check message instead
of silently folding it into synced_recently. The note is appended only when
something is in progress, so steady-state messages stay byte-for-byte
unchanged (the pinned exact-message + 3-bucket-invariant tests still pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(sync): pre-landing review fixes — monotonic clock seed, scoped CHECK guard

Adversarial (codex) review of the implementation diff caught three:

- P1 (correctness): the fresh-schema setval was not monotonic. initSchema
  replays the schema blob, and the unconditional setval(MAX(generation))
  could move page_generation_clock_seq.last_value BACKWARD on an
  already-upgraded brain, letting a stored query_cache bookmark serve stale
  rows. Seed via GREATEST over the sequence's OWN last_value (+ old table
  value + MAX(generation)) in all 3 fresh schemas and migration v118, so a
  replay is idempotent — mirrors the old table's ON CONFLICT DO NOTHING.
  Pinned by a new monotonic regression test.
- P2: v119's CHECK-exists guard keyed on conname only (not globally unique).
  Scope it to conrelid = 'op_checkpoints'::regclass.
- P3: in-progress note ran into the prior sentence in fail/warn doctor
  messages; separate it with '. '.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: make Anthropic/ZE no-key tests hermetic against a dev config key

These "no key" tests cleared only ANTHROPIC_API_KEY / ZEROENTROPY_API_KEY
from the env, but hasAnthropicKey() and checkZeEmbeddingHealth() also read
the key from ~/.gbrain/config.json. On a dev machine whose real config holds
a key, the no-key assertions flipped and the tests failed locally (they
passed only in key-less CI). Add a shared with-env emptyHome() helper and
point GBRAIN_HOME at an empty dir in every no-key path so loadConfig finds
nothing — matching the already-hermetic anthropic-key / gateway-probe tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.44.1.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(key-files): sync doctor + op-checkpoint entries to v0.44.1.0 truth

checkSyncFreshness now reports an actively-running sync via the live
per-source lock (names holder pid+host, counts as synced_recently) instead
of flagging it stale; loadOpCheckpoint gates the legacy union arm on
jsonb_typeof = 'array' so a scalar parent can't take down the whole load,
and migration v119's CHECK constraint makes the corruption class
structurally impossible. Reference docs describe current behavior only —
both entries updated in place, no release-clause appends. Guard + llms
freshness test green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: re-version to v0.42.51.0 (natural next-off-master)

Maintainer override of the queue allocator's leap to 0.44.1.0 (it jumped past
in-flight sibling PR claims at 0.42.50/0.43.0/0.44.0). Take the natural next
slot in the 0.42.x line above the immediate sibling claim (0.42.50.0); a
merge re-bump resolves any collision if a cathedral PR lands first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* ci(e2e): bound + retry the OpenClaw install so a transient npm hang can't burn the Tier 2 budget

The Tier 2 (LLM Skills) job failed at 30m16s — the `npm install -g
openclaw@2026.4.9` step hung on a transient npm/registry stall (orphan
`npm install openclaw` was still running at cancel time) and consumed the
entire 30m job budget that v0.42.50.0 (#2254) introduced. The install
normally finishes in under a minute (Tier 2 is ~4m end to end on master),
so this is flaky-install infra, not a test failure.

Wrap the install in `timeout 120` + a 3-attempt retry loop with an 8-minute
step backstop: a hung attempt is killed in 2 min and retried instead of
eating the whole job. Same bound-the-hang philosophy as #2254's job timeouts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 14:02:47 -07:00

340 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, test, expect, beforeAll, beforeEach, afterAll } from 'bun:test';
import { PGLiteEngine } from '../src/core/pglite-engine.ts';
import { resetPgliteState } from './helpers/reset-pglite.ts';
import {
loadOpCheckpoint,
recordCompleted,
appendCompleted,
clearOpCheckpoint,
resumeFilter,
purgeStaleCheckpoints,
fingerprint,
embedFingerprint,
extractFingerprint,
reindexFingerprint,
} from '../src/core/op-checkpoint.ts';
/**
* D12 pinning tests for src/core/op-checkpoint.ts.
*
* Closes codex #10#16:
* - per-param fingerprint scoping (no cross-mode collisions)
* - DB-backed CRUD works on PGLite (single-host fallback path)
* - resumeFilter is pure
* - purgeStaleCheckpoints respects TTL
*/
let engine: PGLiteEngine;
beforeAll(async () => {
engine = new PGLiteEngine();
await engine.connect({});
await engine.initSchema();
});
afterAll(async () => {
await engine.disconnect();
});
beforeEach(async () => {
await resetPgliteState(engine);
});
describe('fingerprint helpers', () => {
test('fingerprint: stable across runs', () => {
const params = { stale: true, source: 'default' };
expect(fingerprint(params)).toBe(fingerprint(params));
});
test('fingerprint: key order does not matter (canonical-JSON)', () => {
const a = fingerprint({ a: 1, b: 2 });
const b = fingerprint({ b: 2, a: 1 });
expect(a).toBe(b);
});
test('fingerprint: different values produce different hashes', () => {
expect(fingerprint({ a: 1 })).not.toBe(fingerprint({ a: 2 }));
});
test('fingerprint returns 8 hex chars', () => {
expect(fingerprint({ x: 1 })).toMatch(/^[a-f0-9]{8}$/);
});
test('codex #11: extract links vs timeline get different fingerprints', () => {
const linksFp = extractFingerprint({ mode: 'links', source: 'default' });
const timelineFp = extractFingerprint({ mode: 'timeline', source: 'default' });
expect(linksFp).not.toBe(timelineFp);
});
test('codex #12: reindex markdown vs code get different fingerprints', () => {
const md = reindexFingerprint({ markdown: true, chunker_version: 2 });
const code = reindexFingerprint({ code: true, chunker_version: 2 });
expect(md).not.toBe(code);
});
test('codex #15: embed model+dim variation produces different fingerprints', () => {
const a = embedFingerprint({
stale: true,
embedding_model: 'openai:text-embedding-3-large',
embedding_dimensions: 3072,
});
const b = embedFingerprint({
stale: true,
embedding_model: 'voyage:voyage-3',
embedding_dimensions: 1024,
});
expect(a).not.toBe(b);
});
test('reindex chunker_version bump invalidates checkpoint', () => {
const v1 = reindexFingerprint({ markdown: true, chunker_version: 1 });
const v2 = reindexFingerprint({ markdown: true, chunker_version: 2 });
expect(v1).not.toBe(v2);
});
});
describe('loadOpCheckpoint / recordCompleted / clearOpCheckpoint', () => {
test('empty checkpoint returns []', async () => {
const result = await loadOpCheckpoint(engine, { op: 'embed', fingerprint: 'abc12345' });
expect(result).toEqual([]);
});
test('round-trip: write then read', async () => {
const key = { op: 'embed', fingerprint: 'abc12345' };
await recordCompleted(engine, key, ['chunk-1', 'chunk-2', 'chunk-3']);
const result = await loadOpCheckpoint(engine, key);
expect(result.sort()).toEqual(['chunk-1', 'chunk-2', 'chunk-3']);
});
test('write overwrites prior state', async () => {
const key = { op: 'embed', fingerprint: 'abc12345' };
await recordCompleted(engine, key, ['chunk-1']);
await recordCompleted(engine, key, ['chunk-1', 'chunk-2']);
const result = await loadOpCheckpoint(engine, key);
expect(result.sort()).toEqual(['chunk-1', 'chunk-2']);
});
test('different fingerprints stay isolated', async () => {
const linksKey = { op: 'extract', fingerprint: 'fp-links' };
const timelineKey = { op: 'extract', fingerprint: 'fp-timeline' };
await recordCompleted(engine, linksKey, ['file-a.md']);
await recordCompleted(engine, timelineKey, ['file-b.md']);
const links = await loadOpCheckpoint(engine, linksKey);
const timeline = await loadOpCheckpoint(engine, timelineKey);
expect(links).toEqual(['file-a.md']);
expect(timeline).toEqual(['file-b.md']);
});
test('clearOpCheckpoint drops the row', async () => {
const key = { op: 'embed', fingerprint: 'to-clear' };
await recordCompleted(engine, key, ['x']);
expect(await loadOpCheckpoint(engine, key)).toEqual(['x']);
await clearOpCheckpoint(engine, key);
expect(await loadOpCheckpoint(engine, key)).toEqual([]);
});
test('clearOpCheckpoint on missing row is no-op (idempotent)', async () => {
// Should not throw and load should still return [] afterwards
await clearOpCheckpoint(engine, { op: 'never-written', fingerprint: 'nope' });
const after = await loadOpCheckpoint(engine, { op: 'never-written', fingerprint: 'nope' });
expect(after).toEqual([]);
});
});
// #1794: append-only delta storage (op_checkpoint_paths). recordCompleted keeps
// REPLACE semantics for the 9 non-sync consumers; appendCompleted is the
// additive path sync uses to avoid O(N²) full-set rewrites.
describe('appendCompleted (delta) + union read', () => {
async function pathRowCount(op: string, fp: string): Promise<number> {
const rows = await engine.executeRaw<{ n: string | number }>(
`SELECT count(*)::text AS n FROM op_checkpoint_paths WHERE op = $1 AND fingerprint = $2`,
[op, fp],
);
return Number(rows[0]?.n ?? 0);
}
test('appendCompleted returns true and load reflects the delta', async () => {
const key = { op: 'sync', fingerprint: 'fp-append' };
expect(await appendCompleted(engine, key, ['a.md', 'b.md'])).toBe(true);
expect((await loadOpCheckpoint(engine, key)).sort()).toEqual(['a.md', 'b.md']);
});
test('re-appending an already-banked path inserts 0 new rows (delta, not full rewrite)', async () => {
const key = { op: 'sync', fingerprint: 'fp-delta' };
await appendCompleted(engine, key, ['a.md', 'b.md']);
expect(await pathRowCount('sync', 'fp-delta')).toBe(2);
// Second flush re-sends one banked + one new path; ON CONFLICT DO NOTHING
// means only the genuinely-new row lands.
await appendCompleted(engine, key, ['b.md', 'c.md']);
expect(await pathRowCount('sync', 'fp-delta')).toBe(3);
expect((await loadOpCheckpoint(engine, key)).sort()).toEqual(['a.md', 'b.md', 'c.md']);
});
test('empty delta is a no-op (returns true, writes nothing)', async () => {
const key = { op: 'sync', fingerprint: 'fp-empty' };
expect(await appendCompleted(engine, key, [])).toBe(true);
expect(await pathRowCount('sync', 'fp-empty')).toBe(0);
});
test('union read across legacy completed_keys array AND appended child rows', async () => {
// Simulates an in-flight upgrade: a pre-existing parent row carries the
// legacy array, then the new code appends child rows to the same key.
const key = { op: 'sync', fingerprint: 'fp-union' };
await engine.executeRaw(
`INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at)
VALUES ('sync', 'fp-union', '["legacy-1","legacy-2"]'::jsonb, now())`,
);
await appendCompleted(engine, key, ['new-1']);
expect((await loadOpCheckpoint(engine, key)).sort()).toEqual(['legacy-1', 'legacy-2', 'new-1']);
});
test('clearOpCheckpoint cascades to child rows', async () => {
const key = { op: 'sync', fingerprint: 'fp-clear' };
await appendCompleted(engine, key, ['a.md', 'b.md']);
expect(await pathRowCount('sync', 'fp-clear')).toBe(2);
await clearOpCheckpoint(engine, key);
expect(await pathRowCount('sync', 'fp-clear')).toBe(0);
expect(await loadOpCheckpoint(engine, key)).toEqual([]);
});
test('recordCompleted still REPLACES (sync appendCompleted does not)', async () => {
// Guards V3: recordCompleted must remove stale keys, not append them.
const key = { op: 'embed', fingerprint: 'fp-replace' };
await recordCompleted(engine, key, ['x', 'y']);
await recordCompleted(engine, key, ['x']);
expect((await loadOpCheckpoint(engine, key)).sort()).toEqual(['x']);
});
test('purge of a stale parent cascades to its child rows', async () => {
// The FK guarantees children always have a parent, so deleting the stale
// parent cascade-drops its children. (A standalone orphan is impossible to
// create — the FK rejects it — so there is no separate orphan sweep.)
await engine.executeRaw(
`INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at)
VALUES ('sync', 'fp-stale', '[]'::jsonb, now() - interval '10 days')`,
);
await engine.executeRaw(
`INSERT INTO op_checkpoint_paths (op, fingerprint, path, created_at)
VALUES ('sync', 'fp-stale', 'old.md', now() - interval '10 days')`,
);
const purged = await purgeStaleCheckpoints(engine, 7);
expect(purged).toBe(1); // counts the parent; child cascades silently
expect(await pathRowCount('sync', 'fp-stale')).toBe(0);
});
});
describe('resumeFilter (pure)', () => {
test('empty completed returns all', () => {
expect(resumeFilter(['a', 'b', 'c'], [])).toEqual(['a', 'b', 'c']);
});
test('filters out completed keys', () => {
expect(resumeFilter(['a', 'b', 'c', 'd'], ['b', 'd'])).toEqual(['a', 'c']);
});
test('no completed keys present in all: identity', () => {
expect(resumeFilter(['a'], ['z'])).toEqual(['a']);
});
test('all completed: returns empty', () => {
expect(resumeFilter(['a', 'b'], ['a', 'b'])).toEqual([]);
});
});
describe('BUG 3: completed_keys array-shape guard (v119 CHECK + defensive loader)', () => {
const CONSTRAINT = 'op_checkpoints_completed_keys_array';
test('CHECK rejects a scalar completed_keys write — exactly one constraint (no blob+migration dupe)', async () => {
// Fresh PGLite install: the schema blob ships the NAMED inline CHECK and
// migration v119's IF NOT EXISTS skips re-adding it. Exactly one constraint.
const c = await engine.executeRaw<{ n: number }>(
`SELECT count(*)::int AS n FROM pg_constraint WHERE conname = $1`,
[CONSTRAINT],
);
expect(Number(c[0].n)).toBe(1);
let threw = false;
try {
await engine.executeRaw(
`INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at)
VALUES ('embed', 'fp-reject', '"not-an-array"'::jsonb, now())`,
);
} catch {
threw = true;
}
expect(threw).toBe(true);
});
test('loader survives a scalar parent: returns child rows (not []), does not throw', async () => {
const key = { op: 'sync', fingerprint: 'fp-scalar-survive' };
// Bypass the CHECK to simulate pre-migration / out-of-band corruption.
await engine.executeRaw(`ALTER TABLE op_checkpoints DROP CONSTRAINT ${CONSTRAINT}`);
try {
await engine.executeRaw(
`INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at)
VALUES ('sync', 'fp-scalar-survive', '"corrupt-scalar"'::jsonb, now())`,
);
await engine.executeRaw(
`INSERT INTO op_checkpoint_paths (op, fingerprint, path)
VALUES ('sync', 'fp-scalar-survive', 'child-a.md')`,
);
// Pre-guard, jsonb_array_elements_text on the scalar threw and the catch
// returned [] — losing child-a.md. The typeof guard skips the scalar so
// the valid child survives.
const loaded = await loadOpCheckpoint(engine, key);
expect(loaded).toEqual(['child-a.md']);
} finally {
await engine.executeRaw(
`UPDATE op_checkpoints SET completed_keys = '[]'::jsonb WHERE jsonb_typeof(completed_keys) <> 'array'`,
);
await engine.executeRaw(
`ALTER TABLE op_checkpoints ADD CONSTRAINT ${CONSTRAINT} CHECK (jsonb_typeof(completed_keys) = 'array')`,
);
}
});
test('v119 repair converts a scalar parent to an empty array', async () => {
await engine.executeRaw(`ALTER TABLE op_checkpoints DROP CONSTRAINT ${CONSTRAINT}`);
try {
await engine.executeRaw(
`INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at)
VALUES ('embed', 'fp-repair', '"scalar"'::jsonb, now())`,
);
// Migration v119's repair statement.
await engine.executeRaw(
`UPDATE op_checkpoints SET completed_keys = '[]'::jsonb, updated_at = now()
WHERE jsonb_typeof(completed_keys) <> 'array'`,
);
const typ = await engine.executeRaw<{ t: string }>(
`SELECT jsonb_typeof(completed_keys) AS t FROM op_checkpoints WHERE op = 'embed' AND fingerprint = 'fp-repair'`,
);
expect(typ[0].t).toBe('array');
} finally {
await engine.executeRaw(
`ALTER TABLE op_checkpoints ADD CONSTRAINT ${CONSTRAINT} CHECK (jsonb_typeof(completed_keys) = 'array')`,
);
}
});
});
describe('purgeStaleCheckpoints', () => {
test('no stale rows: returns 0', async () => {
await recordCompleted(engine, { op: 'embed', fingerprint: 'fresh' }, ['x']);
const purged = await purgeStaleCheckpoints(engine, 7);
expect(purged).toBe(0);
});
test('purges rows older than TTL', async () => {
// Insert a fake old row directly
await engine.executeRaw(
`INSERT INTO op_checkpoints (op, fingerprint, completed_keys, updated_at)
VALUES ('embed', 'old', '["x"]'::jsonb, now() - interval '10 days')`,
);
const purged = await purgeStaleCheckpoints(engine, 7);
expect(purged).toBe(1);
expect(await loadOpCheckpoint(engine, { op: 'embed', fingerprint: 'old' })).toEqual([]);
});
});