Diagnosing Checkpoint Drift in CouchDB Replication
Your continuous replication has started chewing through bandwidth it should not need: the job re-reads tens of thousands of changes it already copied yesterday, through_seq keeps resetting to a lower value on every restart, and documents you already resolved reappear as fresh conflicts on the target. This is checkpoint drift — the source and target no longer agree on how far replication has progressed, so CouchDB rewinds to the last sequence both sides can prove they reached and re-scans the range in between. This guide shows you how to read the _local/ checkpoint documents on each side, compare their session histories, and decide whether to accept a one-time re-scan or reset the job cleanly. It sits under Error Handling & Retry Logic; if the job is not merely re-scanning but has stopped advancing at all, see Recovering from a Stalled Replication Job instead.
_local checkpoint lost its two newest sessions, so the newest session_id both histories share sits at recorded_seq 31005. CouchDB restarts there and re-scans everything up to the source's 48210.Immediate Triage / Prerequisites
Before touching the job, confirm you are looking at drift and not ordinary continuous idling. The signature is a through_seq that advances during a run but resets downward every time the job restarts, plus repeated large revisions_checked counts with a low docs_written. Pull the job state and watch for the reset:
# what sequence does the scheduler think it has committed?
curl -s http://localhost:5984/_scheduler/jobs | python3 -c "
import sys, json
for j in json.load(sys.stdin)['jobs']:
i = j.get('info') or {}
print(j['id'], 'through_seq=', i.get('through_seq'),
'checkpointed=', i.get('checkpointed_source_seq'),
'checked=', i.get('revisions_checked'),
'written=', i.get('docs_written'))
"
# the same numbers appear here for a legacy/one-shot job
curl -s http://localhost:5984/_active_tasks | python3 -c "
import sys, json
for t in json.load(sys.stdin):
if t.get('type') == 'replication':
print(t.get('replication_id'), t.get('through_seq'), t.get('checkpointed_source_seq'))
"
A healthy continuous job shows checkpointed_source_seq tracking through_seq a short distance behind it and both climbing. Drift shows checkpointed_source_seq frozen or jumping backward after a restart. Prerequisites for the steps below: Python 3.8+ with requests (pip install requests), admin credentials (the _local checkpoint documents are only readable by a database admin), and the exact replication_id for the job — it is the string you just printed, and it is also the document id of the checkpoint on both databases.
The checkpoint document id and the replication id are the same value. If you change a
filter,selector,query_params, or the source/target URL, the replication id changes, so CouchDB looks for a checkpoint document that does not exist and starts from sequence0. A “sudden full re-scan” after an edit to the_replicatordocument is almost always this, not corruption.
Step-by-Step Implementation
Follow these steps to read both checkpoints, diff their histories, and locate the sequence CouchDB will actually restart from.
-
Locate the replication id. Read it from
_scheduler/jobs(fieldid) or_scheduler/docs(fieldreplication_id). This one string names the_localcheckpoint document on both endpoints:curl -s http://localhost:5984/_scheduler/docs/_replicator/sensor-sync \ | python3 -c "import sys,json;print(json.load(sys.stdin)['info']['replication_id'])" # 6f2a0b7c9d1e4f8a2b3c4d5e6f7a8b9c -
GET the checkpoint on the source. The checkpoint lives at
_local/<replication_id>._localdocuments are never replicated and never appear in the change feed, so you must query each endpoint directly:curl -s http://admin:pass@source:5984/mydb/_local/6f2a0b7c9d1e4f8a2b3c4d5e6f7a8b9c \ | python3 -m json.toolVerify the response contains a
session_id, asource_last_seq, and ahistoryarray. A404here means the checkpoint was deleted or never written — the next run will start from0. -
GET the checkpoint on the target. Repeat against the target database. The document id is identical:
curl -s http://admin:pass@target:5984/mydb/_local/6f2a0b7c9d1e4f8a2b3c4d5e6f7a8b9c \ | python3 -m json.toolConfirm both sides parsed. If either
source_last_seqis a smaller value than the other, you have found the drift; the smaller value is the ceiling on where replication can safely resume. -
Diff the history arrays by
session_id. CouchDB does not simply trust the largersource_last_seq. It walks bothhistoryarrays and finds the newestsession_idrecorded on both sides, then resumes from that session’srecorded_seq. Compute that intersection yourself so you know the exact restart point before you act. The complete example below does this; the key is that the restart sequence is the highestrecorded_seqwhosesession_idappears in both histories, notmin(source_last_seq). -
Decide: accept the re-scan or reset the job. If the gap is small (a few thousand sequences) and the target is healthy, do nothing — the job will re-scan once, write the missing checkpoint forward, and settle. If the gap is enormous, or one side has no common session at all (restart point
0), decide deliberately whether a full re-scan is acceptable or whether you should reset and re-seed. A re-scan is always safe (CouchDB re-checks via_revs_diffand only re-writes genuinely missing revisions) but it costs bandwidth and can resurface conflicts you already resolved.
Complete Working Example
The tool below fetches both _local checkpoints, aligns their history arrays by session_id, reports the true restart sequence, and quantifies the drift so you can judge whether to intervene. It is self-contained and read-only — it never modifies a checkpoint.
import argparse
import sys
import requests
def get_checkpoint(base_url: str, db: str, repl_id: str, session: requests.Session):
"""Fetch the _local/<repl_id> checkpoint doc, or None if it is absent."""
url = f"{base_url}/{db}/_local/{repl_id}"
resp = session.get(url, timeout=30)
if resp.status_code == 404:
return None # no checkpoint -> this side would restart from seq 0
resp.raise_for_status()
return resp.json()
def sessions_by_id(checkpoint: dict) -> dict:
"""Map session_id -> recorded_seq for every entry in a checkpoint history."""
out = {}
for entry in (checkpoint or {}).get("history", []):
# recorded_seq is the source sequence committed at the end of that session
out[entry["session_id"]] = entry.get("recorded_seq")
return out
def restart_seq(source_cp: dict, target_cp: dict):
"""The sequence replication will actually resume from.
It is the recorded_seq of the newest session_id present in BOTH histories.
If nothing matches, replication restarts from the beginning (0).
"""
if not source_cp or not target_cp:
return 0, None
src = sessions_by_id(source_cp)
tgt = sessions_by_id(target_cp)
common = [sid for sid in src if sid in tgt]
if not common:
return 0, None
# history is newest-first; the first shared id is the newest agreed session
for entry in source_cp["history"]:
if entry["session_id"] in tgt:
return entry.get("recorded_seq"), entry["session_id"]
return 0, None
def main() -> int:
ap = argparse.ArgumentParser(description="Diff CouchDB replication checkpoints")
ap.add_argument("--source", required=True, help="e.g. http://admin:pass@src:5984")
ap.add_argument("--target", required=True, help="e.g. http://admin:pass@tgt:5984")
ap.add_argument("--db", required=True)
ap.add_argument("--repl-id", required=True)
args = ap.parse_args()
s = requests.Session()
source_cp = get_checkpoint(args.source, args.db, args.repl_id, s)
target_cp = get_checkpoint(args.target, args.db, args.repl_id, s)
src_seq = (source_cp or {}).get("source_last_seq")
tgt_seq = (target_cp or {}).get("source_last_seq")
print(f"source source_last_seq: {src_seq}")
print(f"target source_last_seq: {tgt_seq}")
resume, sid = restart_seq(source_cp, target_cp)
print(f"agreed restart session: {sid}")
print(f"replication will resume from sequence: {resume}")
# quantify the drift when both sides carry numeric sequences
if isinstance(src_seq, int) and isinstance(resume, int):
gap = src_seq - resume
verdict = "clean" if gap == 0 else ("minor re-scan" if gap < 5000 else "large re-scan")
print(f"drift gap: {gap} sequences ({verdict})")
return 0
if __name__ == "__main__":
sys.exit(main())
Run it as python checkpoint_diff.py --source http://admin:pass@src:5984 --target http://admin:pass@tgt:5984 --db mydb --repl-id 6f2a…. It prints each side’s source_last_seq, the agreed restart session and sequence, and the size of the gap replication must re-scan — the single number that tells you whether to wait it out or reset.
Gotchas & Edge Cases
- A changed filter or selector is not drift — it is a new replication. Editing
filter,selector,query_params, or either endpoint URL changes the computed replication id, so CouchDB looks for a checkpoint document under a different_localid, finds none, and restarts from0. Expect this after any_replicatoredit and schedule it for a quiet window. If you must change a filter without a full re-scan, plan it the way you would a credential change in Handling 409 Conflicts in CouchDB Replication Jobs — carefully and off-peak. - Deleting a
_localcheckpoint forces a full re-scan. Some operators delete_local/<id>“to fix” a stuck job. It works, but it discards all progress and re-reads the entire change feed. Prefer bouncing the job first; delete the checkpoint only as a deliberate re-seed. - A rebuilt target rolls its checkpoint back. Restoring a target from a snapshot, or recreating it, reverts its
_localcheckpoint to whatever the snapshot held while the source still records48210. The histories then agree only at the older sequence — exactly the drift in the diagram. After any target rebuild, expect and budget for one large re-scan. _localdocuments can be compacted away._localdocs have no revision history and are not replicated, but they are stored in the database file; an aggressive external cleanup, aDELETE, or restoring an older file can lose them. Never assume the checkpoint is durable across a manual database file swap.- Sequence values are opaque strings on a clustered node. On a CouchDB node the
source_last_seqlooks like48210-g1AAAA…. Compare the whole opaque token for equality; only compare the numeric prefix for a rough “how far apart” estimate, never for correctness.
Verification & Observability
After you let the job re-scan or reset it, confirm the checkpoints have re-converged. Re-run the diff tool and check that restart session now points at a recent session and the drift gap has collapsed to zero. Then watch the scheduler write the checkpoint forward:
# checkpointed_source_seq should now climb and stay close behind through_seq
watch -n 5 "curl -s http://localhost:5984/_scheduler/jobs | python3 -c \"
import sys, json
for j in json.load(sys.stdin)['jobs']:
i = j.get('info') or {}
print(j['id'], i.get('checkpointed_source_seq'), i.get('through_seq'))
\""
A converged job shows checkpointed_source_seq advancing and surviving a manual restart without dropping — the definitive proof the drift is gone. For a standing dashboard rather than a one-off check, wire the checkpointed_source_seq gap into Monitoring CouchDB Replication Checkpoints via the API and alert when the committed sequence stops moving while changes are still pending.
FAQ
Why does CouchDB restart from the newest common session instead of the target's source_last_seq?
Because source_last_seq alone cannot prove the two sides agree on the path they took to get there. The history array records a chain of sessions, each tagged with a session_id. Replication resumes from the newest session_id that appears in both histories so it never skips a range one side committed but the other did not. If the two histories share no session at all, the only safe restart point is 0.
Is a large re-scan dangerous, or just slow?
It is safe but wasteful. During the re-scan CouchDB still runs _revs_diff for every batch, so it only transfers and writes revisions the target genuinely lacks — it will not duplicate documents. The cost is the bandwidth of re-reading the change feed and the CPU of re-checking revisions, plus the chance of resurfacing conflicts you previously resolved if a loser revision was pruned in between. For a small gap, let it run; for a huge gap, decide deliberately.
Should I ever edit a _local checkpoint document by hand?
Almost never. Hand-writing a higher source_last_seq to “skip” a re-scan tells replication it copied changes it never copied, silently leaving the target missing documents. If you need to move the restart point, delete the checkpoint and accept a full re-scan, or reset and re-seed the target — both are honest about what was actually transferred.
Related
- Recovering from a Stalled Replication Job
- Handling 409 Conflicts in CouchDB Replication Jobs
- Monitoring CouchDB Replication Checkpoints via the API
Part of: Error Handling & Retry Logic