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):

A forked revision tree: shared trunk, one tombstoned generation-3 branch, and two live generation-4 leaves (winner and conflict) Revision 1-9a8c is the root; 2-4d11 is its child, forming the common trunk. Generation 2 forks into two children: 3-7c2e stays live while sibling 3-1bd0 is deleted and shown as a dashed grey tombstone. The live 3-7c2e node forks a second time into two generation-4 leaves that share the same parent: 4-e10f is the deterministic winning leaf shown in teal, and 4-aa93 is the losing conflict leaf shown in coral. Generation labels run down the left gutter, and a legend below maps winning leaf, conflict leaf, and tombstoned branch to their colours. gen 1 gen 2 gen 3 gen 4 fork at gen 2 1-9a8c root revision 2-4d11 shared trunk 3-7c2e live branch point 3-1bd0 tombstoned 4-e10f winning leaf 4-aa93 conflict leaf winning leaf conflict leaf tombstoned parent → child

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=true returns the _revisions object for one branch: a start integer (the newest generation) and an ids array of bare hashes ordered newest-to-oldest. This is the linear ancestry of the winning leaf only.
  • GET /{db}/{docid}?open_revs=all returns every live leaf, including conflicting and deleted branches, so you can union their ancestries into the full tree.
  • GET /{db}/{docid}?revs_info=true returns the _revs_info array marking each ancestor available or missing, pinpointing where _revs_limit pruned 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.

Three CouchDB read endpoints converge into one reconstructed DAG, then render to SVG Three document GET calls on the left each answer a different question. ?revs=true returns _revisions, the linear ancestry of a single leaf. ?open_revs=all returns every live and tombstoned leaf so their ancestries can be unioned. ?revs_info=true returns _revs_info, marking each ancestor available or missing. Three arrows, labelled one branch, all leaves, and gaps flagged, converge into a central box that reconstructs the directed acyclic graph by unioning the ancestries into a single parent-to-child map. A final arrow labelled render feeds that map into a Graphviz DOT stage that emits tree.svg. GET ?revs=true linear ancestry of one leaf GET ?open_revs=all every live + tombstoned leaf GET ?revs_info=true each ancestor: available / missing 1 branch all leaves gaps flagged Reconstruct DAG union every ancestry into one parent → child map render Graphviz DOT dot -Tsvg → tree.svg

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.

  1. Capture the winner and its losers. Read the document with ?conflicts=true so the winning _rev and the _conflicts array 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).

  2. Pull the linear ancestry of a leaf. Request ?revs=true (optionally pinned to a specific leaf with &rev=<leaf>) and read the _revisions object:

    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 start equals the winning generation and that len(ids) matches the branch depth. Generation N for ids[i] is start - i.

  3. Enumerate every branch. A single ?revs=true call only walks the winning path, so fetch all leaves at once with open_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 _revisions object. Assert that the count of returned leaves matches the 1 + len(_conflicts) you recorded in step 1 — a mismatch means a leaf was tombstoned and only surfaces with open_revs=all.

  4. Flag pruned ancestors. Read ?revs_info=true and scan for missing statuses:

    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 missing entry marks a gap where _revs_limit truncated history or a network partition dropped an intermediate node — render it as a dashed placeholder so the gap is visible, not silently bridged.

  5. Reconstruct the DAG. Map each N-<hash> node to its immediate parent by walking every leaf’s ids array. 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=true alone hides the fork. The _revisions object 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 by open_revs=all, as build_tree does, 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. Only open_revs=all returns it; render it dashed so reviewers don’t mistake it for missing data.
  • missing ancestors mean history was pruned, not lost forever. When ?revs_info=true reports missing, _revs_limit truncated 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 a 3-<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.

Part of: Revision Tree Mechanics