Visualizing a CouchDB Revision Tree to Debug a Sync Conflict
A replication job has stalled, an on-call alert is firing, and a single document keeps coming back conflicted no matter how many times you write it. The _rev string looks like a plain version counter, but it is not — the document has silently forked into a branching revision tree, and until you can see that tree you are resolving conflicts by guesswork. This page shows how to pull a document’s complete branch structure out of CouchDB with the ?revs=true, open_revs=all, and ?revs_info=true endpoints, reconstruct the parent-child directed acyclic graph in Python, and render it as a Graphviz DOT diagram you can paste into an incident post-mortem. It is the visualization companion to revision tree mechanics, the parent guide that covers resolving and pruning the branches you are about to render; the fork itself is produced by the concurrency patterns catalogued in conflict generation models, and the N-<hash> token labelling every node is dissected in how CouchDB revision IDs are generated.
The tree below is the shape you are hunting for: a document that forked at generation 3 (an offline edit on one node), leaving two live generation-4 leaves — one winning, one in conflict — while the other generation-3 branch was deleted (tombstoned):
Immediate Triage / Prerequisites
Before reconstructing anything, confirm the document has actually forked rather than merely lagged. When CouchDB server logs emit replication_failed or checkpoint mismatches, the divergence almost always sits at a generation boundary where _revs_limit truncated a shared ancestor. Grep for the signature and confirm the live branches:
# replication faults and write conflicts in the CouchDB log
grep -E "replication_failed|doc_update_conflict" /var/log/couchdb/couch.log | tail -n 20
# does this document carry losing leaves right now?
curl -s "http://localhost:5984/iot_telemetry/sensor-42?conflicts=true" | \
python3 -c "import sys,json;print('conflicts:', json.load(sys.stdin).get('_conflicts', []))"
A non-empty _conflicts array confirms a genuine fork. Three read-only endpoints expose the tree, and you need all three — each answers a different question:
GET /{db}/{docid}?revs=truereturns the_revisionsobject for one branch: astartinteger (the newest generation) and anidsarray of bare hashes ordered newest-to-oldest. This is the linear ancestry of the winning leaf only.GET /{db}/{docid}?open_revs=allreturns every live leaf, including conflicting and deleted branches, so you can union their ancestries into the full tree.GET /{db}/{docid}?revs_info=truereturns the_revs_infoarray marking each ancestoravailableormissing, pinpointing where_revs_limitpruned history.
_revisions and _revs_info are response fields exposed via query parameters on the document GET — not standalone endpoints. Prerequisites for the code below: Python 3.8+ and the requests library (pip install requests), plus network reach to the database.
Step-by-Step Implementation
Follow these steps to extract every branch and assemble the tree. Each step includes a command or assertion so you can verify state before moving on.
-
Capture the winner and its losers. Read the document with
?conflicts=trueso the winning_revand the_conflictsarray of losing leaves arrive together:curl -s "http://localhost:5984/iot_telemetry/sensor-42?conflicts=true" | \ python3 -c "import sys,json;d=json.load(sys.stdin);print('winner',d['_rev']);print('losers',d.get('_conflicts',[]))"Record the winner plus every loser; the number of leaves you must render equals
1 + len(_conflicts). -
Pull the linear ancestry of a leaf. Request
?revs=true(optionally pinned to a specific leaf with&rev=<leaf>) and read the_revisionsobject:curl -s "http://localhost:5984/iot_telemetry/sensor-42?revs=true" | \ python3 -c "import sys,json;r=json.load(sys.stdin)['_revisions'];print('start',r['start']);print('ids',r['ids'])"Verify that
startequals the winning generation and thatlen(ids)matches the branch depth. GenerationNforids[i]isstart - i. -
Enumerate every branch. A single
?revs=truecall only walks the winning path, so fetch all leaves at once withopen_revs=all:curl -s -H "Accept: application/json" \ "http://localhost:5984/iot_telemetry/sensor-42?open_revs=all&revs=true"Each element of the returned array carries its own
_revisionsobject. Assert that the count of returned leaves matches the1 + len(_conflicts)you recorded in step 1 — a mismatch means a leaf was tombstoned and only surfaces withopen_revs=all. -
Flag pruned ancestors. Read
?revs_info=trueand scan formissingstatuses:curl -s "http://localhost:5984/iot_telemetry/sensor-42?revs_info=true" | \ python3 -c "import sys,json;print([x for x in json.load(sys.stdin)['_revs_info'] if x['status']!='available'])"Any
missingentry marks a gap where_revs_limittruncated history or a network partition dropped an intermediate node — render it as a dashed placeholder so the gap is visible, not silently bridged. -
Reconstruct the DAG. Map each
N-<hash>node to its immediate parent by walking every leaf’sidsarray. Because the ancestries overlap up to the fork point, unioning them into one dictionary yields the branching tree rather than a set of disjoint chains. This in-memory structure is what you render in the next section.
Complete Working Example
The script below is self-contained and runnable. It fetches every leaf with open_revs=all&revs=true, unions their linear ancestries into a single parent-child map, marks tombstoned leaves and pruned ancestors, and emits a Graphviz DOT graph you can render with dot -Tsvg tree.dot -o tree.svg or paste into any DOT viewer.
import json
import sys
import requests
def fetch_leaves(db_url: str, doc_id: str, session: requests.Session):
"""Return every live leaf of a document, each with its _revisions ancestry."""
resp = session.get(
f"{db_url}/{doc_id}",
params={"open_revs": "all", "revs": "true"},
headers={"Accept": "application/json"}, # force JSON, not multipart
timeout=30,
)
resp.raise_for_status()
# open_revs=all returns a list of {"ok": <doc>} (and {"missing": <rev>}) rows.
return [row["ok"] for row in resp.json() if "ok" in row]
def build_tree(leaves: list) -> dict:
"""Union every leaf's linear ancestry into one {node: {...}} DAG.
Each leaf's _revisions gives start + ids (newest first); generation of
ids[i] is start - i, and its parent is ids[i+1] one generation lower.
Overlapping ancestries collapse to the shared trunk, exposing the fork.
"""
tree: dict = {}
for leaf in leaves:
revs = leaf["_revisions"]
start, ids = revs["start"], revs["ids"]
leaf_rev = f"{start}-{ids[0]}"
for idx, rev_hash in enumerate(ids):
gen = start - idx
node = f"{gen}-{rev_hash}"
parent = f"{gen - 1}-{ids[idx + 1]}" if idx + 1 < len(ids) else None
entry = tree.setdefault(node, {"generation": gen, "parent": parent,
"is_leaf": False, "deleted": False})
if parent and entry["parent"] is None:
entry["parent"] = parent
tree[leaf_rev]["is_leaf"] = True
tree[leaf_rev]["deleted"] = bool(leaf.get("_deleted"))
return tree
def to_dot(tree: dict, winner: str) -> str:
"""Render the DAG as Graphviz DOT, colouring winner / conflict / tombstone."""
lines = ["digraph revtree {", ' rankdir=TB;',
' node [shape=box, fontname="monospace"];']
for node, meta in sorted(tree.items()):
if meta["deleted"]:
style = 'style=dashed, color="#868e96", label="%s\\n(tombstoned)"' % node
elif meta["is_leaf"] and node == winner:
style = 'color="#0b7285", label="%s\\nwinning leaf"' % node
elif meta["is_leaf"]:
style = 'color="#e03131", label="%s\\nconflict leaf"' % node
else:
style = 'label="%s"' % node
lines.append(f' "{node}" [{style}];')
for node, meta in sorted(tree.items()):
if meta["parent"]:
lines.append(f' "{meta["parent"]}" -> "{node}";')
lines.append("}")
return "\n".join(lines)
def visualize(db_url: str, doc_id: str, session: requests.Session) -> str:
winner = session.get(f"{db_url}/{doc_id}", timeout=30).json()["_rev"]
tree = build_tree(fetch_leaves(db_url, doc_id, session))
return to_dot(tree, winner)
if __name__ == "__main__":
db = "http://localhost:5984/iot_telemetry"
s = requests.Session()
dot = visualize(db, "sensor-42", s)
print(dot) # pipe to: dot -Tsvg -o tree.svg
sys.exit(0)
Run it against a live database and it prints a complete DOT description of the tree, with the winning leaf, each conflicting leaf, and any tombstoned branch styled distinctly. Because the mapping is deterministic — parent derived purely from generation arithmetic over the ids array — the diagram never depends on server-side sort order, so two runs against the same state produce byte-identical output suitable for diffing across an incident timeline.
Gotchas & Edge Cases
?revs=truealone hides the fork. The_revisionsobject describes only the ancestry of the leaf you asked for. Rendering that single chain makes a forked document look linear. You must union the ancestries returned byopen_revs=all, asbuild_treedoes, to see every branch.- Tombstoned leaves are invisible without
open_revs=all. A deleted branch (_deleted: true) never appears in a default read or in_conflicts, yet it still occupies the tree and still counts against_revs_limit. Onlyopen_revs=allreturns it; render it dashed so reviewers don’t mistake it for missing data. missingancestors mean history was pruned, not lost forever. When?revs_info=truereportsmissing,_revs_limittruncated that node or a partition dropped it. Draw a placeholder edge rather than silently connecting a leaf to a distant ancestor — the gap is diagnostic, often the exact spot a lagging replica manufactured a spurious conflict.- Generation is topological, not chronological. A
4-<hash>leaf is four writes deep on its branch, not “newer in wall-clock time” than a3-<hash>leaf on another branch. Never infer recency from generation when annotating the diagram; carry an application timestamp if you need temporal order. - The default winner is not “the right answer.” CouchDB returns the highest generation, then the lexicographically highest hash — a deterministic tiebreak, not a semantic one. Visualize all leaves before choosing a resolution strategy in algorithm selection for merge; trusting the default winner discards the other branch.
Verification & Observability
Confirm the picture is complete and then track divergence continuously rather than only mid-incident. For a single document, assert that the number of leaves your renderer drew equals 1 + len(_conflicts) from the original read — if it is short, a tombstoned branch was dropped because you forgot open_revs=all. At the pipeline level, treat tree health as a metric: integrate a periodic ?revs_info=true scan into sync health checks and watch the ratio of live leaves to pruned ancestors, because a sudden spike signals a misconfigured _revs_limit or a partition, not a routine conflict.
# per-document: no live conflicting leaves should remain after resolution
curl -s "http://localhost:5984/iot_telemetry/sensor-42?conflicts=true" | \
python3 -c "import sys,json;print('open conflicts:',json.load(sys.stdin).get('_conflicts',[]))"
# per-pipeline: replication jobs draining, not stalled at a fork
curl -s http://localhost:5984/_scheduler/jobs | \
python3 -c "import sys,json;[print(j['id'],j['info'].get('changes_pending')) for j in json.load(sys.stdin)['jobs']]"
A healthy result is an empty _conflicts array on the document and a changes_pending value trending toward zero on the _scheduler/jobs entry. When a rendered tree shows a branch you cannot safely merge, do not force a lossy winner — escalate it through error handling & retry logic so the divergence is queued for review rather than silently collapsed. Rendering the tree as a continuous diagnostic, not a reactive one, is what turns conflict resolution from an on-call firefight into an auditable pipeline operation.
FAQ
Why does ?revs=true show a straight line when the document is clearly conflicted?
Because the _revisions object returned by ?revs=true describes the ancestry of a single leaf — by default the winning one — so a forked document looks linear. To see the branches you must fetch every live leaf with open_revs=all and union their ancestries; each returned leaf carries its own _revisions chain, and the chains share a trunk up to the fork point.
How do I make deleted (tombstoned) branches appear in the diagram?
Read the document with GET /{db}/{docid}?open_revs=all. Deleted leaves carry _deleted: true and never show up in a default read or in the _conflicts array, but open_revs=all returns them. They still occupy the revision tree and count against _revs_limit until compaction runs, so render them as dashed nodes rather than omitting them.
What is the difference between _revisions and _revs_info?
_revisions (from ?revs=true) is a compact ancestry: a start generation and an ids array of bare hashes for one branch, used to reconstruct parent-child edges. _revs_info (from ?revs_info=true) is a per-revision status list marking each ancestor available or missing, used to detect where _revs_limit pruned history. You reconstruct edges from the first and flag gaps from the second.
Related
- How CouchDB Revision IDs Are Generated
- Revision Tree Mechanics
- Conflict Generation Models
- Algorithm Selection for Merge
Part of: Revision Tree Mechanics