Race Condition: Initial Fetch vs Change Stream Events
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 18
- Forks
- 1
- PR merge metrics
- No merged PRs in 30d
Description
Problem Summary
There is a race condition window between when the initial MongoDB find() query returns results and when the Change Stream subscription begins. Events that occur during this window can be missed, leading to inconsistent client state.
Current Implementation Flow
┌─────────────────────────────────────────────────────────────────┐
│ Cursor::start() │
├─────────────────────────────────────────────────────────────────┤
│ 1. fetch() - runs find() query │
│ └─> Returns snapshot of documents at time T1 │
│ │
│ ⚠️ GAP: Events between T1 and T2 are NOT captured ⚠️ │
│ │
│ 2. watch() - starts Change Stream │
│ └─> Begins capturing events from time T2 │
└─────────────────────────────────────────────────────────────────┘
Relevant code: src/cursor/mod.rs lines 52-82
// 1. Run initial query (blocking)
self.fetcher
.write()
.await
.fetch(&mergeboxes)
.await
.context("Cursor::start")?;
// 2. Start background task (Change Stream starts AFTER fetch completes)
let task = async move {
let receiver_or_interval = fetcher.read().await.watch().await;
// ...
};
Affected Scenarios
| Event Type | Impact |
|---|---|
| Insert | Document is missed entirely until next refetch (polling) |
| Update | Client receives stale data until Change Stream delivers a subsequent update |
| Delete | Client receives a "ghost" document that no longer exists in the database |
Current Mitigations
1. MergeBox Idempotency
The MergeBox handles duplicate operations gracefully:
- Duplicate inserts become updates (only changed fields sent)
- No message sent if nothing actually changed
However: This only helps with duplicates, not missed events.
2. Polling Fallback
When Change Streams are unavailable, the system polls every 10 seconds (configurable via polling_interval_ms). This provides eventual consistency but with a delay.
How Meteor (JS) Handles This
The original Meteor DDP server solves this elegantly through a phased approach with queuing:
Phased State Machine
-
QUERYING Phase: During initial load:
- The initial query runs against the database
- Oplog entries that arrive are NOT immediately applied
- Instead, document IDs are stored in
_needToFetchfor later processing
// From oplog_observe_driver.js _handleOplogEntryQuerying: function (op) { var self = this; Meteor._noYieldsAllowed(function () { self._needToFetch.set(idForOp(op), op); }); }, -
Multiplexer Queue: The
ObserveMultiplexeruses a_SynchronousQueueensuring callbacks are processed in order. It also enforces that onlyaddedevents occur during initial load:// From observe_multiplex.js if (!self._ready() && (callbackName !== 'added' && callbackName !== 'addedBefore')) { throw new Error("Got " + callbackName + " during initial adds"); } -
Transition to STEADY: After the initial query completes:
_multiplexer.ready()is called_doneQuerying()processes all pending oplog entries stored in_needToFetch- The driver enters
PHASE.STEADYwhere changes are applied immediately
Result
Documents inserted/updated/deleted during the initial query are:
- Tracked in
_needToFetchif oplog-driven - Processed after the initial snapshot is sent and
ready()is called - Merged with the initial results to provide a consistent view
This ensures the client gets a consistent initial snapshot followed by all changes that happened during the load, in proper order. No changes are lost or duplicated.
Proposed Solutions for ddp-router
Option A: Phased Approach (matches Meteor's design)
Implement a similar QUERYING/STEADY phase model:
┌─────────────────────────────────────────────────────────────────┐
│ 1. watch() - Start Change Stream immediately │
│ └─> Buffer events in a queue (QUERYING phase) │
│ │
│ 2. fetch() - Run find() query │
│ └─> Send initial documents to client │
│ │
│ 3. Transition to STEADY │
│ └─> Process buffered events │
│ └─> Apply future events immediately │
└─────────────────────────────────────────────────────────────────┘
Pros:
- No events missed - matches proven Meteor behavior
- MergeBox already handles duplicate inserts/updates (idempotent)
- Conceptually aligned with original Meteor implementation
Cons:
- May send duplicate data to client (added → changed with same data)
- Memory usage for event buffer during slow initial fetches
- Requires adding phase state to
CursorFetcher
Option B: Use Change Stream Resume Token
- Get current resume token before fetch
- Perform fetch
- Start Change Stream from saved resume token
Pros:
- No duplicates
- No event buffer needed
Cons:
- Resume tokens expire (default ~1 hour on Atlas)
- More complex implementation
- Requires storing resume token state
Option C: Use Read Concern + Timestamp
- Get current cluster time
- Perform fetch with
readConcern: { level: "majority" } - Start Change Stream with
startAtOperationTime
Pros:
- Precise synchronization
- No duplicates
Cons:
- Requires replica set (standalone MongoDB does not support Change Streams anyway)
- Read concern "majority" may have minor performance implications
MongoDB Version Considerations
Opportunity: Simplify by Requiring MongoDB 7.0+
All MongoDB versions 6.0 and older are now End of Life (EOL):
| Version | EOL Date | Status |
|---|---|---|
| 6.0 | July 2025 | ❌ EOL |
| 7.0 | August 2027 | ✅ Current LTS |
| 8.0 | October 2029 | ✅ Current Release |
If we require MongoDB 7.0+, all the features we need are well-established and battle-tested:
- Change Streams: Available since 3.6
startAtOperationTime: Available since 4.0readConcern: "majority": Available since 3.2FullDocumentType::Required: Available since 6.0
This would allow us to:
- Use Option C (
startAtOperationTime) without version compatibility concerns - Use
FullDocumentType::Requiredfor the update race condition fix - Simplify documentation and testing (single target platform)
Question for team: Should we require MongoDB 7.0+ given that all older versions are EOL?
Impact Assessment
- Severity: Medium
- Frequency: Rare (only during subscription setup + concurrent writes)
- User Impact: Temporary inconsistent state; self-corrects on next update or refetch
Questions for Team Discussion
- Which solution approach should we pursue?
- Is the polling fallback interval (10s) acceptable for the gap window?
- Should we add a "forced refetch" after Change Stream starts as a simple fix?
Related Issues
FullDocumentType::Required (MongoDB 6.0+)
There's a related race condition in src/watcher.rs (lines 75-77):
// TODO: Ideally we would use `Required` here, but it has to be
// enabled on the database level. It should be configurable.
.full_document(Some(FullDocumentType::UpdateLookup))
Current behavior (UpdateLookup): On update events, MongoDB performs a separate lookup query to fetch the full document. This introduces a race condition where the document might change between the update event and the lookup.
Better option (Required): Available since MongoDB 6.0, this mode guarantees the full document is included directly in the change event - no race condition. Requires enabling changeStreamPreAndPostImages on the collection.
If we decide to require MongoDB 7.0+ (see MongoDB Version Considerations above), we could:
- Make
FullDocumentTypeconfigurable (as the TODO suggests) - Default to
RequiredwhenchangeStreamPreAndPostImagesis enabled - Document the collection-level setting requirement
Related Files
src/cursor/mod.rs- Cursor start/stop logicsrc/cursor/fetcher.rs- Initial fetch and event processingsrc/watcher.rs- Change Stream management (includes FullDocumentType TODO)src/mergebox.rs- Document merging and deduplication
References
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with src/cursor/mod.rs lines 52-82, then read src/cursor/fetcher.rs, src/watcher.rs, and src/mergebox.rs to trace initial fetching and Change Stream processing. The issue presents three possible approaches but does not select one or define acceptance criteria, so the intended behavior and done state need team clarification first.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- mongodb, rust
- Domain
- backend, databases
- Issue type
- Bug
- Difficulty
- 5/5
- Estimated time
- Over a week
- Activity status
- Stale
- Clarity
- Needs clarification
- Newbie friendliness
- 25/100