perf(sync): pace CRDT write-back by what the last pass cost - #1177
Merged
Conversation
A write-back re-serializes the whole document (`yDocToMarkdown`), so its cost tracks note size, not edit size — measured at ~36ms for a 2KB note, ~134ms at 12KB and ~430ms at 49KB. The 500ms debounce is re-armed per update, so it never fires during a fast typing run, but a rhythm whose gaps sit around half a second (word and sentence pauses) fires the whole serialize -> read -> parse -> write -> reindex pipeline on every one of them, up to twice a second. After a pass finishes, the next one now waits until the note has been idle for nine times what that pass cost, capped at 5s. Cheap notes never reach the 500ms debounce floor, so their timing is unchanged; expensive notes settle at roughly 10% of wall clock instead of 40%. Nothing is dropped: the trailing pass still runs, `flushPendingWritebacks` still forces any deferred pass through on quit and vault switch, and the bytes written are byte-identical (SHA-256 verified on a note with frontmatter, tasks, wiki links, inline tags, nested lists, a code block, a table and an embed).
|
React Doctor found no new issues. 🎉 Reviewed by React Doctor for commit |
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
h4yfans
marked this pull request as ready for review
August 8, 2026 02:04
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
crdt-writeback.tsschedules a fullyDocToMarkdown→ CriticMarkup serialize →safeRead→parseNote→serializeParsedNote→atomicWrite→syncNoteToCache(FTS re-index) pass on a 500 ms debounce, re-armed on every Y.Doc update.The issue's stated trigger is not quite right, so I measured before touching anything.
Measured cycle counts, 30 s of simulated typing on
origin/main(fake timers, counting mocked pipeline stages):So the debounce is trailing-only with no max-wait: continuous typing produces zero passes, not 60. What does produce a pass per update is any rhythm whose gaps sit at or above 500 ms — exactly the word-and-sentence pauses of normal prose typing. That reaches 20–60 passes per 30 s.
Measured cost of one pass (real
yDocToMarkdown+ realparseNote/serializeParsedNote, 20 iterations each):yDocToMarkdownThe pipeline is ~99%
yDocToMarkdown, and it scales with note size. At the 500 ms rhythm a 12 KB note spends ~27% of a core in write-back and a 49 KB note ~85%. That is the real defect, and it is worse than the issue's framing on large notes and absent on the "continuous typing" case it names.Root cause
The debounce delay is a constant. It bounds how soon a pass runs but not how often, and it is unrelated to what a pass costs — so the same 500 ms applies to a note whose pass takes 36 ms and one whose pass takes 430 ms.
Fix
Keep the 500 ms debounce; add a cost-proportional cooldown measured from the end of the previous pass:
FACTOR = 9, ceiling 5 s. Write-back therefore consumes at most ~1/10 of wall clock per note. A 36 ms pass earns a 324 ms cooldown, below the 500 ms debounce floor — small notes behave exactly as before. Only notes whose pass exceeds ~55 ms are paced at all.The suggested "cheap dirty check (doc update counter / state-vector compare) before serializing" was deliberately not implemented:
scheduleWritebackis only ever called fromonDocUpdate, so the doc has always advanced since the last pass and such a check can never short-circuit. It would be dead code.Test evidence
New tests in
crdt-writeback.test.tspin exact operation counts and the exact bytes handed toatomicWrite.RED on
origin/main:GREEN after:
Tests 16 passed (16). Measured before/after for the 200 ms-pass note: 60 → 16 cycles per 30 s (serialize, read, parse, write and re-index all asserted individually); for a 3 s pass, 60 → 7.Mutation verification — two single-line reverts, each targeted by line number:
}, writebackDelayMs(noteId))→}, WRITEBACK_DEBOUNCE_MS)→ 3 tests red (16→60, 7→60, 1→2).WRITEBACK_MAX_COOLDOWN_MS→Number.POSITIVE_INFINITY→ the ceiling test red (7→2).No survivors.
Byte fidelity. A non-trivial note (frontmatter with a custom key, checked/unchecked tasks, wiki link, inline tag, nested lists, ordered list, blockquote, fenced
tscode block, table,![[Embed.png]]) was pushed through the real converter and real frontmatter serializer, capturing theatomicWritepayload onorigin/mainand on this branch:Identical. The change is timing-only and touches no serialization path.
Risk and backward compatibility
Durability of a pending write-back
before-quit→stopSyncRuntime()→CrdtProvider.destroy()→flushPendingWritebacks(), which clears the timers and runs every deferred pass immediately (a test covers the in-cooldown case). This is the sameflushPendingWritebackspath as today; theapp:request-flush/app:flush-donehandshake scoped by fix(shutdown): scope the window flush handshake per window and request #1150 runs earlier in that chain and is untouched by this PR.closeVault()→stopSyncRuntime()→ the samedestroy()→flushPendingWritebacks()..mdfile loses whatever the deferred pass would have written (today up to 500 ms of edits, now up to 5 s on an expensive note). The typing itself is not lost:onDocUpdatepersists every update to y-leveldb before it schedules the write-back, andseedFromMarkdownonly seeds a doc whose fragment is empty, so the recovered Y.Doc wins over the stale file and the next edit or sync writes it out. The note editor's own 1 s autosave (updateNote) is a second, independent writer of the same file and is not throttled here.Search index.
syncNoteToCacheruns inside the pass, so the index catches up exactly when the file does — ≤5 s after a keystroke on a large note instead of ≤500 ms. A user who searches inside that window sees the note's previous body. In exchange, a large note no longer re-indexes twice a second while being typed into.Markdown-as-truth readers.
hasPendingWriteback()stays true for the longer window, soreconcileTaskCheckboxesFromMarkdownstands down for longer. That is the safe direction — it is what stops a stale file from reverting a checkbox the user just ticked.Compatibility. No vault file format, sync protocol, CRDT payload, IPC contract, DB schema or settings shape is touched.
sourceWindowIdtagging and main-owned Y.Docs are untouched. Twonumberfields per note in one module-levelMap, cleared bycancelPendingWritebacks().Relation to open PRs. #1118 makes buffered network CRDT updates survive shutdown; #1116 coalesces full-document snapshot uploads. Both pace the sync side of a doc update. This PR paces the third fan-out of the same
onDocUpdate— the vault-file write — and shares no code or state with either. Their scheduler/queue changes and this cooldown compose without interacting.Verification
Docs:
apps/docs/src/architecture/crdt.mdgains a "Markdown Write-Back" section — the gate reportedmissing-docsand the write-back timing genuinely was undocumented, so it was written rather than skipped.Closes #1030