Continuous vs One-Shot Replication: A Trade-off Matrix
You have a stack of CouchDB sync edges to wire up — a nightly warehouse roll-up, a live config push to a hundred field gateways, a battery-powered sensor that wakes for ninety seconds an hour — and setting every one of them to continuous: true is quietly bleeding file descriptors and scheduler slots while a few of them still lag. The continuous flag in a _replicator document is a single boolean, but it commits you to a whole operating profile: propagation latency, open-socket cost, checkpoint and restart behaviour, scheduler pressure, and recovery semantics all move together with it. This page is a decision instrument. It gives you a scenario-to-mode matrix you can read down like a lookup table, a compact pros/cons ledger, and a Python helper that picks the mode from a workload profile and posts the correct document — so you stop defaulting the whole fleet to continuous out of habit. It sits under Continuous vs One-Way Sync in CouchDB _replicator, which explains how each mode runs; this page is about which one to pick, per edge.
Immediate Triage / Prerequisites
Before you decide anything, quantify the two numbers the decision actually turns on: how fresh the target must be, and how much steady-state connection cost you can afford. If a continuous job is already misbehaving, confirm which pressure it is hitting before you reach for a mode change. Count how many jobs the scheduler is running against its ceiling:
# how many replication jobs exist versus what the scheduler will run at once
curl -s http://localhost:5984/_scheduler/jobs | \
python3 -c "import sys,json;d=json.load(sys.stdin);print('total jobs:',d['total_rows'])"
# the ceiling: max_jobs is how many jobs run concurrently before others queue as 'pending'
curl -s http://localhost:5984/_node/_local/_config/replicator/max_jobs
If the job total is brushing max_jobs (default 500) and you see jobs parked in pending, every extra continuous job you add now steals a slot from one that needs to run. That is scheduler pressure, and it is the strongest single argument for demoting freshness-tolerant edges to a scheduled one-shot. Prerequisites for the code below: Python 3.8+, the requests library (pip install requests), admin-equivalent credentials for the _replicator database, and a written-down staleness budget per edge (the maximum seconds of target lag your product can absorb). Keep that budget handy — it is the first column of the matrix.
Step-by-Step Implementation
Work each edge through these steps rather than flipping one global switch. Every step ends in a check you can run.
-
State the staleness budget. Write down the tolerated target lag in seconds for this edge. Sub-minute (dashboards, live config) points toward continuous; minutes-to-hours (nightly consolidation, metered uploads) points toward scheduled one-shot. This single number resolves most rows of the matrix on its own.
-
Price the connection cost. A continuous job holds one open
_changesfeed for its whole life; a one-shot job holds sockets only while it runs, then releases them. On a gateway with a tight descriptor limit, confirm the headroom before committing to an always-open feed:# descriptors CouchDB currently holds open on this node curl -s http://localhost:5984/_node/_local/_stats/couchdb/open_os_files | \ python3 -c "import sys,json;print('open files:',json.load(sys.stdin)['value'])" -
Check scheduler headroom. Continuous jobs occupy a scheduler slot permanently; one-shot jobs occupy one only while transferring and then free it. If
total_rowsfrom the triage step is close tomax_jobs, prefer one-shot for anything that tolerates lag so live edges keep their slots. -
Decide with the matrix. Read the scenario matrix below top-to-bottom, match the row whose freshness and link profile fit this edge, and take its recommended mode. Do not average across edges — bind a mode per edge.
-
Post the matching
_replicatordocument. Writecontinuous: truefor a live edge orcontinuous: false(the default) for a scheduled one, re-reading_revso a redeploy updates in place. Confirm the scheduler accepted it in the mode’s healthy state —runningfor continuous,completedfor a finished one-shot:curl -s http://localhost:5984/_scheduler/docs/_replicator/rep_edge_config_push | \ python3 -c "import sys,json;print(json.load(sys.stdin)['state'])"
The decision matrix
Read down the first two columns to find your edge, then take the mode in the third.
| Scenario | Staleness budget | Link profile | Recommended mode | Why |
|---|---|---|---|---|
| Live central-to-edge config push | Seconds | Stable, unmetered | Continuous | Only an open feed converges within seconds; the feed cost is affordable on a stable link |
| Operator dashboard mirror | Seconds | Stable | Continuous | Freshness is the product; a scheduled sweep would show stale numbers between runs |
| Nightly warehouse consolidation | Hours | Any | One-shot (scheduled) | Deterministic completion and zero idle sockets between the nightly runs |
| Battery / solar edge sensor | Minutes | Intermittent, metered | One-shot (scheduled sweep) | Controls when the radio is used; no feed draining power between wake windows |
| Bulk backfill / provisioning a new target | One-off | Any | One-shot | Runs to the current sequence and exits; nothing to leave running afterwards |
Large fleet, freshness-tolerant, near max_jobs |
Minutes | Any | One-shot (scheduled sweep) | Frees a scheduler slot between runs so live jobs are not starved |
| Bidirectional peer sync, low latency needed | Seconds | Stable | Continuous (one job per direction) | Each direction is its own job; both must stay live to converge quickly |
| Flaky cellular link, freshness matters | Seconds | Metered, unstable | Filtered continuous | Keep the feed but scope it so it survives jitter and moves fewer bytes |
| Compliance snapshot at a fixed time | Point-in-time | Any | One-shot | The snapshot must be a defined sequence, not a moving continuous target |
Pros and cons at a glance
Continuous (continuous: true) |
One-shot (continuous: false, default) |
|
|---|---|---|
| Propagation latency | Seconds | Bounded by the schedule interval |
| Steady-state connection cost | One open _changes feed per job, always |
Sockets only while a run is in flight |
| Scheduler slot use | Occupies a slot for the job’s whole life | Occupies a slot only during a run |
| Restart cost | Resumes from checkpoint automatically | Re-run resumes from checkpoint if use_checkpoints is on |
| Survives node restart | Yes — the _replicator doc re-enqueues the job |
Only if re-triggered by the external schedule |
| Failure recovery | Scheduler retries with backoff, stays live | Next scheduled run retries the transfer |
| Best on constrained edge | No — the feed drains power and descriptors | Yes — quiet between wake windows |
Complete Working Example
The script below is self-contained and runnable. It models an edge as a WorkloadProfile, chooses the mode with the same logic as the matrix, and posts the correct _replicator document idempotently. For freshness-tolerant edges it prints a ready-to-install cron line so the one-shot job is swept on a fixed interval; for live edges it deploys a continuous job and returns.
import sys
from dataclasses import dataclass
import requests
@dataclass
class WorkloadProfile:
"""The three numbers the continuous-vs-one-shot choice turns on."""
edge_id: str
source: str
target: str
staleness_budget_s: int # max tolerated target lag, in seconds
link_is_metered: bool # cellular / solar / pay-per-byte?
scheduler_near_capacity: bool # is job count close to max_jobs?
def choose_mode(p: WorkloadProfile) -> str:
"""Return 'continuous' or 'one_shot' using the matrix logic.
Sub-minute freshness on an unmetered link with slots to spare -> continuous.
Everything freshness-tolerant, metered, or slot-starved -> scheduled one-shot.
"""
wants_live = p.staleness_budget_s < 60
if wants_live and not p.link_is_metered and not p.scheduler_near_capacity:
return "continuous"
return "one_shot"
def build_doc(p: WorkloadProfile, mode: str) -> dict:
"""Assemble the _replicator document body for the chosen mode."""
doc = {
"_id": f"rep_{p.edge_id}",
"source": p.source,
"target": p.target,
"continuous": mode == "continuous",
"user_ctx": {"name": "sync_service", "roles": ["_admin"]},
}
if mode == "continuous":
# keepalive only matters for a live feed; keep it under the path's idle timeout
doc["heartbeat"] = 30000
return doc
def deploy(couch_url: str, doc: dict, session: requests.Session) -> None:
"""Idempotently PUT the _replicator document, carrying _rev forward on update."""
url = f"{couch_url}/_replicator/{doc['_id']}"
existing = session.get(url)
if existing.status_code == 200:
doc = {**doc, "_rev": existing.json()["_rev"]} # update in place, no 409
resp = session.put(url, json=doc)
resp.raise_for_status()
def cron_line(p: WorkloadProfile, couch_url: str) -> str:
"""Emit a crontab entry that sweeps a one-shot job on a fixed interval.
The interval is derived from the staleness budget: never sweep less often
than the budget allows the target to drift.
"""
minutes = max(1, p.staleness_budget_s // 60)
trigger = (
f"curl -s -X POST {couch_url}/_replicator "
f"-H 'Content-Type: application/json' "
f"-d @/etc/couch-sync/{p.edge_id}.json"
)
return f"*/{minutes} * * * * {trigger}"
def provision(couch_url: str, p: WorkloadProfile, session: requests.Session) -> str:
mode = choose_mode(p)
deploy(couch_url, build_doc(p, mode), session)
if mode == "one_shot":
print(f"[{p.edge_id}] scheduled one-shot; install cron:\n {cron_line(p, couch_url)}")
else:
print(f"[{p.edge_id}] continuous job deployed; watch _scheduler/docs for 'running'")
return mode
if __name__ == "__main__":
couch = "http://admin:pass@localhost:5984"
s = requests.Session()
edges = [
WorkloadProfile("config_push", f"{couch}/device_config",
"https://edge.local:5984/device_config",
staleness_budget_s=10, link_is_metered=False,
scheduler_near_capacity=False),
WorkloadProfile("night_rollup", "https://edge.local:5984/telemetry",
f"{couch}/telemetry_warehouse",
staleness_budget_s=21600, link_is_metered=True,
scheduler_near_capacity=False),
]
chosen = {e.edge_id: provision(couch, e, s) for e in edges}
print("modes:", chosen)
sys.exit(0)
Run it against a live database and it deploys the live edge as a continuous job, deploys the roll-up as a one-shot, and prints the crontab line that sweeps the roll-up every six hours. For the asynchronous, many-jobs-at-once version of this provisioning loop, see Automating Continuous Sync with Python Scripts.
Gotchas & Edge Cases
- A one-shot job still checkpoints and can resume. With
use_checkpointson (the default), a one-shot job that is interrupted mid-transfer records its progress; re-running the same document resumes from the last committed sequence instead of replaying the whole database. One-shot does not mean “start from zero every time” — it means “run to the current sequence, then exit.” - Continuous jobs survive a node restart; scheduled one-shots do not restart themselves. Because the continuous job lives as a persistent
_replicatordocument, the scheduler re-instantiates it after a reboot or leader election. A one-shot’s next run only happens because something external — cron, aCronJob, a timer — triggers it again. If you demote an edge to one-shot, you own its schedule. ownerand node placement decide who runs the job. With[replicator] require_valid_userenabled theownerfield records the authenticated user, and the scheduler assigns the job to a node; two provisioning agents writing the same_idrace and one loses with409. Pick a single owner per edge and derive_iddeterministically so a redeploy is idempotent.- Freshness budget in seconds, sweep interval in minutes. Cron’s finest granularity is one minute, so a sub-minute staleness budget cannot be met by a scheduled one-shot at all — that requirement forces continuous. Do not paper over a ten-second budget with a “every minute” sweep; it will miss the target by design.
max_jobsis a hard ceiling, not a suggestion. Pastmax_jobs, extra jobs sit inpendingand never run until a slot frees. A fleet of always-on continuous jobs can pin the ceiling; converting freshness-tolerant edges to scheduled one-shots is often the cheapest way to reclaim slots for the edges that genuinely need to be live.
Verification & Observability
Confirm each edge is behaving as its chosen mode should. For a continuous edge, the health signal is that it stays in running with an advancing checkpoint; for a scheduled one-shot, it is that each run reaches completed inside its window and the target lag never exceeds the budget.
# continuous edge: expect state 'running' and a checkpoint that keeps moving
curl -s http://localhost:5984/_scheduler/jobs | python3 -c "
import sys, json
for j in json.load(sys.stdin)['jobs']:
print(j['doc_id'], j['info'].get('checkpointed_source_seq'), j.get('pid') is not None)"
# scheduler headroom: pending jobs mean you are at or over max_jobs
curl -s http://localhost:5984/_scheduler/jobs | python3 -c "
import sys, json
jobs = json.load(sys.stdin)['jobs']
from collections import Counter
print(Counter(j['info'].get('error') is not None and 'error' or 'ok' for j in jobs))"
A healthy continuous edge shows a checkpointed_source_seq that climbs between polls and a live pid. A healthy one-shot fleet shows no jobs parked in pending for want of a slot. Track three metrics regardless of mode: target lag versus the staleness budget (the SLO), job count versus max_jobs (scheduler headroom), and open feeds versus the descriptor limit (resource headroom). When a continuous job flaps between crashing and running, treat it as retry tuning first — the backoff and status-code mechanics live in Error Handling & Retry Logic.
FAQ
Does a one-shot replication start from scratch every time it runs?
No. With use_checkpoints enabled (the default) a one-shot job records the source sequence it reached, so re-running the same _replicator document resumes from that checkpoint and only transfers what changed since. It transfers everything up to the source’s current update sequence and then exits — “one-shot” describes when it stops, not that it forgets. Disable checkpoints only if you deliberately want a full replay each run.
How many continuous jobs can one node run before they start queuing?
Up to max_jobs (default 500) run concurrently; beyond that, additional jobs sit in pending and are scheduled in fair-share rotation as slots free. Every continuous job holds its slot for life, so a fleet of always-on jobs can pin the ceiling and starve edges that need to be live. Read total_rows from _scheduler/jobs against the replicator/max_jobs config value, and demote freshness-tolerant edges to scheduled one-shots to reclaim slots.
Can a scheduled one-shot ever meet a sub-minute freshness target?
Not reliably. Cron’s finest interval is one minute, and each sweep also needs time to connect, transfer, and reach completed, so the effective floor on target lag is well above a minute. If the staleness budget is in seconds, that requirement forces continuous mode — optionally filtered continuous on a metered link so the always-open feed carries fewer bytes.