diff --git a/apps/desktop/src/main/sync/crdt-writeback.test.ts b/apps/desktop/src/main/sync/crdt-writeback.test.ts index 49308bc15..72b7a9de8 100644 --- a/apps/desktop/src/main/sync/crdt-writeback.test.ts +++ b/apps/desktop/src/main/sync/crdt-writeback.test.ts @@ -468,4 +468,99 @@ describe('crdt writeback', () => { expect.objectContaining({ noteId: 'note-1' }) ) }) + + /** + * Burn `ms` of clock inside the conversion — the dominant stage of a pass — + * and hand back a different body every time so the no-op guard never trips. + * Measured real costs: ~36ms for a 2KB note, ~134ms at 12KB, ~430ms at 49KB. + */ + function withWritebackCost(ms: number): () => number { + let pass = 0 + mocks.yDocToMarkdown.mockImplementation(async () => { + vi.setSystemTime(new Date(Date.now() + ms)) + return `updated markdown ${pass++}` + }) + return () => pass + } + + /** 60 updates 500ms apart — the rhythm that fires the debounce every time. */ + async function typeFor30Seconds(): Promise { + for (let i = 0; i < 60; i++) { + scheduleWriteback('note-1', makeDoc('Typing', ['new-tag'])) + await vi.advanceTimersByTimeAsync(500) + } + await vi.advanceTimersByTimeAsync(5000) + } + + it('throttles the write-back pipeline in proportion to what the last pass cost', async () => { + withWritebackCost(200) + + await typeFor30Seconds() + + // A 200ms pass buys a 1800ms cooldown, so passes land every ~2s of wall + // clock: 16 full serialize→read→parse→write→reindex cycles instead of the + // 60 this rhythm used to cost, and write-back now burns ~10% of a core + // instead of 40%. + expect(mocks.yDocToMarkdown).toHaveBeenCalledTimes(16) + expect(mocks.safeRead).toHaveBeenCalledTimes(16) + expect(mocks.parseNote).toHaveBeenCalledTimes(16) + expect(mocks.atomicWrite).toHaveBeenCalledTimes(16) + expect(mocks.syncNoteToCache).toHaveBeenCalledTimes(16) + + // The file still ends up holding the last body the doc produced, byte for + // byte what an unthrottled pass would have written. + expect(mocks.atomicWrite).toHaveBeenLastCalledWith( + '/vault/notes/Existing.md', + JSON.stringify({ + frontmatter: { id: 'note-1', title: 'Existing', tags: ['new-tag'] }, + markdown: 'updated markdown 15', + options: { frontmatterEdited: true } + }) + ) + }) + + it('leaves cheap notes on the plain 500ms debounce', async () => { + withWritebackCost(0) + + await typeFor30Seconds() + + // A pass that costs nothing earns no cooldown: unchanged from before. + expect(mocks.yDocToMarkdown).toHaveBeenCalledTimes(60) + expect(mocks.atomicWrite).toHaveBeenCalledTimes(60) + }) + + it('caps the cooldown so a pathologically slow pass cannot strand the file', async () => { + withWritebackCost(3000) + + await typeFor30Seconds() + + // 3000ms * 9 would be 27s of cooldown; the 5s ceiling keeps the file + // catching up during the run instead of only at the end. + expect(mocks.atomicWrite).toHaveBeenCalledTimes(7) + }) + + it('flushPendingWritebacks still lands a write-back that is inside its cooldown', async () => { + withWritebackCost(200) + + scheduleWriteback('note-1', makeDoc('First', ['new-tag'])) + await vi.advanceTimersByTimeAsync(500) + expect(mocks.atomicWrite).toHaveBeenCalledTimes(1) + + // Second edit lands inside the 1800ms cooldown, so its timer has not fired. + scheduleWriteback('note-1', makeDoc('Second', ['new-tag'])) + await vi.advanceTimersByTimeAsync(500) + expect(mocks.atomicWrite).toHaveBeenCalledTimes(1) + + await flushPendingWritebacks() + + expect(mocks.atomicWrite).toHaveBeenCalledTimes(2) + expect(mocks.atomicWrite).toHaveBeenLastCalledWith( + '/vault/notes/Existing.md', + JSON.stringify({ + frontmatter: { id: 'note-1', title: 'Existing', tags: ['new-tag'] }, + markdown: 'updated markdown 1', + options: { frontmatterEdited: true } + }) + ) + }) }) diff --git a/apps/desktop/src/main/sync/crdt-writeback.ts b/apps/desktop/src/main/sync/crdt-writeback.ts index 51fe51e61..99d4dd818 100644 --- a/apps/desktop/src/main/sync/crdt-writeback.ts +++ b/apps/desktop/src/main/sync/crdt-writeback.ts @@ -58,6 +58,28 @@ const reminderSyncHooks: RemindersServiceHooks = { const log = createLogger('CrdtWriteback') const WRITEBACK_DEBOUNCE_MS = 500 + +/** + * How many times the last pass's own cost a note must stay idle before the next + * write-back may start. + * + * A pass re-serializes the WHOLE document (`yDocToMarkdown`), so it costs what + * the note is big rather than what the edit was: ~36ms for a 2KB note, ~134ms at + * 12KB, ~430ms at 49KB. The debounce re-arms per update, so it never fires while + * keys land faster than every 500ms — but a typing rhythm whose gaps sit around + * half a second (word and sentence pauses) fires the whole pipeline on each one, + * up to twice a second. Spacing passes by a multiple of their own cost caps + * write-back at roughly 1/(1 + FACTOR) of wall clock per note. Cheap notes never + * reach the 500ms floor, so their timing is unchanged. + */ +const WRITEBACK_COOLDOWN_FACTOR = 9 + +/** + * Ceiling on that cooldown. The markdown file is the user's data, so however + * expensive a note gets, it may never lag the live doc by more than this. + */ +const WRITEBACK_MAX_COOLDOWN_MS = 5000 + const IGNORED_WRITE_TTL_MS = 5000 interface PendingWriteback { @@ -65,6 +87,12 @@ interface PendingWriteback { doc: Y.Doc } +interface WritebackCost { + finishedAt: number + durationMs: number +} + +const lastWritebackCost = new Map() const pendingTimers = new Map() const inFlightWritebacks = new Set() const ignoredWrites = new Map() @@ -153,6 +181,31 @@ function emitToRenderer(channel: string, data: unknown): void { } } +/** + * Delay before the next pass for this note: the debounce, extended while the + * previous pass's cooldown is still running. Never shortens the debounce. + */ +function writebackDelayMs(noteId: string): number { + const last = lastWritebackCost.get(noteId) + if (!last) return WRITEBACK_DEBOUNCE_MS + const cooldownMs = Math.min( + last.durationMs * WRITEBACK_COOLDOWN_FACTOR, + WRITEBACK_MAX_COOLDOWN_MS + ) + return Math.max(WRITEBACK_DEBOUNCE_MS, last.finishedAt + cooldownMs - Date.now()) +} + +/** Runs a pass and records what it cost, which is what paces the next one. */ +async function runWriteback(noteId: string, doc: Y.Doc): Promise { + const startedAt = Date.now() + try { + await performWriteback(noteId, doc) + } finally { + const finishedAt = Date.now() + lastWritebackCost.set(noteId, { finishedAt, durationMs: finishedAt - startedAt }) + } +} + export function scheduleWriteback(noteId: string, doc: Y.Doc): void { const existing = pendingTimers.get(noteId) if (existing) clearTimeout(existing.timer) @@ -165,7 +218,7 @@ export function scheduleWriteback(noteId: string, doc: Y.Doc): void { const timer = setTimeout(() => { pendingTimers.delete(noteId) inFlightWritebacks.add(noteId) - performWriteback(noteId, doc) + runWriteback(noteId, doc) .catch((err) => { updateDebugState(noteId, { pending: false, @@ -182,7 +235,7 @@ export function scheduleWriteback(noteId: string, doc: Y.Doc): void { .finally(() => { inFlightWritebacks.delete(noteId) }) - }, WRITEBACK_DEBOUNCE_MS) + }, writebackDelayMs(noteId)) pendingTimers.set(noteId, { timer, doc }) } @@ -192,6 +245,7 @@ export function cancelPendingWritebacks(): void { clearTimeout(timer) } pendingTimers.clear() + lastWritebackCost.clear() } export async function flushPendingWritebacks(): Promise { @@ -200,7 +254,7 @@ export async function flushPendingWritebacks(): Promise { for (const [, { timer }] of pending) clearTimeout(timer) await Promise.all( pending.map(([noteId, { doc }]) => - performWriteback(noteId, doc).catch((err) => { + runWriteback(noteId, doc).catch((err) => { log.error('Write-back failed during shutdown flush', { noteId, error: err }) }) ) diff --git a/apps/docs/src/architecture/crdt.md b/apps/docs/src/architecture/crdt.md index 3779dee4c..db18209b6 100644 --- a/apps/docs/src/architecture/crdt.md +++ b/apps/docs/src/architecture/crdt.md @@ -82,6 +82,26 @@ Three pieces of metadata prevent feedback loops: 2. **Y.Doc origin parameter** distinguishes local typing, IPC re-application, and network apply. 3. **Update buffering** in `CrdtUpdateQueue` orders updates per `noteId`. +## Markdown Write-Back + +Every local or remote Y.Doc update schedules a write-back that re-serializes the whole +document to its vault `.md` file and re-indexes it for search. + +- **Debounce** — 500 ms after the last update, re-armed per update, so a fast typing run + produces one write-back at the end rather than one per keystroke. +- **Cost-proportional cooldown** — a pass re-serializes the entire document, so it costs + what the note is big rather than what the edit was. After a pass finishes, the next one + waits until the note has been idle for nine times what that pass cost, capped at 5 s. + Small notes never reach the 500 ms debounce floor and are unaffected; large notes settle + at roughly a tenth of wall clock instead of saturating a core while the user types. +- **Nothing is deferred indefinitely** — the trailing pass always runs, and + `flushPendingWritebacks()` forces any pending pass through before the CRDT provider is + destroyed, which covers app quit and vault switch. + +While a write-back is queued or mid-write the `.md` file is knowingly behind the Y.Doc, so +markdown-as-truth readers (task checkbox reconciliation) stand down for that window. Search +results and the file on disk catch up when the pass runs. + ## Hybrid Sync Model Notes flow through **both** sync paths: