Revision Tree Mechanics in CouchDB Replication

CouchDB tracks every document’s lineage as a revision tree — a directed structure in which each revision has exactly one parent and branches form only when writes diverge before they have synchronized. For edge/IoT deployments, mobile backend engineers, and Python sync pipeline builders, treating this tree as an append-only event log is the difference between a resilient sync workflow and silent data loss. This page is the mechanics layer of CouchDB Replication Architecture & Revision Fundamentals: it shows how to configure revision retention, observe branch divergence from the _changes feed, resolve conflicting leaves in production Python, and tune compaction so the tree never grows unbounded. The branches you see here are produced by the concurrency patterns catalogued in conflict generation models, and the field-level _rev format that identifies each node is dissected in how CouchDB revision IDs are generated.

Anatomy of a CouchDB _rev string: generation counter plus MD5 content hash The _rev 3-c20e8ba8f2d1ac91 is split into a generation counter (the number 3, the revision's depth in the tree, incremented on every write) and an MD5 content hash (a 128-bit digest that makes the revision content-addressable). The hash is derived by running the serialized document body together with the parent _rev id 2-b714 through MD5, whose output becomes the hash segment. 3 - c20e8ba8f2d1ac91 _rev Generation counter (N) Depth of this revision in the tree Increments by 1 on every write MD5 content hash 128-bit digest — content-addressable Same body ⇒ same hash, no coordinator Serialized doc body canonical JSON Parent _rev 2-b714 MD5( ) computes hash

Configuration Schema & Required Parameters

Two settings govern how much of the revision tree CouchDB keeps and how completely a replication job carries divergent branches to the target. The first is the per-database _revs_limit, which caps the depth of revision history retained per document; the second is the replication style, which decides whether every leaf or only the winner crosses the wire. Deploy the replication job itself with the standard _replicator document schema; the revision-tree-specific configuration is applied directly to the database:

{
  "database": "iot_telemetry",
  "revs_limit_endpoint": "PUT /iot_telemetry/_revs_limit",
  "revs_limit_body": 1000,
  "replicator_document": {
    "_id": "rep_edge_to_central",
    "source": "https://edge-node-01.local:5984/iot_telemetry",
    "target": "https://central-db.cluster:5984/iot_telemetry",
    "continuous": true,
    "create_target": false,
    "style": "all_docs"
  }
}

_revs_limit defaults to 1000; you set it with a bare integer body, e.g. PUT /iot_telemetry/_revs_limit with 1000. The style field belongs to the replication request, not the database — all_docs (the replicator default) copies every leaf revision so conflicts survive the trip, while main_only would ship only the winning revision and hide divergence.

Parameter Type Default Effect on the revision tree
_revs_limit integer 1000 Maximum number of revision IDs retained per document. Lower values shrink storage but can prune the shared ancestor two replicas need to recognize common history.
style string all_docs On a replication job, all_docs transfers every leaf revision; main_only transfers just the winner and suppresses conflict propagation.
create_target boolean false Leave false in production so a typo cannot spawn a stray database whose empty tree re-seeds spurious conflicts.
continuous boolean false When true, holds an open _changes listener so new branches surface within seconds rather than per sweep.
revs_info (query) boolean false On a read, ?revs_info=true returns each ancestor’s status (available or missing), exposing where history was pruned.
conflicts (query) boolean false On a read, ?conflicts=true surfaces the computed _conflicts array of losing leaves.

Setting _revs_limit too aggressively is the classic self-inflicted wound: if a replica falls behind by more revisions than the limit retains, CouchDB prunes the common ancestor, and what should have been a fast-forward update is instead recorded as a conflict. Validate the active value with GET /iot_telemetry/_revs_limit before tuning it, and consult the Apache CouchDB replication documentation for the authoritative pruning semantics.

Streaming Detection / Monitoring Setup

Branch divergence must be observed at the point the pipeline consumes the _changes feed, before any downstream compaction runs. Request the feed with style=all_docs so every leaf is reported, and conflicts=true so each document carries its computed _conflicts array. The minimal listener below streams changes and yields only the documents whose revision tree has actually forked:

import json

import httpx


def stream_divergent_docs(db_url: str, since: str = "now"):
    """Yield (doc_id, winning_rev, losing_revs[]) for each forked document.

    style=all_docs makes CouchDB report every leaf revision, and
    conflicts=true attaches the computed _conflicts array so the losing
    leaves ride along without a second round trip.
    """
    params = {
        "feed": "continuous",
        "since": since,
        "style": "all_docs",
        "include_docs": "true",
        "conflicts": "true",
        "heartbeat": "10000",
    }
    with httpx.stream("GET", f"{db_url}/_changes", params=params, timeout=None) as r:
        for line in r.iter_lines():
            if not line:  # heartbeat keep-alive line
                continue
            row = json.loads(line)
            doc = row.get("doc") or {}
            losers = doc.get("_conflicts")
            if losers:  # only forked docs carry this key
                yield doc["_id"], doc["_rev"], losers


if __name__ == "__main__":
    for doc_id, rev, losers in stream_divergent_docs("http://localhost:5984/iot_telemetry"):
        print(f"forked: {doc_id} winner={rev} losers={losers}")

CouchDB picks a winner among competing leaves deterministically — highest generation number first, then the lexicographically highest revision hash as a tiebreaker — but that choice only decides which revision a default read returns; it never deletes the losing leaves. Detection must therefore stay idempotent: during network flapping the same fork reappears on the feed, so the resolver has to tolerate re-processing a document whose losers were already tombstoned. The tree below shows the shape the listener is watching for.

A forked CouchDB revision tree with a winning leaf and a conflict leaf Root revision 1-a3f8 has child 2-b714, the shared ancestor, which forks into two generation-3 leaves: the winning leaf 3-c20e and the conflict leaf 3-9ef1. At equal generation CouchDB returns the higher hash, so 3-c20e (c20e greater than 9ef1) is the winner returned on reads, while the losing leaf 3-9ef1 is retained in the tree until the application resolves the conflict. 1-a3f8 root write · gen 1 2-b714 shared ancestor · gen 2 3-c20e winning leaf 3-9ef1 conflict leaf Equal generation → CouchDB returns the higher hash c20e > 9ef1, so 3-c20e wins; 3-9ef1 stays until resolved

Both generation-3 leaves share parent 2-b714; CouchDB returns 3-c20e by default because, at equal generation, the lexicographically higher hash wins (c20e > 9ef1). The losing leaf 3-9ef1 is not deleted — it persists until an application resolves the conflict. Emit a per-document metric at this point (fork detected, timestamp) so you can later measure end-to-end resolution latency.

Core Implementation

Resolving a fork means fetching every competing leaf, applying a deterministic merge, and committing the merged winner and a tombstone for each loser in a single _bulk_docs batch. Splitting those two writes is the canonical mistake — writing a new winner alone just adds another leaf, and the document stays conflicted until each losing rev is deleted. The class below adds structured logging and bounded exponential-backoff retries on 409 Conflict, and never mutates revision history by hand: it only ever extends the tree.

import logging
import time
from typing import Any, Optional
from urllib.parse import urljoin

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("revision-tree")


class RevisionTreeResolver:
    """Resolve forked CouchDB documents by merging every leaf and tombstoning losers."""

    def __init__(self, base_url: str, db_name: str,
                 auth: Optional[tuple] = None, max_retries: int = 5):
        self.base_url = base_url.rstrip("/")
        self.db_url = urljoin(self.base_url + "/", f"{db_name}/")
        self.max_retries = max_retries
        self.session = requests.Session()
        if auth:
            self.session.auth = auth

    def _get_doc(self, doc_id: str, params: dict) -> dict:
        resp = self.session.get(urljoin(self.db_url, doc_id), params=params, timeout=30)
        resp.raise_for_status()
        return resp.json()

    def _bulk_docs(self, docs: list) -> Any:
        """POST a batch, retrying on 409 with capped exponential backoff."""
        for attempt in range(1, self.max_retries + 1):
            resp = self.session.post(
                urljoin(self.db_url, "_bulk_docs"), json={"docs": docs}, timeout=30
            )
            if resp.status_code != 409:
                resp.raise_for_status()
                return resp.json()
            backoff = min(2 ** attempt, 30)
            logger.warning("409 on _bulk_docs, retrying in %ss", backoff)
            time.sleep(backoff)
        raise RuntimeError("exhausted retries committing merged revision")

    def resolve(self, doc_id: str) -> dict:
        """Fetch a document, merge all leaves deterministically, and commit."""
        # 1. Read the winner plus its computed conflict metadata.
        doc = self._get_doc(doc_id, {"conflicts": "true", "revs_info": "true"})
        losing_revs = doc.get("_conflicts")
        if not losing_revs:
            return {"status": "clean", "doc_id": doc_id}

        logger.info("resolving %s (%d losing leaves)", doc_id, len(losing_revs))

        # 2. Retrieve every losing leaf by explicit rev.
        losers = [self._get_doc(doc_id, {"rev": rev}) for rev in losing_revs]

        # 3. Deterministic field-union merge: prefer non-null values, then the
        #    higher revision hash as a stable tiebreak so the result converges.
        merged = doc.copy()
        for loser in losers:
            for key, value in loser.items():
                if key.startswith("_") or value is None:
                    continue
                if merged.get(key) is None or loser["_rev"] > merged["_rev"]:
                    merged[key] = value

        # 4. Keep _id and the winning _rev so the write extends the winning branch;
        #    drop the read-only computed fields before writing.
        merged.pop("_conflicts", None)
        merged.pop("_revs_info", None)

        # 5. Commit the merged winner AND a tombstone for every loser in one batch.
        #    Writing the winner alone would NOT clear the conflict.
        batch = [merged] + [
            {"_id": doc_id, "_rev": rev, "_deleted": True} for rev in losing_revs
        ]
        result = self._bulk_docs(batch)
        logger.info("resolved %s; %d loser(s) tombstoned", doc_id, len(losing_revs))
        return {"status": "resolved", "doc_id": doc_id, "bulk_result": result}


if __name__ == "__main__":
    resolver = RevisionTreeResolver("http://localhost:5984", "iot_telemetry")
    print(resolver.resolve("telemetry.sensor-42"))

On a 409, re-read the current winner before rebuilding the batch so you never write against a stale generation. When draining a large _conflicts backlog, bound the number of concurrent revision fetches so I/O-bound reads do not overwhelm a resource-constrained edge cluster. The merge shown here is a field-union; the decision of which algorithm to bind to each document class is covered next and, in depth, in algorithm selection for merge.

Strategy Variants & Trade-offs

Once the losing leaves are in hand, the merge function decides how the branches collapse into a single winner. Three paradigms dominate production pipelines, each optimal for a different workload and each interacting differently with the revision tree:

Strategy Consistency guarantee Latency / cost Complexity Best fit for the tree
Last-write-wins (LWW) Convergent, lossy (one branch discarded) Lowest Low High-churn telemetry.* leaves where a dropped overwrite is tolerable
Field-union (semantic) Convergent, lossless for disjoint fields Medium (diff per field) Medium config.* documents whose branches edited different fields
CRDT Strong convergence, lossless Higher payload, low compute High (schema + ops) Offline counters and collaborative state synced after long partitions

Last-write-wins compares generation sequences or application timestamps and keeps a single leaf, so it collapses the tree fastest but discards a branch; its failure mode is clock skew, mitigated with NTP and hybrid logical clocks. Field-union folds non-overlapping field updates across every branch — accepting a new firmware_version from one leaf while keeping a network_config edited on another — preserving structured data at moderate CPU cost. CRDTs embed commutative, associative, idempotent merge state in every write so branches converge mathematically, at the price of payload overhead and custom schemas. Whichever you choose, the resolver must still tombstone the losing leaves, because CouchDB never prunes a live branch on its own. When a merge cannot be trusted — semantic validation fails or confidence falls below threshold — do not force a lossy write; escalate through error handling & retry logic rather than corrupting the tree.

Deployment & Orchestration

Run the resolver as a small stateless service, one replica per replication partition. Two replicas against the same partition race to tombstone the same losers and simply double the 409 retry pressure, so scale horizontally by sharding document namespaces, not by cloning workers onto one stream. Drive every setting through the environment so one image serves every edge node:

# Container environment (one replica per partition)
COUCH_URL=https://central-db.cluster:5984
COUCH_DB=iot_telemetry
RESOLVER_MAX_RETRIES=5
COMPACT_CRON="0 3 * * *"     # nightly, low-traffic window
HEALTHCHECK_PORT=8080

Deleting the losing leaves clears a conflict, but their tombstones and the now-historical interior revisions still occupy disk until compaction runs. POST /iot_telemetry/_compact rewrites the .couch file, discarding non-leaf revision bodies beyond _revs_limit and reclaiming space from superseded revisions — but it never removes a live conflict leaf, so resolution must delete the losers first. Schedule compaction in low-traffic windows to avoid I/O contention on constrained edge devices. Expose a /healthz endpoint that confirms the worker can still reach CouchDB and that its _changes cursor is advancing; a cursor stalled longer than one heartbeat interval signals a dead listener, not a healthy idle. Pin the since checkpoint in durable storage so a restart resumes rather than replays the feed.

Revision tree before and after _compact: interior bodies pruned, winning leaf retained Before _compact, the revision tree carries full bodies for interior revisions 1-a3f8 and 2-b714, the winning leaf 3-c20e, and the tombstoned conflict leaf 3-9ef1. POST /db/_compact reclaims superseded interior bodies beyond _revs_limit. After compaction, the interior revisions and the tombstone are reduced to stubs with their bodies pruned, while only the winning leaf 3-c20e keeps its full body. Before _compact After _compact 1-a3f8 interior · body 2-b714 interior · body 3-c20e winning leaf · body 3-9ef1 tombstoned POST /db/_compact reclaims superseded bodies beyond _revs_limit 1-a3f8 stub · pruned 2-b714 stub · pruned 3-c20e leaf · body retained 3-9ef1 tombstone stub

Troubleshooting & Common Errors

Symptom / error Likely cause Remediation
409 Conflict on _bulk_docs Winning _rev went stale between read and write Re-read the document, rebuild the batch against the fresh _rev, retry with backoff
Conflicts never clear Winner written but losers not tombstoned Include a _deleted: true entry for every rev in _conflicts in the same batch
_revs_info shows missing ancestors _revs_limit pruned history a lagging replica still needed Raise _revs_limit; re-replicate from a source that still holds the ancestor
Spurious conflicts after normal edits Common ancestor pruned below _revs_limit Increase _revs_limit; avoid extreme low values on frequently synced databases
Revision tree grows unbounded Compaction never runs after resolution Schedule POST /db/_compact in low-traffic windows; watch _revs_limit
doc_update_conflict in logs Two resolvers racing the same partition Enforce single-replica-per-partition; shard by namespace
Winner is not the revision you expected Default read returns highest generation, then highest hash Read with ?conflicts=true and merge explicitly rather than trusting the default winner

FAQ

Why does CouchDB keep losing revisions instead of deleting them?

Because the database has no way to know your merge semantics. CouchDB deterministically selects a winning revision to return on reads, but every divergent leaf stays in the revision tree so your application can inspect all branches and merge them without losing data. Deleting the losers is your responsibility, done with a _deleted tombstone once you have committed a merged winner.

What does the `N-hash` format of `_rev` actually encode?

N is the generation counter — the depth of that revision in the tree — and the hash is an MD5 digest computed from the serialized document body and the parent revision ID. That makes each revision content-addressable and lets replicas agree on identity without a coordinator. The full derivation is covered in the guide on how CouchDB revision IDs are generated.

Can I safely lower `_revs_limit` to save disk on edge devices?

Only cautiously. _revs_limit caps how many revision IDs are retained per document; set it below the number of revisions a replica can fall behind by, and CouchDB prunes the shared ancestor two replicas need to recognize common history, turning ordinary updates into spurious conflicts. Reclaim space with compaction first, and treat lowering _revs_limit as a last resort on rarely-diverging databases.

Does compaction remove my unresolved conflicts?

No. Compaction discards non-leaf revision bodies beyond _revs_limit and reclaims space from superseded revisions, but it never removes a live conflict leaf. A document stays conflicted after compaction until you explicitly tombstone each rev in its _conflicts array. Resolve first, then compact.

How do I inspect a document's full revision tree for debugging?

Read it with ?revs_info=true to list every ancestor and its available/missing status, and ?conflicts=true to see the losing leaves. For a visual walkthrough of the endpoints and tooling, see the guide on visualizing revision trees in CouchDB.

Part of: CouchDB Replication Architecture & Revision Fundamentals