1use std::collections::VecDeque;
13use std::sync::Arc;
14
15use arrow_schema::{DataType, Field, Schema, SchemaRef};
16use tokio::sync::{Notify, Semaphore};
17use uuid::Uuid;
18
19use crate::config::ConnectorState;
20use crate::connector::{ConnectorTaskOwner, ConnectorTaskTracker, SourceBatch};
21use crate::error::ConnectorError;
22
23use super::change_event::{MongoDbChangeEvent, OperationType};
24use super::config::MongoDbSourceConfig;
25use super::metrics::MongoDbCdcMetrics;
26
27const MAX_RESUME_TOKEN_BYTES: usize = 64 * 1024;
28const MONGODB_CHECKPOINT_CONNECTOR: &str = "mongodb-cdc";
29const MONGODB_CHECKPOINT_VERSION: &str = "4";
30const STREAM_IDENTITY_METADATA: &str = "stream_identity_sha256";
31const COLLECTION_UUID_METADATA: &str = "collection_uuid";
32const DEPLOYMENT_IDENTITY_METADATA: &str = "deployment_identity";
33const RESUME_TOKEN_OFFSET: &str = "resume_token";
34const START_AFTER_TOKEN_OFFSET: &str = "start_after_token";
35#[cfg(feature = "mongodb-cdc")]
36const MAX_MONGODB_WIRE_EVENT_BYTES: usize = 16 * 1024 * 1024;
37#[cfg(feature = "mongodb-cdc")]
38const CURSOR_MAX_AWAIT_TIME: std::time::Duration = std::time::Duration::from_secs(1);
39#[cfg(feature = "mongodb-cdc")]
40const READER_STARTUP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30);
41
42mod admission;
43mod buffering;
44mod checkpoint;
45mod decoding;
46mod lifecycle;
47mod reader;
48
49use admission::observe_mongodb_admission;
50#[cfg(test)]
51use admission::{
52 await_mongo_reader_ready, mongodb_identity_command_is_permanent, source_client_options,
53};
54#[cfg(test)]
55use buffering::events_to_record_batch;
56use buffering::{
57 events_to_record_batch_refs, mongo_event_retained_bytes, mongo_high_watermark_retained_bytes,
58 BufferedMongoEvent, BufferedMongoPayload,
59};
60#[cfg(test)]
61use checkpoint::parse_deployment_identity;
62use checkpoint::{
63 canonical_resume_token, mongodb_stream_identity, parse_mongodb_checkpoint,
64 MongoCheckpointPosition, MongoDeploymentIdentity, ParsedMongoCheckpoint,
65};
66use decoding::parse_change_stream_event;
67#[cfg(test)]
68use reader::{
69 acquire_mongo_event_ownership, bootstrap_change_stream_options, change_stream_options,
70 send_event_or_shutdown, verify_mongodb_collection, verify_mongodb_collection_uuid,
71 verify_mongodb_deployment_identity,
72};
73use reader::{run_change_stream_reader, READER_SHUTDOWN_TIMEOUT};
74
75#[must_use]
89pub fn mongodb_cdc_envelope_schema() -> SchemaRef {
90 Arc::new(Schema::new(vec![
91 Field::new("_namespace", DataType::Utf8, false),
92 Field::new("_op", DataType::Utf8, false),
93 Field::new("_document_key", DataType::Utf8, false),
94 Field::new("_cluster_time_s", DataType::UInt32, false),
95 Field::new("_cluster_time_i", DataType::UInt32, false),
96 Field::new(
97 "_wall_time_ms",
98 DataType::Timestamp(arrow_schema::TimeUnit::Millisecond, None),
99 false,
100 ),
101 Field::new("_full_document", DataType::Utf8, true),
102 Field::new("_update_desc", DataType::Utf8, true),
103 Field::new("_resume_token", DataType::Utf8, false),
104 ]))
105}
106
107pub struct MongoDbCdcSource {
120 config: MongoDbSourceConfig,
122
123 state: ConnectorState,
125
126 schema: SchemaRef,
128
129 metrics: Arc<MongoDbCdcMetrics>,
131
132 event_buffer: VecDeque<BufferedMongoEvent>,
134
135 checkpoint_resume_token: Option<String>,
138
139 checkpoint_requires_start_after: bool,
141
142 collection_uuid: Option<Uuid>,
144
145 deployment_identity: Option<MongoDeploymentIdentity>,
147
148 byte_budget: Arc<Semaphore>,
150
151 data_ready: Arc<Notify>,
153
154 #[cfg(feature = "mongodb-cdc")]
156 reader_handle: Option<tokio::task::JoinHandle<()>>,
157
158 #[cfg(feature = "mongodb-cdc")]
160 event_rx: Option<ChangeStreamRx>,
161
162 #[cfg(feature = "mongodb-cdc")]
164 reader_shutdown: Option<tokio::sync::watch::Sender<bool>>,
165
166 #[cfg(feature = "mongodb-cdc")]
168 reader_error: Option<tokio::sync::watch::Receiver<Option<MongoReaderFailure>>>,
169
170 task_owner: ConnectorTaskOwner,
172 task_tracker: ConnectorTaskTracker,
173}
174
175impl Drop for MongoDbCdcSource {
176 fn drop(&mut self) {
177 #[cfg(feature = "mongodb-cdc")]
178 if let Some(shutdown) = self.reader_shutdown.take() {
179 shutdown.send_replace(true);
180 }
181 #[cfg(feature = "mongodb-cdc")]
182 if let Some(handle) = self.reader_handle.take() {
183 reap_mongo_reader(handle, &self.task_owner);
184 }
185 }
186}
187
188#[cfg(feature = "mongodb-cdc")]
189fn reap_mongo_reader(handle: tokio::task::JoinHandle<()>, task_owner: &ConnectorTaskOwner) {
190 let Some(reaper_guard) = task_owner.track() else {
191 tracing::warn!("MongoDB CDC task generation was sealed before reader reaping");
192 return;
193 };
194 let Ok(runtime) = tokio::runtime::Handle::try_current() else {
195 drop(reaper_guard);
198 return;
199 };
200 drop(runtime.spawn(async move {
201 let _reaper_guard = reaper_guard;
202 if let Err(error) = handle.await {
203 tracing::debug!(%error, "MongoDB CDC retired reader task reaped");
204 }
205 }));
206}
207
208#[cfg(feature = "mongodb-cdc")]
210type ChangeStreamTx = crossfire::MAsyncTx<crossfire::mpsc::Array<BufferedMongoEvent>>;
211#[cfg(feature = "mongodb-cdc")]
213type ChangeStreamRx = crossfire::AsyncRx<crossfire::mpsc::Array<BufferedMongoEvent>>;
214
215#[cfg(feature = "mongodb-cdc")]
216#[derive(Debug)]
217struct MongoReaderReady {
218 initial_resume_token: Option<String>,
219 collection_uuid: Uuid,
220 deployment_identity: MongoDeploymentIdentity,
221}
222
223#[cfg(feature = "mongodb-cdc")]
224#[derive(Clone, Debug)]
225enum MongoReaderFailure {
226 Configuration(String),
227 Connection(String),
228 Read(String),
229}
230
231#[cfg(feature = "mongodb-cdc")]
232impl MongoReaderFailure {
233 fn from_connector(error: &ConnectorError) -> Self {
234 match error {
235 ConnectorError::ConfigurationError(message) => Self::Configuration(message.clone()),
236 ConnectorError::ConnectionFailed(message) => Self::Connection(message.clone()),
237 ConnectorError::ReadError(message) => Self::Read(message.clone()),
238 error if error.is_transient() => Self::Read(error.to_string()),
239 error => Self::Configuration(error.to_string()),
240 }
241 }
242
243 fn into_connector(self) -> ConnectorError {
244 match self {
245 Self::Configuration(message) => ConnectorError::ConfigurationError(message),
246 Self::Connection(message) => ConnectorError::ConnectionFailed(message),
247 Self::Read(message) => ConnectorError::ReadError(message),
248 }
249 }
250}
251
252#[cfg(feature = "mongodb-cdc")]
253#[derive(Clone, Copy, Debug, PartialEq, Eq)]
254struct MongoCollectionObservation {
255 collection_uuid: Uuid,
256 post_images_enabled: bool,
257}
258
259#[cfg(feature = "mongodb-cdc")]
260#[derive(Clone, Debug, PartialEq, Eq)]
261struct MongoAdmissionObservation {
262 deployment_identity: MongoDeploymentIdentity,
263 collection: MongoCollectionObservation,
264}
265
266#[cfg(feature = "mongodb-cdc")]
267struct MongoReaderAdmissionGuard {
268 shutdown: Option<tokio::sync::watch::Sender<bool>>,
269}
270
271#[cfg(feature = "mongodb-cdc")]
272impl MongoReaderAdmissionGuard {
273 fn new(shutdown: tokio::sync::watch::Sender<bool>) -> Self {
274 Self {
275 shutdown: Some(shutdown),
276 }
277 }
278
279 fn disarm(&mut self) {
280 self.shutdown = None;
281 }
282}
283
284#[cfg(feature = "mongodb-cdc")]
285impl Drop for MongoReaderAdmissionGuard {
286 fn drop(&mut self) {
287 if let Some(shutdown) = self.shutdown.as_ref() {
288 shutdown.send_replace(true);
289 }
290 }
291}
292
293#[cfg(feature = "mongodb-cdc")]
294#[derive(Clone, Debug, PartialEq)]
295enum MongoResumePosition {
296 ResumeAfter(mongodb::change_stream::event::ResumeToken),
297 StartAfter(mongodb::change_stream::event::ResumeToken),
298}
299
300impl MongoDbCdcSource {
301 #[must_use]
303 pub fn new(config: MongoDbSourceConfig, registry: Option<&prometheus::Registry>) -> Self {
304 let byte_budget = Arc::new(Semaphore::new(config.max_buffered_bytes));
305 let (task_owner, task_tracker) = ConnectorTaskOwner::new();
306 Self {
307 byte_budget,
308 config,
309 state: ConnectorState::Created,
310 schema: mongodb_cdc_envelope_schema(),
311 metrics: Arc::new(MongoDbCdcMetrics::new(registry)),
312 event_buffer: VecDeque::new(),
313 checkpoint_resume_token: None,
314 checkpoint_requires_start_after: false,
315 collection_uuid: None,
316 deployment_identity: None,
317 data_ready: Arc::new(Notify::new()),
318 #[cfg(feature = "mongodb-cdc")]
319 reader_handle: None,
320 #[cfg(feature = "mongodb-cdc")]
321 event_rx: None,
322 #[cfg(feature = "mongodb-cdc")]
323 reader_shutdown: None,
324 #[cfg(feature = "mongodb-cdc")]
325 reader_error: None,
326 task_owner,
327 task_tracker,
328 }
329 }
330
331 #[cfg(test)]
332 fn buffered_events(&self) -> usize {
333 self.event_buffer
334 .iter()
335 .filter(|item| item.event().is_some())
336 .count()
337 }
338
339 #[cfg(test)]
341 fn enqueue_event(&mut self, event: MongoDbChangeEvent) -> Result<(), ConnectorError> {
342 let retained_bytes = mongo_event_retained_bytes(&event)?;
343 let byte_permits = u32::try_from(retained_bytes).map_err(|_| {
344 ConnectorError::ConfigurationError(format!(
345 "MongoDB CDC event exceeds the hard byte bound: event={retained_bytes}, limit={}",
346 self.config.max_buffered_bytes
347 ))
348 })?;
349 if retained_bytes > self.config.max_buffered_bytes {
350 return Err(ConnectorError::ConfigurationError(format!(
351 "MongoDB CDC event exceeds the hard byte bound: event={retained_bytes}, limit={}",
352 self.config.max_buffered_bytes
353 )));
354 }
355 let byte_permit = Arc::clone(&self.byte_budget)
356 .try_acquire_many_owned(byte_permits)
357 .map_err(|_| {
358 ConnectorError::ConfigurationError(format!(
359 "MongoDB CDC buffered bytes reached the hard bound: limit={}",
360 self.config.max_buffered_bytes
361 ))
362 })?;
363 self.metrics.record_event(event.operation_type.as_str());
364 self.event_buffer
365 .push_back(BufferedMongoEvent::new(event, byte_permit));
366 Ok(())
367 }
368
369 #[cfg(test)]
370 fn enqueue_high_watermark(&mut self, token: &str) -> Result<(), ConnectorError> {
371 let token = canonical_resume_token(token)?;
372 let retained_bytes = mongo_high_watermark_retained_bytes(token.capacity())?;
373 if retained_bytes > self.config.max_buffered_bytes {
374 return Err(ConnectorError::ConfigurationError(format!(
375 "MongoDB CDC high watermark exceeds the hard byte bound: item={retained_bytes}, \
376 limit={}",
377 self.config.max_buffered_bytes
378 )));
379 }
380 let permits = u32::try_from(retained_bytes).map_err(|_| {
381 ConnectorError::ConfigurationError("MongoDB CDC high watermark is too large".into())
382 })?;
383 let byte_permit = Arc::clone(&self.byte_budget)
384 .try_acquire_many_owned(permits)
385 .map_err(|_| {
386 ConnectorError::ConfigurationError(
387 "MongoDB CDC high watermark exceeded the byte budget".into(),
388 )
389 })?;
390 self.event_buffer
391 .push_back(BufferedMongoEvent::high_watermark(
392 token,
393 false,
394 byte_permit,
395 ));
396 Ok(())
397 }
398
399 fn drain_to_batch(
406 &mut self,
407 max_records: usize,
408 ) -> Result<Option<SourceBatch>, ConnectorError> {
409 if max_records == 0 || self.event_buffer.is_empty() {
410 return Ok(None);
411 }
412
413 let count = max_records.min(self.event_buffer.len());
414 let count = self
417 .event_buffer
418 .iter()
419 .take(count)
420 .position(|item| {
421 item.event()
422 .is_some_and(|event| event.operation_type == OperationType::Invalidate)
423 })
424 .map_or(count, |index| index + 1);
425 let items: Vec<BufferedMongoEvent> = self.event_buffer.drain(..count).collect();
426 let events: Vec<&MongoDbChangeEvent> =
427 items.iter().filter_map(BufferedMongoEvent::event).collect();
428 let (position_token, requires_start_after) = match items.last() {
429 Some(item) => {
430 let (token, start_after) = match &item.payload {
431 BufferedMongoPayload::Event(event) => (
432 event.resume_token.as_str(),
433 event.operation_type == OperationType::Invalidate,
434 ),
435 BufferedMongoPayload::HighWatermark {
436 token,
437 requires_start_after,
438 } => (token.as_str(), *requires_start_after),
439 };
440 match canonical_resume_token(token) {
441 Ok(token) => (token, start_after),
442 Err(error) => {
443 drop(events);
444 for item in items.into_iter().rev() {
445 self.event_buffer.push_front(item);
446 }
447 return Err(error);
448 }
449 }
450 }
451 None => return Ok(None),
452 };
453
454 if events.is_empty() {
455 self.checkpoint_resume_token = Some(position_token);
456 self.checkpoint_requires_start_after = requires_start_after;
457 return Ok(None);
458 }
459
460 let batch = match events_to_record_batch_refs(&events, &self.schema) {
461 Ok(batch) => batch,
462 Err(error) => {
463 drop(events);
464 for item in items.into_iter().rev() {
465 self.event_buffer.push_front(item);
466 }
467 return Err(error);
468 }
469 };
470 self.metrics.record_batch();
471 self.checkpoint_resume_token = Some(position_token);
472 self.checkpoint_requires_start_after = requires_start_after;
473
474 Ok(Some(SourceBatch::new(batch)))
475 }
476}
477
478#[cfg(test)]
479mod tests;