Skip to content

TransactionInactiveError (unhandled rejection) in runWalBranchPredicted during initial bulk pull replication — IndexedDB WAL storage, v17.2.0 #8497

@jnsflschr

Description

@jnsflschr

Summary

During the initial downstreamResyncOnce pull (first-time bulk sync from server), the premium IndexedDB WAL storage emits a flood of Uncaught (in promise) TransactionInactiveError in the browser console. The operations self-heal via the addTask retry path, so no data is lost, but the rejected promises from abandoned optimistic calls are never caught, causing noisy unhandled rejections. The issue is present in both multiInstance: true and (less frequently) multiInstance: false configurations.


Environment

  • rxdb: 17.2.0
  • rxdb-premium: 17.2.0 (IndexedDB storage with WAL mode)
  • Browser: Chrome (reproducible consistently on first sync)
  • Storage stack: getRxStorageIndexedDB → getRxStorageSharding → getLocalstorageMetaOptimizerRxStorage → wrappedKeyEncryptionWebCryptoStorage →
    wrappedKeyCompressionStorage
  • multiInstance: true (primary trigger) and false (reduced but not eliminated)

Reproduction

  1. Use getRxStorageIndexedDB (premium) with the WAL mode active (default in v17).
  2. Set up replicateRxCollection with pull.batchSize of 100–200.
  3. On first run (empty local database), trigger start() — this calls downstreamResyncOnce which performs a large bulk pull.
  4. Observe console: repeated Uncaught (in promise) TransactionInactiveError from indexeddb-find-by-ids.js and indexeddb-wal.js.

The errors appear at two call sites within persistFromMaster → downstreamResyncOnce:

  • findDocumentsById (checking whether pulled docs already exist locally)
  • getAssumedMasterState in meta-instance.ts (reading replication checkpoint)

Stack Traces (two distinct paths)

Path A — findDocumentsById:

  Uncaught (in promise) TransactionInactiveError: Failed to execute 'get' on 'IDBObjectStore': The transaction is not active.                                    
      at indexedDBFindByIds (indexeddb-find-by-ids.js:1)                                                                     
      at indexeddb-storage-instance.js:1                                                                                                                         
      at indexeddb-transaction.js:1       ← addTask / IndexedDBTransactionScope                                                                                  
      at runWalBranchPredicted (indexeddb-wal.js:1)                                                                                                              
      at indexeddb-transaction.js:1                                                                                                                              
      at rx-storage-instance-sharding.js:1                                                                                                                       
      at rx-storage-helper.ts:822         
      at lockedRun (rx-database.ts:520)                                                                                                                          
      at findDocumentsById (rx-storage-helper.ts:821)
      at downstream.ts:293                                                                                                                                       
      at persistFromMaster (downstream.ts:274)                                                                                                                   
      at downstreamResyncOnce (downstream.ts:199)

Path B — getAssumedMasterState:

  Uncaught (in promise) TransactionInactiveError: Failed to execute 'get' on 'IDBObjectStore': The transaction is not active.                                    
      at indexedDBFindByIds (indexeddb-find-by-ids.js:1)                                                                     
      at indexeddb-storage-instance.js:1                                                                                                                         
      at indexeddb-transaction.js:1                                                                                                                              
      at runWalBranchPredicted (indexeddb-wal.js:1)                                                                                                              
      at indexeddb-transaction.js:1                                                                                                                              
      at getAssumedMasterState (meta-instance.ts:114)                                                                                                            
      at downstream.ts:294                           
      at persistFromMaster (downstream.ts:274)                                                                                                                   
      at downstreamResyncOnce (downstream.ts:199)                                                                                                                

Root Cause Analysis

runWalBranchPredicted — three branches, two bugs

  // indexeddb-wal.js (minified, expanded for clarity)
  export async function runWalBranchPredicted(instance, tx, taskFn) {                                                                                            
    // Branch A                                                                                                                                                  
    if (tx.walDone) return taskFn();                                                                                                                             
                                                                                                                                                                 
    // Branch B — safe sequential path                                                                                                                           
    if (instance.multiInstance === false && instance.internals.isWalDirty === true)
      return await tx.wal, taskFn();                                                                                                                             
                                                                                                                                                                 
    // Branch C — optimistic path                                                                                                                                
    var optimistic = taskFn();                                                                                                                                   
    return await tx.wal ? taskFn() : optimistic;                                                                                                                 
  }

Bug 1 — Branch C (multiInstance: true): abandoned rejected promise

When multiInstance: true, Branch C is always taken regardless of WAL dirty state.

  1. optimistic = taskFn() — queues IDB read requests on the current transaction tx
  2. await tx.wal — suspends; ensureWalPersisted runs, processes WAL writes, auto-commits tx
  3. await tx.wal resolves truthy (WAL had pending data) → taskFn() is called a second time
  4. The second taskFn() uses the same tx object, which is now committed → TransactionInactiveError
  5. The first optimistic promise resolves successfully (its requests were queued before commit), but is silently dropped — never awaited when the truthy branch
    re-runs taskFn()

The addTask wrapper does catch TransactionInactiveError and retries via a new runReadTask call, so the operation eventually succeeds. However, optimistic is a detached rejected promise with no .catch() handler — it surfaces as an unhandled rejection.

Bug 2 — Branch A (multiInstance: false): stale walDone flag after transaction commit

With multiInstance: false, createTx uses isWalDirty at transaction-creation time:

  // createTx (simplified)                                                                                                                                       
  const tx = db.transaction(stores, "readwrite");                                                                                                                
  return (multiInstance === false && isWalDirty === false)
    ? (tx.walDone = true, tx.wal = PROMISE_RESOLVE_FALSE) // no WAL needed                                                                                       
    : tx.wal = ensureWalPersisted(instance, tx);          // start WAL flush                                                                                     

When the background WAL flush (scheduled by writeIndexedDBWal after a 50 ms idle) creates an IndexedDBTransactionScope and begins ensureWalPersisted, a concurrent findDocumentsById call reuses the same openReadonlyTransaction singleton (same tx).

ensureWalPersisted completes and sets tx.walDone = true on that shared tx, then IDB auto-commits it (no more pending requests on the WAL flush side). When the findDocumentsById task's .then() microtask fires and calls runWalBranchPredicted:

  • Branch A: tx.walDone === true → taskFn() is called immediately on the already-committed tx → TransactionInactiveError

The inconsistency is that walDone is a transaction-level flag (set per tx object by ensureWalPersisted) while isWalDirty is an instance-level flag (checked at createTx time and at runWalBranchPredicted time). A late-arriving task can observe walDone = true on a committed transaction that it had no part in creating.


Expected Behaviour

runWalBranchPredicted should guarantee that taskFn() is always called on an active (not yet committed) transaction, regardless of WAL timing or multiInstance setting. The TransactionInactiveError from the second taskFn() in Branch C and from Branch A's stale walDone check should not surface as unhandled rejections.


Actual Behaviour

During any bulk write burst (initial pull replication, large push batch), many unhandled TransactionInactiveError rejections appear in the browser console. Operations self-heal via the existing addTask retry, but each retry creates a new transaction and adds latency. On a full initial sync of several thousand documents the error can fire dozens to hundreds of times.


Proposed Fix Direction

Branch C: Ensure the abandoned optimistic promise has a no-op .catch() to prevent the unhandled rejection, and open a fresh transaction for the second taskFn() call rather than reusing the committed one:

  // instead of:                                                                                                                                                 
  var optimistic = taskFn();
  return await tx.wal ? taskFn() : optimistic;

  // consider:
  var optimistic = taskFn();
  optimistic.catch(() => {}); // prevent unhandled rejection if abandoned                                                                                        
  if (await tx.wal) {
    // tx is now committed — need a new transaction for the re-run                                                                                               
    return runReadTask(instance, originalCallback);                                                                                                              
  }                                                                                                                                                              
  return optimistic;                                                                                                                                             

Branch A: Before calling taskFn() when walDone is true, verify the transaction is still active (or always open a fresh transaction when walDone was set by a concurrent flush rather than by this task's own ensureWalPersisted):

  if (tx.walDone) {
    // Only safe to reuse tx if WE triggered ensureWalPersisted and it just finished.
    // If walDone was set by a concurrent scope, tx may be committed.                                                                                            
    try {                                                                                                                                                        
      return taskFn();                                                                                                                                           
    } catch (e) {                                                                                                                                                
      if (e.name === 'TransactionInactiveError')
        return runReadTask(instance, originalCallback);                                                                                                          
      throw e;
    }                                                                                                                                                            
  }               

Alternatively, IndexedDBTransactionScope could track whether ensureWalPersisted was started by this scope, and refuse to share a tx whose WAL flush was initiated by a different concurrent caller.


Additional Notes

  • The errors are cosmetic from a correctness standpoint — data integrity is preserved by the retry in addTask. However they mask real errors and add unnecessary IDB round-trips.
  • Switching to multiInstance: false reduces Bug 1 to zero but Bug 2 still fires occasionally during high-write bursts.
  • The related fixes Tests to illustrate downstream replication issues #7804 and fix(rx-query): add event guard to count and findByIds in _execOverDatabase #7864 referenced in the v17 changelog address downstream replication and findByIds event guard respectively — it is unclear whether they cover the specific walDone race described in Bug 2 above.
  • Reproducible deterministically on first-run bulk pull with batchSize: 200 and a server returning several hundred documents per collection.

Co-authored by Claude Sonnet 4.6

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions