mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-30 11:22:34 +00:00
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>
94 lines
3.5 KiB
TypeScript
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/);
|
|
}
|
|
});
|
|
});
|
|
});
|