1use std::collections::VecDeque;
16use std::sync::Arc;
17
18use arrow_array::{BinaryArray, UInt32Array};
19use arrow_schema::SchemaRef;
20use mongodb::bson::{RawDocumentBuf, Timestamp};
21use tokio::sync::{Notify, Semaphore};
22use uuid::Uuid;
23
24use crate::config::ConnectorState;
25use crate::connector::{
26 ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch, SourceMutation, SourceRowPositions,
27};
28use crate::error::ConnectorError;
29
30use super::change_event::ChangeOperation;
31use super::config::{MongoDbSourceConfig, SourceOutputMode};
32use super::metrics::MongoDbCdcMetrics;
33
34const MAX_RESUME_TOKEN_BYTES: usize = 64 * 1024;
35const MONGODB_CHECKPOINT_CONNECTOR: &str = "mongodb-cdc";
36const MONGODB_CHECKPOINT_VERSION: &str = "5";
37const STREAM_IDENTITY_METADATA: &str = "stream_identity_sha256";
38const COLLECTION_UUID_METADATA: &str = "collection_uuid";
39const DEPLOYMENT_IDENTITY_METADATA: &str = "deployment_identity";
40const MAX_MONGODB_WIRE_EVENT_BYTES: usize = 16 * 1024 * 1024;
41const CURSOR_MAX_AWAIT_TIME: std::time::Duration = std::time::Duration::from_secs(1);
42const READER_STARTUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
43
44mod admission;
45mod buffering;
46mod checkpoint;
47mod decoding;
48mod document;
49mod history;
50mod lifecycle;
51mod reader;
52mod schema_resolution;
53
54pub use history::{mongodb_history_schema, MONGODB_HISTORY_VERSION, SNAPSHOT_OPERATION};
55
56use admission::observe_mongodb_admission;
57use buffering::{BufferedMongoEvent, BufferedMongoPayload};
58use checkpoint::{
59 mongodb_stream_identity, parse_mongodb_checkpoint, EmittedPosition, MongoCheckpointPosition,
60 MongoDeploymentIdentity, ParsedMongoCheckpoint, SnapshotCut, StreamPosition,
61};
62use decoding::decode_change;
63use document::{DocumentProjection, DocumentRow};
64use history::{history_batch, id_key, HistoryIdentity};
65use reader::{run_change_stream_reader, MongoResumePosition, ReaderStart, READER_SHUTDOWN_TIMEOUT};
66
67pub struct MongoDbCdcSource {
72 config: MongoDbSourceConfig,
73 state: ConnectorState,
74 schema: SchemaRef,
75 metrics: Arc<MongoDbCdcMetrics>,
76 event_buffer: VecDeque<BufferedMongoEvent>,
77
78 emitted: Option<EmittedPosition>,
81
82 collection_uuid: Option<Uuid>,
84
85 deployment_identity: Option<MongoDeploymentIdentity>,
87
88 projection: Option<DocumentProjection>,
90
91 snapshot_committed: Option<(Timestamp, tokio::sync::watch::Sender<bool>)>,
93
94 byte_budget: Arc<Semaphore>,
96
97 data_ready: Arc<Notify>,
99
100 reader_handle: Option<tokio::task::JoinHandle<()>>,
101 event_rx: Option<ChangeStreamRx>,
102 reader_shutdown: Option<tokio::sync::watch::Sender<bool>>,
103
104 reader_error: Option<tokio::sync::watch::Receiver<Option<MongoReaderFailure>>>,
106
107 task_owner: ConnectorTaskOwner,
109 task_tracker: ConnectorTaskTracker,
110}
111
112impl Drop for MongoDbCdcSource {
113 fn drop(&mut self) {
114 if let Some(shutdown) = self.reader_shutdown.take() {
115 shutdown.send_replace(true);
116 }
117 if let Some(handle) = self.reader_handle.take() {
118 reap_mongo_reader(handle, &self.task_owner);
119 }
120 }
121}
122
123fn reap_mongo_reader(handle: tokio::task::JoinHandle<()>, task_owner: &ConnectorTaskOwner) {
124 let Some(reaper_guard) = task_owner.track() else {
125 tracing::warn!("MongoDB CDC task generation was sealed before reader reaping");
126 return;
127 };
128 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
129 drop(reaper_guard);
132 return;
133 };
134 drop(runtime.spawn(async move {
135 let _reaper_guard = reaper_guard;
136 if let Err(error) = handle.await {
137 tracing::debug!(%error, "MongoDB CDC retired reader task reaped");
138 }
139 }));
140}
141
142type ChangeStreamTx = crossfire::MAsyncTx<crossfire::mpsc::Array<BufferedMongoEvent>>;
144type ChangeStreamRx = crossfire::AsyncRx<crossfire::mpsc::Array<BufferedMongoEvent>>;
146
147#[derive(Debug)]
148struct MongoReaderReady {
149 initial_position: Option<MongoCheckpointPosition>,
151 collection_uuid: Uuid,
152 deployment_identity: MongoDeploymentIdentity,
153}
154
155#[derive(Clone, Debug)]
156enum MongoReaderFailure {
157 Configuration(String),
158 Connection(String),
159 Read(String),
160}
161
162impl MongoReaderFailure {
163 fn from_connector(error: &ConnectorError) -> Self {
164 match error {
165 ConnectorError::ConfigurationError(message) => Self::Configuration(message.clone()),
166 ConnectorError::ConnectionFailed(message) => Self::Connection(message.clone()),
167 ConnectorError::ReadError(message) => Self::Read(message.clone()),
168 error if error.is_transient() => Self::Read(error.to_string()),
169 error => Self::Configuration(error.to_string()),
170 }
171 }
172
173 fn into_connector(self) -> ConnectorError {
174 match self {
175 Self::Configuration(message) => ConnectorError::ConfigurationError(message),
176 Self::Connection(message) => ConnectorError::ConnectionFailed(message),
177 Self::Read(message) => ConnectorError::ReadError(message),
178 }
179 }
180}
181
182#[derive(Clone, Copy, Debug, PartialEq, Eq)]
183struct MongoCollectionObservation {
184 collection_uuid: Uuid,
185 post_images_enabled: bool,
186}
187
188#[derive(Clone, Debug, PartialEq, Eq)]
189struct MongoAdmissionObservation {
190 deployment_identity: MongoDeploymentIdentity,
191 collection: MongoCollectionObservation,
192}
193
194struct MongoReaderAdmissionGuard {
195 shutdown: Option<tokio::sync::watch::Sender<bool>>,
196}
197
198impl MongoReaderAdmissionGuard {
199 fn new(shutdown: tokio::sync::watch::Sender<bool>) -> Self {
200 Self {
201 shutdown: Some(shutdown),
202 }
203 }
204
205 fn disarm(&mut self) {
206 self.shutdown = None;
207 }
208}
209
210impl Drop for MongoReaderAdmissionGuard {
211 fn drop(&mut self) {
212 if let Some(shutdown) = self.shutdown.as_ref() {
213 shutdown.send_replace(true);
214 }
215 }
216}
217
218struct PendingBatch {
220 items: Vec<BufferedMongoEvent>,
221 next: EmittedPosition,
222 first_sequence: u64,
223 snapshot_at: Option<Timestamp>,
225}
226
227impl MongoDbCdcSource {
228 #[must_use]
230 pub fn new(config: MongoDbSourceConfig, registry: Option<&prometheus::Registry>) -> Self {
231 let byte_budget = Arc::new(Semaphore::new(config.max_buffered_bytes));
232 let (task_owner, task_tracker) = ConnectorTaskOwner::new();
233 Self {
234 byte_budget,
235 config,
236 state: ConnectorState::Created,
237 schema: mongodb_history_schema(),
238 metrics: Arc::new(MongoDbCdcMetrics::new(registry)),
239 event_buffer: VecDeque::new(),
240 emitted: None,
241 collection_uuid: None,
242 deployment_identity: None,
243 projection: None,
244 snapshot_committed: None,
245 data_ready: Arc::new(Notify::new()),
246 reader_handle: None,
247 event_rx: None,
248 reader_shutdown: None,
249 reader_error: None,
250 task_owner,
251 task_tracker,
252 }
253 }
254
255 fn advance(
257 &self,
258 position: &mut EmittedPosition,
259 item: &BufferedMongoEvent,
260 ) -> Result<Option<Timestamp>, ConnectorError> {
261 let snapshot_at = match &position.position {
262 MongoCheckpointPosition::Snapshot(cut) => Some(cut.at),
263 MongoCheckpointPosition::Stream(_) => None,
264 };
265 match &item.payload {
266 BufferedMongoPayload::Change(record) => {
267 if self.produces_row(record.operation)? {
268 position.next_sequence = next_sequence(position.next_sequence)?;
269 }
270 position.position = MongoCheckpointPosition::Stream(
271 if record.operation == ChangeOperation::Invalidate {
272 StreamPosition::StartAfter(record.token.clone())
273 } else {
274 StreamPosition::ResumeAfter(record.token.clone())
275 },
276 );
277 }
278 BufferedMongoPayload::Snapshot(record) => {
279 let at = snapshot_at.ok_or_else(|| {
280 ConnectorError::Internal("snapshot row outside a snapshot scan".into())
281 })?;
282 position.next_sequence = next_sequence(position.next_sequence)?;
283 position.position = MongoCheckpointPosition::Snapshot(SnapshotCut {
284 at,
285 after_key: Some(record.key.clone()),
286 });
287 }
288 BufferedMongoPayload::HighWatermark {
289 token,
290 requires_start_after,
291 } => {
292 position.position = MongoCheckpointPosition::Stream(if *requires_start_after {
293 StreamPosition::StartAfter(token.clone())
294 } else {
295 StreamPosition::ResumeAfter(token.clone())
296 });
297 }
298 BufferedMongoPayload::SnapshotComplete => {
299 let at = snapshot_at.ok_or_else(|| {
300 ConnectorError::Internal("snapshot completion outside a snapshot scan".into())
301 })?;
302 position.position = MongoCheckpointPosition::Stream(StreamPosition::StartAt(at));
303 }
304 }
305 Ok(snapshot_at)
306 }
307
308 fn produces_row(&self, operation: ChangeOperation) -> Result<bool, ConnectorError> {
310 match (self.config.output_mode, operation) {
311 (SourceOutputMode::History, _)
312 | (
313 SourceOutputMode::Document,
314 ChangeOperation::Insert
315 | ChangeOperation::Update
316 | ChangeOperation::Replace
317 | ChangeOperation::Delete,
318 ) => Ok(true),
319 (SourceOutputMode::Document, ChangeOperation::Metadata) => Ok(false),
320 (
321 SourceOutputMode::Document,
322 ChangeOperation::Invalidate
323 | ChangeOperation::Drop
324 | ChangeOperation::Rename
325 | ChangeOperation::DropDatabase
326 | ChangeOperation::Unknown,
327 ) => Err(ConnectorError::ConfigurationError(format!(
328 "MongoDB CDC document replication of {}.{} stopped at a {operation:?} event; \
329 the mirror is not changed. Recreating, renaming, or dropping the source \
330 collection requires rebuilding the targets from a new snapshot",
331 self.config.database, self.config.collection
332 ))),
333 }
334 }
335
336 fn plan_batch(&mut self, max_records: usize) -> Result<Option<PendingBatch>, ConnectorError> {
338 let Some(start) = self.emitted.clone() else {
339 return Err(ConnectorError::InvalidState {
340 expected: "admitted position".into(),
341 actual: "no emitted position".into(),
342 });
343 };
344 let mut next = start.clone();
345 let mut snapshot_at = None;
346 let mut count = 0;
347 for item in self.event_buffer.iter().take(max_records) {
348 let at = self.advance(&mut next, item)?;
349 snapshot_at = snapshot_at.or(at);
350 count += 1;
351 if item.is_invalidate() {
354 break;
355 }
356 }
357 if count == 0 {
358 return Ok(None);
359 }
360 Ok(Some(PendingBatch {
361 items: self.event_buffer.drain(..count).collect(),
362 next,
363 first_sequence: start.next_sequence,
364 snapshot_at,
365 }))
366 }
367
368 fn drain_to_batch(
374 &mut self,
375 max_records: usize,
376 ) -> Result<Option<SourceBatch>, ConnectorError> {
377 if max_records == 0 || self.event_buffer.is_empty() {
378 return Ok(None);
379 }
380 let Some(pending) = self.plan_batch(max_records)? else {
381 return Ok(None);
382 };
383 let result = if pending.items.iter().any(BufferedMongoEvent::is_record) {
384 self.build_batch(&pending).map(Some)
385 } else {
386 Ok(None)
387 };
388 match result {
389 Ok(batch) => {
390 for item in &pending.items {
391 if let BufferedMongoPayload::Change(record) = &item.payload {
392 self.metrics.record_event(record.operation);
393 if self.config.output_mode == SourceOutputMode::Document
394 && record.operation == ChangeOperation::Metadata
395 {
396 self.metrics.metadata_events_skipped.inc();
397 }
398 }
399 }
400 if batch.is_some() {
401 self.metrics.record_batch();
402 }
403 self.metrics
404 .snapshot_in_progress
405 .set(i64::from(pending.next.in_snapshot()));
406 self.emitted = Some(pending.next);
407 Ok(batch)
408 }
409 Err(error) => {
410 for item in pending.items.into_iter().rev() {
411 self.event_buffer.push_front(item);
412 }
413 Err(error)
414 }
415 }
416 }
417
418 fn build_batch(&self, pending: &PendingBatch) -> Result<SourceBatch, ConnectorError> {
419 let (Some(collection_uuid), Some(deployment)) =
420 (self.collection_uuid, self.deployment_identity.as_ref())
421 else {
422 return Err(ConnectorError::InvalidState {
423 expected: "admitted collection identity".into(),
424 actual: "unbound source".into(),
425 });
426 };
427 match &self.projection {
428 None => {
429 let deployment = deployment.encode();
430 let identity = HistoryIdentity {
431 deployment: &deployment,
432 collection_uuid,
433 database: &self.config.database,
434 collection: &self.config.collection,
435 };
436 let records = pending
437 .items
438 .iter()
439 .filter(|item| item.is_record())
440 .map(|item| &item.payload)
441 .collect::<Vec<_>>();
442 let batch = history_batch(
443 records.into_iter(),
444 &self.schema,
445 &identity,
446 pending.snapshot_at,
447 )?;
448 Ok(SourceBatch::new(batch))
449 }
450 Some(projection) => {
451 self.document_batch(projection, pending, collection_uuid.as_bytes())
452 }
453 }
454 }
455
456 fn document_batch(
457 &self,
458 projection: &DocumentProjection,
459 pending: &PendingBatch,
460 partition: &[u8],
461 ) -> Result<SourceBatch, ConnectorError> {
462 let snapshot_keys = pending
463 .items
464 .iter()
465 .filter_map(|item| match &item.payload {
466 BufferedMongoPayload::Snapshot(record) => Some(record),
467 _ => None,
468 })
469 .map(|record| {
470 record
471 .raw
472 .get("_id")
473 .ok()
474 .flatten()
475 .map(id_key)
476 .ok_or_else(|| {
477 ConnectorError::SchemaMismatch("snapshot document has no _id".into())
478 })
479 })
480 .collect::<Result<Vec<RawDocumentBuf>, _>>()?;
481 let mut snapshot_keys = snapshot_keys.iter();
482 let mut rows = Vec::with_capacity(pending.items.len());
483 for item in &pending.items {
484 match &item.payload {
485 BufferedMongoPayload::Change(record) => {
486 if let Some(row) = self.change_row(record)? {
487 rows.push(row);
488 }
489 }
490 BufferedMongoPayload::Snapshot(record) => rows.push(DocumentRow::Put {
491 key: snapshot_keys.next().ok_or_else(|| {
492 ConnectorError::Internal("snapshot key bookkeeping".into())
493 })?,
494 document: &record.raw,
495 }),
496 BufferedMongoPayload::HighWatermark { .. }
497 | BufferedMongoPayload::SnapshotComplete => {}
498 }
499 }
500 let (records, mutations) = projection.build(&rows)?;
501 let positions = row_positions(partition, pending.first_sequence, rows.len())?;
502 let batch = SourceBatch::positioned(records, positions)?;
503 if mutations.contains(&SourceMutation::Tombstone) {
504 batch.with_mutations(mutations)
505 } else {
506 Ok(batch)
507 }
508 }
509
510 fn change_row<'a>(
511 &self,
512 record: &'a buffering::ChangeRecord,
513 ) -> Result<Option<DocumentRow<'a>>, ConnectorError> {
514 if !self.produces_row(record.operation)? {
515 return Ok(None);
516 }
517 let change = decode_change(&record.raw)?;
518 let key = change.document_key.ok_or_else(|| {
519 ConnectorError::SchemaMismatch(format!(
520 "MongoDB {} event omitted its document key",
521 change.operation
522 ))
523 })?;
524 if record.operation == ChangeOperation::Delete {
525 return Ok(Some(DocumentRow::Tombstone { key }));
526 }
527 let document = change.full_document.ok_or_else(|| {
528 ConnectorError::SchemaMismatch(format!(
529 "MongoDB {} event for {}.{} has no full document; document replication never \
530 treats a missing image as a delete",
531 change.operation, self.config.database, self.config.collection
532 ))
533 })?;
534 Ok(Some(DocumentRow::Put { key, document }))
535 }
536}
537
538fn next_sequence(sequence: u64) -> Result<u64, ConnectorError> {
539 sequence
540 .checked_add(1)
541 .ok_or_else(|| ConnectorError::Internal("MongoDB CDC row sequence exhausted u64".into()))
542}
543
544fn row_positions(
547 partition: &[u8],
548 first_sequence: u64,
549 rows: usize,
550) -> Result<SourceRowPositions, ConnectorError> {
551 let mut order_keys = Vec::with_capacity(rows);
552 let mut sequence = first_sequence;
553 for _ in 0..rows {
554 order_keys.push(sequence.to_be_bytes());
555 sequence = next_sequence(sequence)?;
556 }
557 SourceRowPositions::try_new(
558 BinaryArray::from_iter_values(std::iter::repeat_n(partition, rows)),
559 BinaryArray::from_iter_values(order_keys.iter()),
560 UInt32Array::from(vec![0; rows]),
561 )
562}
563
564#[cfg(test)]
565mod tests;