Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions apps/desktop/src/main/sync/crdt-writeback.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
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 }
})
)
})
})
60 changes: 57 additions & 3 deletions apps/desktop/src/main/sync/crdt-writeback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,13 +58,41 @@ 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 {
timer: ReturnType<typeof setTimeout>
doc: Y.Doc
}

interface WritebackCost {
finishedAt: number
durationMs: number
}

const lastWritebackCost = new Map<string, WritebackCost>()
const pendingTimers = new Map<string, PendingWriteback>()
const inFlightWritebacks = new Set<string>()
const ignoredWrites = new Map<string, number>()
Expand Down Expand Up @@ -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<void> {
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)
Expand All @@ -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,
Expand All @@ -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 })
}
Expand All @@ -192,6 +245,7 @@ export function cancelPendingWritebacks(): void {
clearTimeout(timer)
}
pendingTimers.clear()
lastWritebackCost.clear()
}

export async function flushPendingWritebacks(): Promise<void> {
Expand All @@ -200,7 +254,7 @@ export async function flushPendingWritebacks(): Promise<void> {
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 })
})
)
Expand Down
20 changes: 20 additions & 0 deletions apps/docs/src/architecture/crdt.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading