You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The map/reduce view indexer (pouchdb-abstract-mapreduce) can permanently and
silently drop documents from a view index. If a batch's row write to the
mrview database fails while a later batch's write succeeds, the later batch
stamps _local/lastSeq past the failed batch. The failed batch's documents
then have no view rows, no _local/doc_<id> tracking record, and a checkpoint
that says they were processed — nothing ever revisits them. db.query()
reports success and simply returns fewer rows than it should, indefinitely,
across restarts.
Any transient write failure suffices: fd exhaustion, disk pressure / quota, a
process kill mid-index. We root-caused a production incident (medical records
system, LevelDB adapter) to this: 219 documents invisible in the application
across 134 patient records, with _local/lastSeq sitting above their update
seqs.
Four stacked causes in updateViewInQueue / saveKeyValues:
Write is queued, never awaited — queue.add(processChange(...)) is
fire-and-forget; processBatch fetches the next changes batch while the
previous batch's write is still pending (and possibly failing). The
rejection surfaces as an unhandledRejection, not as a query error.
The read pointer advances in memory regardless — currentSeq = change.seq per change, so the next batch reads since: currentSeq whether
or not anything was persisted.
Failed writes are discarded — TaskQueue.add chains this.promise.catch(function () { /* just recover */ }), so a rejected
batch write is swallowed and the next queued write runs anyway, stamping
its own (higher) lastSeq.
bulkDocs per-row results are unchecked — rows and _local/lastSeq
go into one bulkDocs whose response is ignored, so per-document failures
(e.g. 409s — which do occur in practice, cf. db.compact() runs much slower than auto_compaction #8525 with auto_compaction) are invisible too.
Info
Environment: Node.js (also affects browsers — the code is shared)
Adapter: leveldb (adapter-independent; the defect is in abstract-mapreduce)
Version: reproduced on pouchdb@9.0.0; present in master and at least back
through 7.3.1
Reproduce
Standalone script (attached / below): 30 docs, view batch size 10, reject the
second batch's mrview bulkDocs once (simulating a transient failure), then
reopen the database with storage healthy again.
Observed output on pouchdb@9.0.0:
unhandledRejection (fire-and-forget write): simulated transient write failure
1st query error surfaced: NO (swallowed)
1st query rows: 20 of 30
after reopen rows: 20 of 30 (PERMANENT SILENT HOLE)
Expected: either the query rejects (view stays stale and recovers on the next
query), or it succeeds with all 30 rows. A view must never be holed —
missing rows below its checkpoint.
// PouchDB 9.0.0 — view indexer advances checkpoint past failed writes// One transient batch-write failure => permanent silent view holes.constfs=require('fs')constos=require('os')constpath=require('path')constPouchDB=require('pouchdb')constBATCH=10constDOCS=30constdir=fs.mkdtempSync(path.join(os.tmpdir(),'pouch-ckpt-repro-'))constdbPath=path.join(dir,'db')letmrviewWrites=0letfailAtWrite=0// 0 = disarmed// the fire-and-forget write escapes as unhandledRejectionprocess.on('unhandledRejection',(e)=>{console.log('unhandledRejection (fire-and-forget write):',e.message)})// mrview db is instance-bound; hook its creationfunctionhook(db){constrealReg=db.registerDependentDatabase.bind(db)db.registerDependentDatabase=(...args)=>realReg(...args).then((res)=>{constreal=res.db.bulkDocs.bind(res.db)res.db.bulkDocs=(...a)=>{constdocs=a[0]&&a[0].docs// count only checkpoint-stamping batch writesif(Array.isArray(docs)&&docs.some((d)=>d._id==='_local/lastSeq')){mrviewWrites++if(failAtWrite&&mrviewWrites===failAtWrite){// simulates fd exhaustion / disk pressure / crashreturnPromise.reject(newError('simulated transient write failure'))}}returnreal(...a)}returnres})returndb}asyncfunctionmain(){letdb=hook(newPouchDB(dbPath,{view_update_changes_batch_size: BATCH}))awaitdb.put({_id: '_design/test',views: {byX: {map: 'function (doc) { emit(doc.x) }'}},})constdocs=[]for(leti=0;i<DOCS;i++){docs.push({_id: 'doc-'+String(i).padStart(4,'0'),x: i})}awaitdb.bulkDocs(docs)failAtWrite=2// second batch write failsleterr=nullconstr1=awaitdb.query('test/byX').catch((e)=>{err=e})console.log('1st query error surfaced:',err ? err.message : 'NO (swallowed)')if(r1)console.log('1st query rows:',r1.rows.length,'of',DOCS)failAtWrite=0// storage healthy againawaitdb.close()// = process restartdb=newPouchDB(dbPath,{view_update_changes_batch_size: BATCH})constr2=awaitdb.query('test/byX')console.log('after reopen rows:',r2.rows.length,'of',DOCS,r2.rows.length===DOCS ? '(OK)' : '(PERMANENT SILENT HOLE)')awaitdb.close()fs.rmSync(dir,{recursive: true,force: true})}main().catch((e)=>{console.error('FATAL',e);process.exit(1)})
Suggested fix
We patched our vendored copies (8.0.1 inline + abstract-mapreduce 7.3.1) as
follows, validated by regression tests; happy to turn this into a PR:
Await each batch's write before fetching the next batch (replace the
fire-and-forget queue.add(processChange(...)) with awaiting processChange(...)()), so a failure aborts the run with _local/lastSeq
still at the last persisted batch — the view goes stale, not holed.
Propagate the failure to the query caller (in v8+/master, updateViewInQueue's final catch must rethrow after activeTasks.remove(taskId, error)).
Check bulkDocs per-row results. Note: naively failing on every row error
breaks recovery, because getDocsToPersist's isGenOne shortcut writes
rev-less kv/meta docs when re-indexing an already-indexed generation-1 doc
— producing 409s that today are silently swallowed by the same unchecked bulkDocs. We repair conflicts with a single _rev-refresh retry and fail
the run on anything else.
Cost: indexing loses read-ahead pipelining (writes are on the critical path),
and previously silent errors become visible — which is the point.
Related: #9177 refactors these exact functions (async/await) without changing
this behavior; if that lands first, the fix becomes a few lines simpler.
Issue
The map/reduce view indexer (
pouchdb-abstract-mapreduce) can permanently andsilently drop documents from a view index. If a batch's row write to the
mrview database fails while a later batch's write succeeds, the later batch
stamps
_local/lastSeqpast the failed batch. The failed batch's documentsthen have no view rows, no
_local/doc_<id>tracking record, and a checkpointthat says they were processed — nothing ever revisits them.
db.query()reports success and simply returns fewer rows than it should, indefinitely,
across restarts.
Any transient write failure suffices: fd exhaustion, disk pressure / quota, a
process kill mid-index. We root-caused a production incident (medical records
system, LevelDB adapter) to this: 219 documents invisible in the application
across 134 patient records, with
_local/lastSeqsitting above their updateseqs.
Four stacked causes in
updateViewInQueue/saveKeyValues:queue.add(processChange(...))isfire-and-forget;
processBatchfetches the next changes batch while theprevious batch's write is still pending (and possibly failing). The
rejection surfaces as an
unhandledRejection, not as a query error.currentSeq = change.seqper change, so the next batch readssince: currentSeqwhetheror not anything was persisted.
TaskQueue.addchainsthis.promise.catch(function () { /* just recover */ }), so a rejectedbatch write is swallowed and the next queued write runs anyway, stamping
its own (higher)
lastSeq.bulkDocsper-row results are unchecked — rows and_local/lastSeqgo into one
bulkDocswhose response is ignored, so per-document failures(e.g. 409s — which do occur in practice, cf. db.compact() runs much slower than auto_compaction #8525 with
auto_compaction) are invisible too.Info
through 7.3.1
Reproduce
Standalone script (attached / below): 30 docs, view batch size 10, reject the
second batch's mrview
bulkDocsonce (simulating a transient failure), thenreopen the database with storage healthy again.
Observed output on pouchdb@9.0.0:
Expected: either the query rejects (view stays stale and recovers on the next
query), or it succeeds with all 30 rows. A view must never be holed —
missing rows below its checkpoint.
Suggested fix
We patched our vendored copies (8.0.1 inline + abstract-mapreduce 7.3.1) as
follows, validated by regression tests; happy to turn this into a PR:
fire-and-forget
queue.add(processChange(...))with awaitingprocessChange(...)()), so a failure aborts the run with_local/lastSeqstill at the last persisted batch — the view goes stale, not holed.
updateViewInQueue's finalcatchmust rethrow afteractiveTasks.remove(taskId, error)).bulkDocsper-row results. Note: naively failing on every row errorbreaks recovery, because
getDocsToPersist'sisGenOneshortcut writesrev-less kv/meta docs when re-indexing an already-indexed generation-1 doc
— producing 409s that today are silently swallowed by the same unchecked
bulkDocs. We repair conflicts with a single_rev-refresh retry and failthe run on anything else.
Cost: indexing loses read-ahead pipelining (writes are on the critical path),
and previously silent errors become visible — which is the point.
Related: #9177 refactors these exact functions (async/await) without changing
this behavior; if that lands first, the fix becomes a few lines simpler.