fix(docmodel): stop infinite loop hydrating documents with a block-tree cycle#860
Conversation
Document.Hydrate rebuilds tree state via treeOpSet.State() -> isAncestor, which did a comparator-heavy B-tree lookup per ancestor step. In a production CPU profile of the hyper.media daemon ~57% of all CPU was in that B-tree machinery (Less/hintsearch/nodeAscend). The #858 version cache cut how often we hydrate but not the per-hydrate cost, so cache misses (latest reads of just-synced docs) still saturate CPU. blockTreeState.blocks is point-lookup only (no ordered iteration), so switch it from *btree.Map[string,blockState] to a plain map[string]blockState. treeOpSet's ordered maps stay B-trees; no CRDT semantics change. Deep-doc hydrate 320.6ms -> 51.1ms (6.3x); flat 2.22ms -> 1.47ms. go test (incl -race) passes.
…ee cycle
A document whose block tree contains a cycle — in production, a block that was
its own parent — made isAncestor loop forever. isAncestor walks up a block's
parent chain in an unbounded `for {}` with no visited guard, so a self-parent
(X.Parent == X) spins a CPU core indefinitely. This hung GetDocument/Hydrate,
and since the read path retries on timeout, a single poisoned document pinned a
core per concurrent request. Three production documents in one space were
affected; each read of them burned a core forever, which was the real cause of
the gateway's CPU falling over at trivial request rates.
How the corruption arose: State() prevents cycles by calling
isAncestor(block, parent) before applying each move, but that inspects the
pre-move state where the block is not yet self-referential, so a move with
block == parent slipped through and made the graph cyclic. The bad changes are
already signed and immutable, so hydration must be robust to them.
Fixes:
- isAncestor now bounds its walk by the number of blocks (a valid ancestor
chain visits each block at most once), so it always terminates; a cyclic
path is treated as "not an ancestor". This un-bricks existing documents.
- State() explicitly marks a self-parent move (block == parent) invisible.
- Move() rejects creating a self-parent move, so new corruption can't form.
Validated against the real exported change history of the affected production
document: hydration went from non-terminating (>400s, effectively infinite) to
~6ms. Adds regression tests for the self-parent cycle (hydrate must terminate),
a two-node cycle in isAncestor, and the write-path guard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012PcXpTnVq45xwRBUMgczVp
…block-tree-cycle-hydration-hang # Conflicts: # backend/api/documents/v3alpha/docmodel/crdt_block_tree.go
This PR now supersedes #859 (incorporates it + fixes the actual bug)I've merged #859 ( Why #859 alone doesn't fix the incident#859 and this PR both touch
The trap (why the profile was misleading)The CPU profile shows Combined result, validated on real dataReplaying the actual exported change history of the affected production document:
Full |
TL;DR
A single production document could pin a CPU core forever.
isAncestorwalks a block's parent chain in an unbounded loop with no cycle guard, so a document containing a block that is its own parent made hydration never terminate. Reading such a document (and the read path retries on timeout) burned one core per concurrent request — the real reason the gateway's CPU fell over at trivial request rates (~6 req/s).Validated against the real affected production document: hydration went from non-terminating (>400s) to ~6ms.
Root cause
treeOpSet.State()rebuilds the block tree by replaying move ops. For each move it callsisAncestor(block, parent)to prevent cycles:The cycle-prevention guard is correct for normal moves, but it inspects the pre-move state. For a move where
block == parent,isAncestor(X, X)looks at X's current parent (not yet itself) and returnsfalse, so the self-parent move is applied —X.Parent = X. Any later walk through X then loops forever.Three documents in one production space (
z6Mkjdkv…:research-roadmap,opportunity-backlog,content-roadmap) contain this corruption. The signed changes are immutable, so hydration must be robust to them, not merely prevent new ones.Why this looked like everything else first
Fix
isAncestorbounds its walk by the number of blocks — a valid ancestor chain visits each block at most once, so exceeding that means a cycle. It stops and treats a cyclic path as "not an ancestor," guaranteeing termination. This un-bricks the existing documents.State()marks a self-parent move (block == parent) invisible, so replaying the corrupt op yields a sane tree (the block keeps its last valid position).Move()rejects self-parent moves, so new corruption can't be created.Validation
Hydratewent from >400s (killed, effectively infinite) to 6.2ms, producing a correct 31-block document.docmodel+documentssuites pass with no regressions.Tests added
TestSelfParentDoesNotHang— injects a self-parent move into the op-log (as historical data has it) and assertsState()terminates (fails via timeout if the guard regresses) and the move is ignored.TestIsAncestorTerminatesOnCycle— a self-loop and a two-node cycle must both terminate as not-an-ancestor.TestMoveBlockRejectsSelfParent— the write path refuses to create a self-parent move.Relationship to other PRs
isAncestorstill loops forever on a self-parent block); see the comment below. perf(docmodel): O(1) block lookups in isAncestor to cut hydrate CPU #859 can be closed in favor of this.Follow-up (not in this PR)
Consider a one-off scan/repair to detect documents with cyclic/self-parent moves and record them, and possibly a migration that supersedes the bad moves. With this fix they render correctly regardless, so it's not urgent.
🤖 Generated with Claude Code