Conflict-Resolution Metrics & SLOs for CouchDB Sync

A sync fleet can look healthy on every dashboard while silently accumulating tens of thousands of unresolved conflicts, because the default CouchDB metrics never count documents carrying a _conflicts array — they count requests, not divergence. When an operator finally reads a document with ?conflicts=true and finds a backlog days deep, there is no history to say when it started or how fast it grew. This page defines the metrics that actually describe conflict-resolution health — conflict rate, backlog depth, resolution latency, auto-resolution success ratio, and manual-queue depth — shows how to count conflicts at scale with a design-document view (because Mango _find cannot select on _conflicts), and turns those numbers into service-level objectives with error budgets. It is the observability layer for the whole of Conflict Detection & Automated Resolution Strategies: the strategies chosen in algorithm selection for merge and dispatched by auto-merge rule engines only earn trust once you can measure what they do to the backlog.

Conflict lifecycle timeline with the metric measured at each stage A horizontal timeline of a single conflict. At the left a conflict is born when two divergent leaves appear on one document, measured by the conflict rate in new conflicts per minute. The document then sits in the backlog, whose depth is the count of documents currently carrying a _conflicts array. The elapsed time from birth to resolution is the resolution latency, typically tracked at the p95. At resolution the path forks: an automatic merge feeds the auto-resolution success ratio, and an escalation adds to the manual-queue depth until an operator resolves it. Each stage names the metric that measures it. fork born two divergent leaves conflict rate new /min document sits in backlog backlog depth docs with _conflicts resolution latency (p95) = resolved − born resolved auto-resolution success ratio auto-merged / total resolved manual-queue depth escalated, awaiting operator outcome branches at resolution
Five metrics map onto one conflict's lifecycle: the conflict rate counts births, backlog depth counts documents still forked, resolution latency measures born-to-resolved, and the outcome splits into the auto-resolution success ratio and the manual-queue depth.

Configuration Schema & Required Parameters

Conflict counting hangs off two artifacts you deploy once: a design document whose view emits every document currently carrying a _conflicts array, and a collector configuration that names the databases to scan, the poll interval, and the SLO thresholds to evaluate against. The view is the scalable counting path because a CouchDB map function is passed the _conflicts field when a document is forked — so it can index exactly the divergent documents — whereas Mango _find can only return conflicts with its conflicts option and cannot use _conflicts in a selector. Deploy the design document to each database you sync:

{
  "_id": "_design/conflict_metrics",
  "views": {
    "conflicted": {
      "map": "function (doc) { if (doc._conflicts) { emit(doc._id, doc._conflicts.length); } }",
      "reduce": "_count"
    }
  },
  "options": { "partitioned": false }
}

The collector reads that view and evaluates thresholds. Drive it entirely from configuration so one image serves every environment:

couch_url: "https://central-db.example:5984"
databases: ["orders", "inventory", "iot_telemetry"]
poll_interval_seconds: 30
view: "_design/conflict_metrics/_view/conflicted"
slo:
  backlog_max: 500              # docs currently carrying _conflicts (per database)
  p95_resolution_latency_seconds: 300
  auto_resolution_ratio_min: 0.95
  manual_queue_max: 50
Parameter Type Default Effect
conflicted view map JS function Emits one row per forked document; reduce: _count returns backlog depth in one request via ?reduce=true.
poll_interval_seconds integer 30 How often the collector re-reads the view; shorter intervals sharpen latency resolution at the cost of query load.
view string Path to the conflicts view; the reduced count is the backlog gauge, the un-reduced rows list the offending IDs.
backlog_max integer 500 SLO ceiling on documents currently forked per database; breaching it burns error budget.
p95_resolution_latency_seconds integer 300 SLO on the 95th-percentile time from fork detection to resolution.
auto_resolution_ratio_min float 0.95 SLO floor on the share of conflicts resolved without human review.
manual_queue_max integer 50 SLO ceiling on documents awaiting a human decision in the review queue.

Key callout: never build a conflict count by scanning _all_docs and re-reading each document with ?conflicts=true — that is one HTTP round trip per document and melts under load. The reduced view returns the backlog depth in a single query, and its keys give you the exact document IDs when you need them.

Because conflict metadata is computed rather than stored, treat the view as eventually consistent: a freshly born conflict appears in the index only after the view is updated, so read with ?stale=update_after (or the modern ?update=lazy) when you want a fast, slightly-behind gauge, and force a fresh build only when auditing. The authoritative semantics for finding conflicts through a view are in the Apache CouchDB replication conflicts documentation.

Streaming Detection / Monitoring Setup

Backlog depth is a level; conflict rate is a flow, and you measure a flow at the edge where conflicts are born. The _changes feed with style=all_docs&conflicts=true surfaces each document the moment a new leaf lands, letting you increment a born-counter and stamp a first-seen timestamp used later for latency. The minimal listener below tracks the rate without holding the whole backlog in memory:

import json
import time

import httpx


def stream_conflict_births(db_url: str, since: str = "now"):
    """Yield (doc_id, timestamp) the first time each forked document is seen.

    style=all_docs reports every leaf and conflicts=true attaches the computed
    _conflicts array, so a document appears here exactly when it is divergent.
    """
    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
                continue
            doc = json.loads(line).get("doc") or {}
            if doc.get("_conflicts"):          # only forked docs carry this key
                yield doc["_id"], time.time()


if __name__ == "__main__":
    seen: dict[str, float] = {}
    window_start = time.time()
    births = 0
    for doc_id, ts in stream_conflict_births("http://localhost:5984/orders"):
        if doc_id not in seen:                 # count each fork once per window
            seen[doc_id] = ts
            births += 1
        if ts - window_start >= 60:            # emit conflict rate per minute
            print(f"conflict_rate_per_min={births}")
            births, window_start = 0, time.time()

Deduplicate by document ID inside the window: during network flapping the same fork reappears on the feed, and counting each reappearance would inflate the rate. The first-seen timestamp captured here is the start clock for resolution latency, closed when the resolver later reports the document converged.

Core Implementation

The collector below is production-grade: it reads the reduced conflicts view for backlog depth, lists offending IDs on demand, tracks resolution latency against a first-seen registry, and exposes every metric as a labelled gauge dictionary ready to hand to a Prometheus client. It uses structured logging and bounded retries, and it never re-reads documents one at a time — the reduced view is a single request per database.

import logging
import time
from dataclasses import dataclass, field
from typing import Optional

import requests

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

VIEW = "_design/conflict_metrics/_view/conflicted"


@dataclass
class Sample:
    """One scrape's worth of conflict-resolution gauges for a single database."""
    database: str
    backlog_depth: int = 0
    manual_queue_depth: int = 0
    auto_success_ratio: float = 1.0
    p95_resolution_latency_s: float = 0.0
    conflicted_ids: list = field(default_factory=list)


class ConflictMetrics:
    """Collect conflict-resolution health gauges from a CouchDB database.

    Backlog depth comes from the reduced conflicts view (one request). Latency
    is derived from a first-seen registry updated as forks are observed and
    cleared as documents converge. Success ratio and manual-queue depth are fed
    by the resolver via record_resolution().
    """

    def __init__(self, base_url: str, database: str,
                 auth: Optional[tuple] = None, max_retries: int = 4):
        self.base_url = base_url.rstrip("/")
        self.database = database
        self.session = requests.Session()
        if auth:
            self.session.auth = auth
        self.max_retries = max_retries
        self._first_seen: dict[str, float] = {}       # doc_id -> fork timestamp
        self._latencies: list[float] = []             # recent resolved latencies (s)
        self._auto, self._manual = 0, 0               # rolling outcome counters

    def _get(self, path: str, params: dict) -> dict:
        for attempt in range(1, self.max_retries + 1):
            resp = self.session.get(f"{self.base_url}/{self.database}/{path}",
                                    params=params, timeout=30)
            if resp.status_code < 500:
                resp.raise_for_status()
                return resp.json()
            backoff = min(2 ** attempt, 20)
            logger.warning("%s returned %s; retry in %ss", path, resp.status_code, backoff)
            time.sleep(backoff)
        raise RuntimeError(f"exhausted retries reading {path}")

    def backlog_depth(self) -> int:
        """Reduced view -> count of documents currently carrying _conflicts."""
        data = self._get(VIEW, {"reduce": "true", "update": "lazy"})
        rows = data.get("rows", [])
        return int(rows[0]["value"]) if rows else 0    # _count reduce -> single row

    def conflicted_ids(self, limit: int = 200) -> list:
        """Un-reduced view -> the actual forked document ids (bounded)."""
        data = self._get(VIEW, {"reduce": "false", "limit": str(limit)})
        return [row["id"] for row in data.get("rows", [])]

    def observe_fork(self, doc_id: str, ts: Optional[float] = None) -> None:
        """Register a newly detected fork so its latency can be measured later."""
        self._first_seen.setdefault(doc_id, ts or time.time())

    def record_resolution(self, doc_id: str, *, automatic: bool) -> None:
        """Close a fork: compute latency and update outcome counters."""
        born = self._first_seen.pop(doc_id, None)
        if born is not None:
            self._latencies.append(time.time() - born)
            self._latencies = self._latencies[-1000:]   # bounded rolling window
        if automatic:
            self._auto += 1
        else:
            self._manual += 1

    def _p95_latency(self) -> float:
        if not self._latencies:
            return 0.0
        ordered = sorted(self._latencies)
        idx = min(len(ordered) - 1, int(round(0.95 * (len(ordered) - 1))))
        return round(ordered[idx], 2)

    def _auto_ratio(self) -> float:
        total = self._auto + self._manual
        return round(self._auto / total, 4) if total else 1.0

    def collect(self) -> Sample:
        """Produce one Sample of all gauges for this scrape."""
        sample = Sample(
            database=self.database,
            backlog_depth=self.backlog_depth(),
            manual_queue_depth=self._manual,           # replace with real queue length
            auto_success_ratio=self._auto_ratio(),
            p95_resolution_latency_s=self._p95_latency(),
        )
        logger.info("db=%s backlog=%d p95_latency=%.1fs auto_ratio=%.3f",
                    sample.database, sample.backlog_depth,
                    sample.p95_resolution_latency_s, sample.auto_success_ratio)
        return sample

    def evaluate_slo(self, sample: Sample, slo: dict) -> dict:
        """Return each SLO's breach state; a True value means the budget is burning."""
        return {
            "backlog_breached": sample.backlog_depth > slo["backlog_max"],
            "latency_breached": sample.p95_resolution_latency_s
                                > slo["p95_resolution_latency_seconds"],
            "auto_ratio_breached": sample.auto_success_ratio
                                   < slo["auto_resolution_ratio_min"],
            "manual_queue_breached": sample.manual_queue_depth > slo["manual_queue_max"],
        }


if __name__ == "__main__":
    slo = {"backlog_max": 500, "p95_resolution_latency_seconds": 300,
           "auto_resolution_ratio_min": 0.95, "manual_queue_max": 50}
    collector = ConflictMetrics("http://localhost:5984", "orders")
    # simulate the resolver reporting outcomes it observed this interval
    collector.observe_fork("order-1", ts=time.time() - 42)
    collector.record_resolution("order-1", automatic=True)
    snap = collector.collect()
    print(snap)
    print("SLO state:", collector.evaluate_slo(snap, slo))

The manual-queue depth here is fed by the resolver’s outcome stream; in production replace it with the live length of the review queue described in Manual Review Sync Queues. The collect() output maps one-to-one onto Prometheus gauges, which is exactly the shape the Prometheus Metrics & Exporter Integration for CouchDB Replication exporter expects, and it complements the request-level signals in Replication Observability & Metrics in CouchDB.

Strategy Variants & Trade-offs

Counting conflicts at scale is a choice between three approaches, and the right one depends on how many documents you sync and how fresh the number must be. Each trades accuracy, freshness, and load differently:

Strategy How it counts Freshness Load / cost Best fit
_changes scan Streams the feed with conflicts=true, tallying births live Real-time flow (rate) One long-lived connection; cheap for rate, weak for total Conflict rate and first-seen latency clocks
Design-doc view Reduced conflicted view returns exact backlog depth Eventually consistent (index lag) One query per scrape; index build cost on write Backlog depth and exact offending IDs at any scale
Sampling Read ?conflicts=true on a random subset, extrapolate Approximate, instant Very low; a few reads per scrape Huge databases where an exact count is too costly

The _changes scan is unbeatable for the rate metric and for stamping when a fork was born, but reconstructing an exact backlog total from it means holding every open conflict in memory — fragile across restarts. The design-doc view is the scalable source of truth for depth: the reduced _count returns the total in one request and the un-reduced rows hand you the exact IDs, at the price of index-build cost on every write and a few seconds of index lag. Sampling trades accuracy for near-zero cost — read a random slice with ?conflicts=true, compute the conflicted fraction, multiply by doc_count — and suits databases with millions of documents where an exact figure is not worth the index. Most production fleets run the _changes scan and the view together: the feed for rate and latency, the view for depth and audit. Note again that Mango _find is not on this list: its conflicts option only decorates returned rows and cannot select or index on _conflicts, so it cannot count conflicts at all.

Error-budget burn-down for a conflict-resolution SLO A monthly error budget bar starts full at one hundred percent. Three SLO-breach periods consume portions of it: a small early breach, a larger middle breach, and a late breach that pushes the remaining budget to zero, crossing a dashed exhaustion line. Once the budget is exhausted, policy freezes risky change until the conflict backlog drains back under the SLO. Monthly error budget — p95 resolution latency < 300s time 100% → within SLO small breach larger breach budget exhausted freeze risky change until backlog drains each breach interval burns budget proportional to its duration
An error budget makes the SLO actionable: each interval the p95 latency (or backlog) breaches its target burns a slice of the monthly budget; when it is exhausted, policy freezes risky sync-pipeline changes until conflicts drain back under target.

Deployment & Orchestration

Run the collector as a small stateless service, one replica per database namespace it scrapes, scheduled on the same cadence as the rest of your monitoring. Two collectors scraping the same view double the query load and can double-count latency samples, so shard by database rather than cloning workers onto one target. Drive everything through the environment so one image serves every environment:

# Collector environment (one replica per database namespace)
COUCH_URL=https://central-db.example:5984
COUCH_DATABASES=orders,inventory,iot_telemetry
POLL_INTERVAL_SECONDS=30
SLO_BACKLOG_MAX=500
SLO_P95_LATENCY_S=300
SLO_AUTO_RATIO_MIN=0.95
METRICS_PORT=9105               # Prometheus scrape endpoint

Expose the gauges on a /metrics endpoint for Prometheus and a /healthz that confirms the collector can reach CouchDB and that its _changes cursor for the rate metric is advancing — a stalled cursor reports a falsely flat conflict rate, which is worse than no metric. Deploy the _design/conflict_metrics document ahead of the collector and let its first read trigger the index build in a low-traffic window; the initial build touches every document, so on a large database it can be slow and I/O-heavy. Pin the _changes since checkpoint in durable storage so a restart resumes the rate count rather than replaying history and briefly spiking the rate. Because the view is eventually consistent, alert on the backlog gauge with a short for: duration so a single lagging scrape does not page.

Troubleshooting & Common Errors

Symptom / error Likely cause Remediation
Backlog gauge reads 0 but conflicts exist View not deployed, or reading with reduce=false and no rows materialized yet Confirm _design/conflict_metrics exists; query the view once to trigger the build; use update=lazy not stale reads for a live gauge
Backlog count lags reality by seconds View index is eventually consistent Expected; accept the lag for gauges, force a fresh build only for audits, and alert with a for: window
_find returns no way to filter _conflicts Mango cannot select on computed conflict metadata Use the design-doc view; Mango’s conflicts:true only decorates returned rows, it cannot index them
Conflict rate reads flat at zero _changes cursor stalled or listener died Health-check the cursor advance; restart from the pinned since; verify style=all_docs&conflicts=true
p95 latency spikes after restart First-seen registry lost, so old forks resolve with no start time Persist the first-seen registry, or exclude resolutions whose birth timestamp is unknown from the percentile
Auto-resolution ratio drops sharply A rule change routed more documents to manual review Check the auto-merge rule engines policy; a bad matcher can silently divert traffic to escalation
500 / os_process_error on view query Map function threw on an unexpected document shape Guard the map with if (doc._conflicts); never assume fields exist; redeploy and rebuild
Backlog grows without new conflicts Resolver writes winners but never tombstones losers Assert convergence in tests; see resolver correctness under the section on testing resolvers

FAQ

Why can't I just use Mango _find to count documents with conflicts?

Because _conflicts is metadata CouchDB computes at read time, not a stored, indexable field. Mango’s _find accepts a conflicts: true option, but that only attaches the conflict list to rows it already returned for some other selector — it cannot use _conflicts in the query itself and cannot build a JSON index on it. The scalable way to count conflicts is a design-document view, whose map function is passed the _conflicts field for forked documents and can therefore emit exactly them; add a _count reduce and the backlog depth is one request.

How should I choose the numbers for my SLOs and error budget?

Start from what your data can tolerate, not from a round number. Set the backlog ceiling below the point where manual review can no longer catch up in a shift, and the p95 resolution latency below the window in which a stale conflict would cause a visible business error (a double-shipped order, a wrong inventory count). Measure your current values for a week first, then set the SLO slightly tighter than today’s p95 and convert the allowed breach time into a monthly error budget. When the budget is exhausted, freeze risky sync-pipeline changes until the backlog drains — that is the whole point of budgeting rather than alerting on every blip.

Does the conflicts view slow down writes?

It adds index-maintenance work like any view: each write to a database with the design document updates the index incrementally, and the first query after a burst of writes pays the catch-up cost. The map is cheap — a single if (doc._conflicts) guard and an emit — so the overhead is small, but do not deploy it to a database you never query for conflicts. Build the index in a low-traffic window on large databases, and read with update=lazy so a scrape returns immediately and triggers the refresh in the background rather than blocking on it.

How do I measure resolution latency when a fork is born on one node and resolved on another?

Anchor both ends to the document, not to a node. Stamp a first-seen timestamp keyed by document ID the moment any collector observes the fork on the _changes feed, and close it when the resolver reports the same document converged. Persist that first-seen registry in durable storage so a restart does not orphan open forks; a resolution whose birth timestamp was lost should be excluded from the percentile rather than counted as zero latency, which would falsely improve the metric.

What is a healthy auto-resolution success ratio to target?

High enough that the manual queue stays drainable by the humans you have. For most fleets a floor around 95% keeps escalations to a trickle a small team can clear, but the right number is whatever keeps the manual-queue depth under its own SLO. Watch the two together: a falling ratio and a rising queue depth at the same time is the signature of a resolver or rule regression, usually a matcher in the rule engine that started routing a document class to manual review after a policy change.

Part of: Conflict Detection & Automated Resolution Strategies