Files
gbrain/test/cycle-lock-per-source.test.ts
T
Garry TanandClaude Opus 4.7 34914e1578 feat(cycle): per-source lock primitive + db-lock consolidation + PGLite ordering
Three intertwined cycle.ts changes that landed as one logical unit:

1) DELETE acquirePostgresLock + acquirePGLiteLock (~75 LOC of duplicated
   UPSERT-with-TTL SQL). Replace with tryAcquireDbLock from
   src/core/db-lock.ts, which was extracted in v0.22.13 and should have
   been adopted here at that time. New acquireDbCycleLock(engine, sourceId)
   is a 6-line adapter that keeps cycle.ts's LockHandle shape.

   Deliberately uses tryAcquireDbLock NOT withRefreshingLock (codex r2 P0-A):
   - tryAcquireDbLock returns null on busy → cycle returns
     {status:'skipped', reason:'cycle_already_running'} (existing contract)
   - withRefreshingLock throws → would convert busy cycles into failures
   - withRefreshingLock's background timer would skip Minion job-lock
     renewal (codex r2 P0-B) and add in-phase DB traffic on PGLite's
     single connection (codex r2 P1-A)

2) Add cycleLockIdFor(sourceId?: string) primitive:
   - undefined → 'gbrain-cycle' (legacy default, back-compat for autopilot
     and every existing caller)
   - valid kebab → 'gbrain-cycle:<source_id>' (per-source DB lock row)
   - invalid → throws via assertValidSourceId (codex r2 P1-B defense-
     in-depth at the primitive layer, since CycleOpts.sourceId is a new
     direct API surface that becomes part of a DB lock ID AND a PGLite
     file path component)

   Add CycleOpts.sourceId; thread through to acquireDbCycleLock. Documents
   that this only scopes the LOCK — embed/orphans/purge/etc remain
   brain-global per PHASE_SCOPE.

3) PGLite file+DB ordering invariant (codex r2 P0-C + P0-D):
   - PGLite engines acquire the GLOBAL file lock (cycle.lock, no source
     suffix) BEFORE the per-source DB lock. PGLite's process-level
     write-lock is the single-writer guard; per-source DB lock IDs
     alone would let two PGLite cycles run concurrently.
   - File lock release on DB acquisition failure (cleanup guarantee)
   - Compose both handles into one LockHandle whose release() is
     reverse-of-acquire (DB first, file last) so file lock isn't released
     while DB lock is still live.
   - Postgres engines skip the file lock entirely — per-source DB IDs
     are the full granularity.

13 unit tests in test/cycle-lock-per-source.test.ts pin the back-compat
default, per-source ID shape, distinct-ID property, and the internal-
validation throws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 07:45:49 -07:00

94 lines
3.5 KiB
TypeScript

/**
* v0.38 cycle lock primitive tests.
*
* Covers `cycleLockIdFor(sourceId?)` exhaustively:
* - back-compat for undefined sourceId (legacy 'gbrain-cycle')
* - per-source lock IDs (`gbrain-cycle:<id>`)
* - internal validation via assertValidSourceId (codex r2 P1-B)
*
* Integration assertions (two cycles holding distinct locks, busy-lock
* 'skipped' semantics, PGLite file+DB ordering) live in
* test/e2e/cycle-lock-integration.test.ts so they can spin a real engine.
*/
import { describe, test, expect } from 'bun:test';
import { cycleLockIdFor } from '../src/core/cycle.ts';
describe('cycleLockIdFor', () => {
test('returns legacy gbrain-cycle for undefined sourceId (back-compat)', () => {
expect(cycleLockIdFor()).toBe('gbrain-cycle');
expect(cycleLockIdFor(undefined)).toBe('gbrain-cycle');
});
test('returns gbrain-cycle:<source_id> for valid kebab IDs', () => {
expect(cycleLockIdFor('default')).toBe('gbrain-cycle:default');
expect(cycleLockIdFor('portfolio')).toBe('gbrain-cycle:portfolio');
expect(cycleLockIdFor('a')).toBe('gbrain-cycle:a');
expect(cycleLockIdFor('alpha-beta-gamma')).toBe('gbrain-cycle:alpha-beta-gamma');
});
test('produces DISTINCT lock IDs for different sources', () => {
// The whole point — two sources must not share a lock row.
const a = cycleLockIdFor('portfolio');
const b = cycleLockIdFor('personal');
expect(a).not.toBe(b);
expect(a).not.toBe('gbrain-cycle');
expect(b).not.toBe('gbrain-cycle');
});
test('legacy and per-source IDs are distinct (no collision)', () => {
// Important for deploy-window coexistence: old-binary callers using
// the legacy ID won't collide with new-binary callers using a
// per-source ID. Codex r1 P0-4 residual risk is acknowledged in
// the plan; this test guards the structural property.
expect(cycleLockIdFor()).not.toBe(cycleLockIdFor('default'));
expect(cycleLockIdFor()).not.toBe(cycleLockIdFor('legacy'));
});
describe('internal validation (codex r2 P1-B)', () => {
test('throws on path-traversal shapes', () => {
expect(() => cycleLockIdFor('../etc')).toThrow();
expect(() => cycleLockIdFor('/abs')).toThrow();
expect(() => cycleLockIdFor('a/b')).toThrow();
});
test('throws on whitespace', () => {
expect(() => cycleLockIdFor('A B')).toThrow();
expect(() => cycleLockIdFor(' a')).toThrow();
expect(() => cycleLockIdFor('a ')).toThrow();
});
test('throws on underscore IDs (strict regex)', () => {
expect(() => cycleLockIdFor('snake_id')).toThrow();
expect(() => cycleLockIdFor('my_source')).toThrow();
});
test('throws on uppercase', () => {
expect(() => cycleLockIdFor('Default')).toThrow();
expect(() => cycleLockIdFor('PORTFOLIO')).toThrow();
});
test('throws on edge hyphens', () => {
expect(() => cycleLockIdFor('-leading')).toThrow();
expect(() => cycleLockIdFor('trailing-')).toThrow();
});
test('throws on 33+ char IDs', () => {
const tooLong = 'a' + 'b'.repeat(31) + 'c'; // 33 chars
expect(() => cycleLockIdFor(tooLong)).toThrow();
});
test('throws on empty string', () => {
expect(() => cycleLockIdFor('')).toThrow();
});
test('error message includes the bad source_id for triage', () => {
try {
cycleLockIdFor('snake_id');
throw new Error('should have thrown');
} catch (e) {
expect((e as Error).message).toMatch(/snake_id/);
}
});
});
});