Skip to content

fix(docmodel): stop infinite loop hydrating documents with a block-tree cycle#860

Merged
juligasa merged 3 commits into
mainfrom
fix/block-tree-cycle-hydration-hang
Jul 11, 2026
Merged

fix(docmodel): stop infinite loop hydrating documents with a block-tree cycle#860
juligasa merged 3 commits into
mainfrom
fix/block-tree-cycle-hydration-hang

Conversation

@ericvicenti

@ericvicenti ericvicenti commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

TL;DR

A single production document could pin a CPU core forever. isAncestor walks 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 calls isAncestor(block, parent) to prevent cycles:

func (state *blockTreeState) isAncestor(a, b string) bool {
	n, ok := state.blocks.Get(b)
	for {                                   // <-- unbounded, no visited guard
		if !ok || n.Parent == "" || n.Parent == TrashNodeID { return false }
		if n.Parent == a { return true }
		n, ok = state.blocks.Get(n.Parent)  // X.Parent == X -> loops forever
	}
}

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 returns false, 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

  • "Slow" (18s): that was just the gateway's upstream timeout cutting off an infinite computation.
  • Not O(n²): the op-log is tiny (2,236 moves, 1,558 shallow blocks) — size was never the issue.
  • Cache didn't help: a computation that never finishes never populates the cache; clients time out and retry, re-triggering the loop (696 hung requests observed on one doc).

Fix

  1. isAncestor bounds 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.
  2. 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).
  3. Move() rejects self-parent moves, so new corruption can't be created.

Validation

  • Replayed the real exported change history of the affected production doc: Hydrate went from >400s (killed, effectively infinite) to 6.2ms, producing a correct 31-block document.
  • Full docmodel + documents suites pass with no regressions.

Tests added

  • TestSelfParentDoesNotHang — injects a self-parent move into the op-log (as historical data has it) and asserts State() 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

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

juligasa and others added 3 commits July 10, 2026 15:26
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
@ericvicenti

Copy link
Copy Markdown
Collaborator Author

This PR now supersedes #859 (incorporates it + fixes the actual bug)

I've merged #859 (fix/hydrate-miss-cost, @juligasa's btree→map O(1) lookup optimization) into this branch and combined it with the correctness fix. #860 is now a strict superset of #859 — you get the faster lookups and the guaranteed termination. #859 can be closed in favor of this.

Why #859 alone doesn't fix the incident

#859 and this PR both touch isAncestor, but they target different things:

  • perf(docmodel): O(1) block lookups in isAncestor to cut hydrate CPU #859 makes each isAncestor lookup O(1) (map instead of btree). But its isAncestor keeps the unbounded for {} with no cycle guard. On a self-parented block (X.Parent == X) it still loops forever — just spins the infinite loop faster. The three poison documents (research-roadmap, opportunity-backlog, content-roadmap in z6Mkjdkv…) would keep pinning cores.
  • This PR bounds the walk by the block count so it always terminates, marks self-parent moves invisible in State(), and rejects them in Move(). That's what actually un-bricks the documents.

The trap (why the profile was misleading)

The CPU profile shows isAncestor → btree ops (hintsearch/Less/cmpbody) dominating, which reads as "the lookups are the bottleneck, make them O(1)." But those ops were hot because of the infinite loop doing billions of lookups, not because lookups are slow. The profile pointed at the symptom. #859 optimizes the symptom; this PR fixes the cause. (I fell into the same read early on — it's a very natural misdiagnosis.)

Combined result, validated on real data

Replaying the actual exported change history of the affected production document:

Full docmodel + documents suites pass under -race; regression tests cover the self-parent hang, a two-node cycle, and the write-path guard.

@ericvicenti ericvicenti requested a review from burdiyan July 10, 2026 16:18
@juligasa juligasa merged commit be905db into main Jul 11, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants