mirror of
https://github.com/garrytan/gbrain.git
synced 2026-07-27 22:15:33 +00:00
v0.42.52.0 fix(sync): bump last_sync_at heartbeat on 0-changes sync (#2335)
D4 invariant ("never advance last_commit on partial", sync.ts comment)
preserved. last_sync_at is a monitoring signal read by doctor
sync_freshness (warn 24h / fail 72h), separate from the import-converged
bookmark. Without this heartbeat write, a cron-driven */15 sync over a
quiet vault pins last_sync_at to the last real commit, so doctor
falsely flags the source as stale for as long as the vault is quiet.
Reproduction (5 lines, no gbrain install required):
1. Setup a fresh obsidian source + commit a single .md file
2. gbrain sync --source obsidian # first_sync, last_sync_at = NOW
3. (do nothing) gbrain sync --source obsidian # up_to_date
4. SELECT last_sync_at FROM sources WHERE id = 'obsidian';
5. Observed: still pinned to step 2. Expected: bumped to step 3.
Fix: in the up_to_date early-return (sync.ts line ~1786), execute a
single `UPDATE sources SET last_sync_at = now() WHERE id = $1` before
returning. The D4-protected writeSyncAnchor path is untouched.
Test: test/sync.test.ts adds a describe block that runs two
consecutive syncs against a quiet vault and asserts last_sync_at
advances while last_commit is unchanged. PGLite + executeRaw pattern
matches the existing #1970 test scaffold.
Workaround: hourly psql touch in WSL crontab documented at
https://github.com/garrytan/gbrain/issues/[link-to-issue]
Co-authored-by: lost9999 <lost9999@users.noreply.github.com>
This commit is contained in:
@@ -1786,6 +1786,18 @@ async function performSyncInner(engine: BrainEngine, opts: SyncOpts): Promise<Sy
|
||||
detachedWorkingTreeManifest.renamed.length > 0);
|
||||
|
||||
if (lastCommit === headCommit && !versionMismatch && !versionNeverSet && !hasDetachedWorkingTreeChanges) {
|
||||
// v0.42.52.0 (PR #22xx): bump last_sync_at as a heartbeat on every successful
|
||||
// 0-changes sync. D4 invariant ("never advance last_commit on partial") is
|
||||
// preserved: last_sync_at is a monitoring signal (doctor sync_freshness
|
||||
// reads it), separate from the import-converged bookmark. Without this,
|
||||
// a cron-driven `*/15 sync` over a quiet vault leaves last_sync_at pinned
|
||||
// to the last real commit, so doctor falsely flags the source as stale.
|
||||
if (opts.sourceId) {
|
||||
await engine.executeRaw(
|
||||
`UPDATE sources SET last_sync_at = now() WHERE id = $1`,
|
||||
[opts.sourceId],
|
||||
);
|
||||
}
|
||||
return {
|
||||
status: 'up_to_date',
|
||||
fromCommit: lastCommit,
|
||||
|
||||
@@ -911,3 +911,88 @@ describe('#1970: unreachable last_commit bookmark recovery', () => {
|
||||
expect(settled.status).toBe('up_to_date');
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.42.52.0: 0-changes sync bumps last_sync_at heartbeat (D4 invariant preserved)', () => {
|
||||
let engine: PGLiteEngine;
|
||||
const repos: string[] = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
engine = new PGLiteEngine();
|
||||
await engine.connect({});
|
||||
await engine.initSchema();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await engine.disconnect();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetPgliteState(engine);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (repos.length) {
|
||||
const d = repos.pop();
|
||||
if (d) rmSync(d, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
function personMd(title: string, body: string): string {
|
||||
return ['---', 'type: person', `title: ${title}`, '---', '', body].join('\n');
|
||||
}
|
||||
|
||||
function mkRepo(files: Record<string, string>): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'gbrain-heartbeat-'));
|
||||
repos.push(dir);
|
||||
execSync('git init', { cwd: dir, stdio: 'pipe' });
|
||||
execSync('git config user.email "test@test.com"', { cwd: dir, stdio: 'pipe' });
|
||||
execSync('git config user.name "Test"', { cwd: dir, stdio: 'pipe' });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
mkdirSync(join(dir, rel, '..'), { recursive: true });
|
||||
writeFileSync(join(dir, rel), content);
|
||||
}
|
||||
execSync('git add -A && git commit -m "initial"', { cwd: dir, stdio: 'pipe' });
|
||||
return dir;
|
||||
}
|
||||
|
||||
const SYNC_OPTS = { noPull: true, noEmbed: true, noExtract: true, sourceId: 'default' } as const;
|
||||
|
||||
async function lastSyncAt(): Promise<string | null> {
|
||||
const rows = await engine.executeRaw<{ last_sync_at: string | null }>(
|
||||
`SELECT last_sync_at FROM sources WHERE id = 'default'`,
|
||||
);
|
||||
return rows[0]?.last_sync_at ?? null;
|
||||
}
|
||||
|
||||
test('consecutive 0-changes syncs advance last_sync_at without advancing last_commit', async () => {
|
||||
const { performSync } = await import('../src/commands/sync.ts');
|
||||
const repo = mkRepo({
|
||||
'people/alice.md': personMd('Alice', 'Alice is a person.'),
|
||||
});
|
||||
|
||||
const first = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
|
||||
expect(first.status).toBe('first_sync');
|
||||
const afterFirst = await lastSyncAt();
|
||||
expect(afterFirst).not.toBeNull();
|
||||
const firstRows = await engine.executeRaw<{ last_commit: string | null }>(
|
||||
`SELECT last_commit FROM sources WHERE id = 'default'`,
|
||||
);
|
||||
const lastCommit = firstRows[0]?.last_commit;
|
||||
expect(lastCommit).not.toBeNull();
|
||||
|
||||
// Wait 1.1s so the DB clock will tick past `afterFirst`.
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
|
||||
const second = await performSync(engine, { repoPath: repo, ...SYNC_OPTS });
|
||||
expect(second.status).toBe('up_to_date');
|
||||
const afterSecond = await lastSyncAt();
|
||||
expect(afterSecond).not.toBeNull();
|
||||
expect(afterSecond).not.toEqual(afterFirst); // heartbeat bumped
|
||||
|
||||
// D4 invariant: last_commit is unchanged on 0-changes sync.
|
||||
const lastCommitRows = await engine.executeRaw<{ last_commit: string | null }>(
|
||||
`SELECT last_commit FROM sources WHERE id = 'default'`,
|
||||
);
|
||||
expect(lastCommitRows[0]?.last_commit).toEqual(lastCommit);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user