skip to content

Why does a filtered CouchDB replication silently stop propagating deletions, and how do you fix the filter?

level: middleimportance: should knowfreq 45%

answer

  1. Deleting strips the document body
  2. Tombstone keeps only _id, _rev, _deleted
  3. Filter tests a field that is gone
  4. Guard the filter on doc._deleted
  5. Filters never retract what already landed

basics

~20 s

Deleting a CouchDB document leaves a tombstone containing only _id, _rev and _deleted, so a filter testing a business field such as doc.type no longer matches and the deletion is skipped. Fix it by returning true whenever doc._deleted is set.

solid answer

~50 s

A filter function is evaluated against each change on the source, and a deletion presents itself as a stripped tombstone: `{"_id": ..., "_rev": ..., "_deleted": true}`. Every field your filter tests is gone, so `return doc.type === 'order'` evaluates false, the deletion is filtered out, and the target keeps the document forever while the source no longer has it. There are two fixes. The usual one is a guard at the top of the filter: `if (doc._deleted) { return true; }` — it lets every deletion through, at the cost of replicating tombstones for documents the target never had. The alternative is to delete by writing the document with `_deleted: true` **and** its discriminating fields still present, so the tombstone carries enough for the filter to match. Selector-based filters have the same trap for the same reason.

code

javascript · 10 lines
javascript
// broken: a tombstone has no `type`, so deletions never replicate
function (doc, req) {
  return doc.type === 'order';
}

// fixed: always let deletions through
function (doc, req) {
  if (doc._deleted) { return true; }
  return doc.type === 'order';
}

go deeper

for a junior

Remember that deleting a CouchDB document leaves a tombstone with almost no fields, and that a replication filter must therefore check doc._deleted explicitly if deletions are to travel.

for a middle

Explain exactly why the filter evaluates false on a tombstone, present both fixes, and note that a filter shapes transfer only, so a document that stops matching is never removed from the target.

for a senior

Debug the live symptom: source and target diverging with a healthy-looking replication, plus the operational fallout of deploying a filter change, which alters the replication id and forces a re-scan.

for a principal

Decide whether filtering belongs in replication at all, versus partitioning data into separate databases, and weigh query-server load from JavaScript filters against selector-based filtering across the whole fleet.

## How filtered replication works A filtered replication passes each row of the source's `_changes` feed through a predicate before deciding whether to replicate that document. The predicate is one of: - a JavaScript function stored under `filters` in a design document, referenced as `filter: "ddoc/name"`; - a Mango `selector` supplied on the replication; - `doc_ids`, a fixed list of document ids; - `_design`, matching only design documents. The filter runs **on the source**, per change. A JavaScript filter is executed by the external query server, which means every change in the database is serialised out to another process and back — a real cost on a busy database. A `selector` is evaluated natively and is much cheaper, which is the usual reason to prefer it when the predicate is expressible as a selector. ## What a deletion looks like This is the crux. When you delete a document with `DELETE /db/{id}?rev=...`, CouchDB does not remove it; it writes a new revision marked deleted. That new revision keeps essentially nothing of the old body. What the change feed hands your filter is: ```json {"_id": "order:912", "_rev": "5-3f21", "_deleted": true} ``` No `type`, no `ownerId`, no `tenant` — whatever your filter tests is simply absent. ## Why the replication goes quiet So a filter such as: ```javascript function (doc, req) { return doc.type === 'order'; } ``` returns `false` for the tombstone. The deletion is dropped, and the target retains the last version it saw. The source and the target now disagree permanently, and nothing reports an error: the replication is healthy, it is doing exactly what you told it to do. This is one of the most common CouchDB support questions and one of the easiest to miss in testing, because tests usually create and update documents and rarely delete them. The same reasoning applies to a selector filter that matches on a business field. It is not a quirk of the JavaScript path; it is a property of what a tombstone contains. ## Fix one: let deletions through ```javascript function (doc, req) { if (doc._deleted) { return true; } return doc.type === 'order'; } ``` This is the standard fix and it is correct, with one side effect worth knowing: the filter now passes deletions for documents that never matched the filter in the first place, so the target accumulates tombstones for documents it never held. That costs a little space in the revision tree until compaction, and it is almost always the right trade. ## Fix two: keep the discriminator on the tombstone Instead of `DELETE`, write the deletion as an ordinary update that sets `_deleted: true` while retaining the fields the filter needs: ```javascript db.put({ _id: doc._id, _rev: doc._rev, type: doc.type, _deleted: true }); ``` CouchDB honours `_deleted` on a write, and the fields you kept survive on the deleted revision, so the filter matches. This keeps the target free of irrelevant tombstones and it is the cleaner option when deletes are routine and the discriminator is stable. It requires every deletion path in the application to cooperate, which is the reason many teams take fix one instead. ## The other half of the trap: filters do not retract A filter decides what a replication **transfers**; it never removes anything from the target. If a document is replicated because `type === 'order'`, and later someone changes `type` to `"draft"`, the new revision no longer matches, so the update is not transferred — and the target keeps the old, now-stale copy indefinitely. The target is not "the set of documents matching the filter"; it is "everything that ever matched, in the state it was in when it last matched". If you need documents to leave the target when they stop matching, you must model that explicitly: delete the document (with a filter that passes deletions), or move it by deleting it from a per-scope database and creating it elsewhere. ## Cost and operational notes Because the filter's source code contributes to the replication's identity, editing a filter function invalidates the recorded checkpoint and the replication restarts from the beginning of the change feed. Deploying the `_deleted` guard to a large existing replication therefore triggers a full re-scan — it will not re-transfer documents the target already has, but it will take time and I/O, so schedule it. Finally, remember that a filter is not an access-control mechanism. It shapes a transfer, not a permission; a client that can read the source database can read everything in it regardless of what any replication filter says.

  • What is the downside of returning true for every deleted document in a filter?
    The replication now carries deletions for documents the target never received, so the target accumulates tombstones for irrelevant ids. They occupy revision-tree space until compaction and they show up as deleted rows in the target's change feed. It is usually a cheap price for correctness, but on a database with heavy delete traffic across many document types it is worth measuring.
  • A document is edited so it no longer matches the replication filter. What happens to the copy already on the target?
    Nothing. The filter suppresses the update, so the target keeps the last version that matched, indefinitely and without any error. A filter controls what is transferred, never what is removed. If documents must leave the target when they stop qualifying, model that as an actual deletion, or move the document between databases rather than mutating a field the filter reads.
  • When would you use a Mango selector instead of a JavaScript filter function?
    Whenever the predicate is expressible as one. A JavaScript filter runs every change through the external query server, which is a per-change serialisation cost on a busy source; a selector is evaluated natively and is substantially cheaper. Selectors are also declarative and easier to review. The deletion trap applies to both, so the tombstone still has to be handled either way.

saying these in an interview costs you the question

  • Assumes CouchDB special-cases tombstones so filters always pass them
  • Thinks a deleted document keeps its fields by default
  • Believes the target drops documents that stop matching the filter
  • Treats a replication filter as an access-control boundary
  • Edits a filter in place without expecting a full re-scan

context