diff --git a/server/services/brainSyncLog.js b/server/services/brainSyncLog.js index e3a00ed9f..82016ce90 100644 --- a/server/services/brainSyncLog.js +++ b/server/services/brainSyncLog.js @@ -261,6 +261,8 @@ export async function compactLog(minSeq) { kept.push({ line, seq: entry.seq }); } + if (dropped === 0) return 0; + const newContent = kept.length > 0 ? kept.map(k => k.line).join('\n') + '\n' : ''; await atomicWrite(SYNC_LOG_FILE, newContent); diff --git a/server/services/brainSyncLog.test.js b/server/services/brainSyncLog.test.js index 20df7df6c..c7cbfd877 100644 --- a/server/services/brainSyncLog.test.js +++ b/server/services/brainSyncLog.test.js @@ -434,5 +434,46 @@ describe('brainSyncLog', () => { expect(lastReadStart()).toBe(lineOffset(2)); expect(result.changes.map(c => c.seq)).toEqual([5]); }); + + it('keeps exactly the newest entry when compactLog(currentSeq) is called and recovers seq across restarts (#5439)', async () => { + for (let i = 0; i < 5; i++) { + await appendChange('create', 'people', `p${i}`, { name: `Person ${i}` }, 'inst-1'); + } + expect(getCurrentSeq()).toBe(5); + + // Compact keeping only seq >= 5 + const dropped = await compactLog(getCurrentSeq()); + expect(dropped).toBe(4); + + // Verify file on disk has only seq 5 + const lines = readFileSync(syncLogPath(), 'utf8').trim().split('\n'); + expect(lines).toHaveLength(1); + const parsed = JSON.parse(lines[0]); + expect(parsed.seq).toBe(5); + expect(parsed.id).toBe('p4'); + + // Simulate restart: re-initialize the log + await initSyncLog(); + expect(getCurrentSeq()).toBe(5); + + // Subsequent appends continue monotonic sequence numbers + const nextEntry = await appendChange('create', 'people', 'p5', { name: 'Person 5' }, 'inst-1'); + expect(nextEntry.seq).toBe(6); + expect(getCurrentSeq()).toBe(6); + }); + + it('returns 0 and does not rewrite file when no entries are dropped (#5439)', async () => { + for (let i = 0; i < 3; i++) { + await appendChange('create', 'people', `p${i}`, { name: `Person ${i}` }, 'inst-1'); + } + const beforeContent = readFileSync(syncLogPath(), 'utf8'); + + // Calling compactLog with minSeq <= 1 drops nothing + const dropped = await compactLog(1); + expect(dropped).toBe(0); + + const afterContent = readFileSync(syncLogPath(), 'utf8'); + expect(afterContent).toBe(beforeContent); + }); }); }); diff --git a/server/services/syncOrchestrator.js b/server/services/syncOrchestrator.js index 5c6f2dcf1..eb3def54b 100644 --- a/server/services/syncOrchestrator.js +++ b/server/services/syncOrchestrator.js @@ -874,6 +874,11 @@ export async function syncAllPeers() { }); const minSeq = Math.min(...consumedSeqs); await brainSyncLog.compactLog(minSeq); + } else if (brainSyncLog.getCurrentSeq() > 0) { + // No brain-sync peer will ever pull these entries; a peer added later + // converges through the reconcile snapshot (#1077). Keep the last entry so + // initSyncLog still recovers the sequence counter across restarts. + await brainSyncLog.compactLog(brainSyncLog.getCurrentSeq()); } } diff --git a/server/services/syncOrchestrator.test.js b/server/services/syncOrchestrator.test.js index 3cce6b4c1..8a6907d49 100644 --- a/server/services/syncOrchestrator.test.js +++ b/server/services/syncOrchestrator.test.js @@ -847,6 +847,39 @@ describe('syncOrchestrator', () => { expect(compactLog).toHaveBeenCalledWith(0); }); + + it('compacts to getCurrentSeq() when peers are configured but none is brain-enabled (#5439)', async () => { + const memoryOnlyPeer = { + ...mockPeer, + instanceId: 'A', + syncCategories: { brain: false, memory: true } + }; + getPeers.mockResolvedValue([memoryOnlyPeer]); + readJSONFile.mockImplementation(async () => ({})); + + await syncAllPeers(); + + expect(compactLog).toHaveBeenCalledWith(200); + }); + + it('compacts to getCurrentSeq() when no peers are configured (#5439)', async () => { + getPeers.mockResolvedValue([]); + readJSONFile.mockImplementation(async () => ({})); + + await syncAllPeers(); + + expect(compactLog).toHaveBeenCalledWith(200); + }); + + it('does not call compactLog when no brain peers exist and getCurrentSeq() is 0 (#5439)', async () => { + getCurrentSeq.mockReturnValue(0); + getPeers.mockResolvedValue([]); + readJSONFile.mockImplementation(async () => ({})); + + await syncAllPeers(); + + expect(compactLog).not.toHaveBeenCalled(); + }); }); describe('initSyncOrchestrator', () => {