use super::*;
pub struct MongoDbIdReader {
collection: Collection<Document>,
db: Database,
checkpoint: Option<Arc<dyn crate::checkpoint::CheckpointStore>>,
cursor_id: Option<String>,
last_id: Arc<Mutex<Option<Bson>>>,
receive_query: Option<Document>,
}
impl MongoDbIdReader {
pub async fn new(config: &MongoDbConfig) -> anyhow::Result<Self> {
let collection_name = config
.collection
.as_deref()
.ok_or_else(|| anyhow!("Collection name is required for MongoDB id-cursor reader"))?;
let client = create_client(config).await?;
let db = client.database(&config.database);
let collection: Collection<Document> = db.collection(collection_name);
let receive_query = if let Some(q) = &config.receive_query {
let doc: Document = serde_json::from_str(q)
.context("Failed to parse 'receive_query' from configuration as a JSON document")?;
Some(doc)
} else {
None
};
let checkpoint: Option<Arc<dyn crate::checkpoint::CheckpointStore>> = if let Some(cid) =
&config.cursor_id
{
use crate::checkpoint::CheckpointBackend;
let backend = match &config.checkpoint_store {
None => CheckpointBackend::Source {
name: crate::checkpoint::default_meta_name(collection_name),
},
Some(spec) => crate::checkpoint::parse_checkpoint_store(spec)?,
};
let store: Arc<dyn crate::checkpoint::CheckpointStore> = match backend {
CheckpointBackend::Source { name } => Arc::new(MongoCollectionCheckpointStore {
meta: db.collection::<Document>(&name),
doc_id: crate::checkpoint::checkpoint_key(collection_name, cid),
}),
external => {
crate::checkpoint::build_external_store(external, collection_name, cid).await?
}
};
Some(store)
} else {
warn!(
collection = %collection_name,
"MongoDB resumable reader has no cursor_id; resume is disabled and every restart re-copies from the beginning. Set cursor_id to persist progress."
);
None
};
let last_id = match &checkpoint {
Some(cp) => cp.load().await?.and_then(|s| {
let decoded = decode_id(&s);
if decoded.is_none() {
warn!(value = %s, "Ignoring unparseable mongo id cursor; starting from beginning");
}
decoded
}),
None => None,
};
info!(collection = %collection_name, cursor_id = ?config.cursor_id, has_checkpoint = %last_id.is_some(), "MongoDB id-cursor reader initialized");
Ok(Self {
collection,
db,
checkpoint,
cursor_id: config.cursor_id.clone(),
last_id: Arc::new(Mutex::new(last_id)),
receive_query,
})
}
}
#[async_trait]
impl MessageConsumer for MongoDbIdReader {
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
let resume_from = self.last_id.lock().unwrap().clone();
let mut messages = Vec::new();
let mut ids: Vec<Bson> = Vec::new();
loop {
let last = self.last_id.lock().unwrap().clone();
let mut filter = match &last {
Some(v) => doc! { "_id": { "$gt": v.clone() } },
None => doc! {},
};
if let Some(extra) = &self.receive_query {
filter = if filter.is_empty() {
extra.clone()
} else {
doc! { "$and": [filter, extra.clone()] }
};
}
let find_options = FindOptions::builder()
.sort(doc! { "_id": 1 })
.limit(max_messages as i64)
.build();
let mut cursor = self
.collection
.find(filter)
.with_options(find_options)
.await
.map_err(|e| ConsumerError::Connection(e.into()))?;
let mut docs_in_page = 0usize;
while let Some(result) = cursor.next().await {
let doc = result.map_err(|e| ConsumerError::Connection(e.into()))?;
docs_in_page += 1;
let Some(id) = doc.get("_id").cloned() else {
warn!("MongoDB document without an `_id`; skipping");
continue;
};
match parse_mongodb_document(doc) {
Ok(msg) => {
messages.push(msg);
ids.push(id.clone());
}
Err(e) => warn!(error = %e, "Skipping unparseable MongoDB document"),
}
*self.last_id.lock().unwrap() = Some(id);
}
if !messages.is_empty() || docs_in_page == 0 {
break;
}
}
if messages.is_empty() {
return Ok(ReceivedBatch {
messages: Vec::new(),
commit: Box::new(|_| Box::pin(async { Ok(()) })),
});
}
let checkpoint = self.checkpoint.clone();
let last_id = self.last_id.clone();
let commit = Box::new(move |dispositions: Vec<MessageDisposition>| {
Box::pin(async move {
let mut acked = 0usize;
for disp in dispositions.iter().take(ids.len()) {
if matches!(disp, MessageDisposition::Ack | MessageDisposition::Reply(_)) {
acked += 1;
} else {
break;
}
}
let boundary: Option<Bson> = if acked == 0 {
resume_from
} else {
Some(ids[acked - 1].clone())
};
if acked < ids.len() {
*last_id.lock().unwrap() = boundary.clone();
}
if let (Some(id), Some(cp)) = (boundary, checkpoint) {
match encode_id(&id) {
Some(s) => {
if let Err(e) = cp.save(&s).await {
tracing::warn!(error = %e, "Failed to persist mongo id cursor. Messages may be reprocessed on restart.");
}
}
None => tracing::warn!(
"Unsupported _id type for cursor persistence; not checkpointing"
),
}
}
Ok(())
}) as BoxFuture<'static, anyhow::Result<()>>
});
Ok(ReceivedBatch { messages, commit })
}
async fn status(&self) -> EndpointStatus {
let mut error = None;
let healthy = match self.db.run_command(doc! { "ping": 1 }).await {
Ok(_) => true,
Err(e) => {
error = Some(e.to_string());
false
}
};
let pending = if healthy {
let last = self.last_id.lock().unwrap().clone();
let filter = match &last {
Some(v) => doc! { "_id": { "$gt": v.clone() } },
None => doc! {},
};
match self.collection.count_documents(filter).await {
Ok(c) => Some(c as usize),
Err(e) => {
error = Some(format!("Failed to count pending: {}", e));
None
}
}
} else {
None
};
EndpointStatus {
healthy,
target: self.collection.name().to_string(),
pending,
capacity: None,
details: serde_json::json!({ "cursor_id": self.cursor_id, "mode": "resumable" }),
error,
}
}
fn as_any(&self) -> &dyn Any {
self
}
}
pub(crate) fn encode_resume_token(token: &ResumeToken) -> anyhow::Result<String> {
let doc = to_document(token).context("Failed to serialize resume token")?;
let value = Bson::Document(doc).into_canonical_extjson();
serde_json::to_string(&value).context("Failed to encode resume token")
}
pub(crate) fn decode_resume_token(s: &str) -> Option<ResumeToken> {
let value: serde_json::Value = serde_json::from_str(s).ok()?;
let bson = Bson::try_from(value).ok()?;
mongodb::bson::from_bson::<ResumeToken>(bson).ok()
}
async fn open_change_stream(
collection: &Collection<Document>,
pipeline: &[Document],
resume_after: Option<ResumeToken>,
) -> anyhow::Result<ChangeStream<ChangeStreamEvent<Document>>> {
let mut watch = collection
.watch()
.pipeline(pipeline.to_vec())
.full_document(FullDocumentType::UpdateLookup);
if let Some(token) = resume_after {
watch = watch.resume_after(token);
}
let name = collection.name().to_string();
watch.await.map_err(|e| {
anyhow::Error::new(e).context(format!("Failed to open MongoDB change stream for '{name}'"))
})
}
pub(crate) fn is_change_stream_unsupported(err: &anyhow::Error) -> bool {
err.downcast_ref::<mongodb::error::Error>()
.is_some_and(|e| matches!(&*e.kind, ErrorKind::Command(cmd) if cmd.code == 40573))
}
const IDLE_RESUME_REFRESH: Duration = Duration::from_secs(10);
pub struct MongoDbChangeStreamReader {
collection: Collection<Document>,
db: Database,
collection_name: String,
checkpoint: Option<Arc<dyn crate::checkpoint::CheckpointStore>>,
cursor_id: Option<String>,
receive_query: Option<Document>,
pipeline: Vec<Document>,
stream: tokio::sync::Mutex<Option<ChangeStream<ChangeStreamEvent<Document>>>>,
pending_resume: Mutex<Option<ResumeToken>>,
snapshot_last_id: Arc<Mutex<Option<Bson>>>,
inflight: Arc<AtomicUsize>,
refresh_clean: Arc<AtomicBool>,
last_saved_token: Arc<Mutex<Option<String>>>,
}
impl MongoDbChangeStreamReader {
pub async fn new(config: &MongoDbConfig, snapshot: bool) -> anyhow::Result<Self> {
let collection_name = config
.collection
.as_deref()
.ok_or_else(|| anyhow!("Collection name is required for MongoDB CDC reader"))?;
let client = create_client(config).await?;
let db = client.database(&config.database);
let collection: Collection<Document> = db.collection(collection_name);
let receive_query = if let Some(q) = &config.receive_query {
let doc: Document = serde_json::from_str(q)
.context("Failed to parse 'receive_query' from configuration as a JSON document")?;
Some(doc)
} else {
None
};
let pipeline: Vec<Document> = receive_query
.as_ref()
.map(|q| vec![doc! { "$match": full_document_match(q) }])
.unwrap_or_default();
let checkpoint: Option<Arc<dyn crate::checkpoint::CheckpointStore>> = if let Some(cid) =
&config.cursor_id
{
use crate::checkpoint::CheckpointBackend;
let backend = match &config.checkpoint_store {
None => CheckpointBackend::Source {
name: crate::checkpoint::default_meta_name(collection_name),
},
Some(spec) => crate::checkpoint::parse_checkpoint_store(spec)?,
};
let store: Arc<dyn crate::checkpoint::CheckpointStore> = match backend {
CheckpointBackend::Source { name } => Arc::new(MongoCollectionCheckpointStore {
meta: db.collection::<Document>(&name),
doc_id: crate::checkpoint::checkpoint_key(collection_name, cid),
}),
external => {
crate::checkpoint::build_external_store(external, collection_name, cid).await?
}
};
Some(store)
} else {
warn!(
collection = %collection_name,
"MongoDB CDC reader has no cursor_id; resume is disabled and every restart starts from the current stream position. Set cursor_id to persist progress."
);
None
};
let resume_token = match &checkpoint {
Some(cp) => cp.load().await?.and_then(|s| {
let decoded = decode_resume_token(&s);
if decoded.is_none() {
warn!(value = %s, "Ignoring unparseable mongo resume token; starting from current stream position");
}
decoded
}),
None => None,
};
let take_snapshot = resume_token.is_none() && snapshot;
let (stream, pending_resume) = if take_snapshot {
let probe = open_change_stream(&collection, &pipeline, None).await?;
match probe.resume_token() {
Some(token) => {
info!(collection = %collection_name, "MongoDB CDC reader starting initial snapshot");
(None, Some(token))
}
None => {
warn!(collection = %collection_name, "Server did not provide a resume token; skipping snapshot and streaming new changes only");
(Some(probe), None)
}
}
} else {
(
Some(open_change_stream(&collection, &pipeline, resume_token.clone()).await?),
None,
)
};
info!(collection = %collection_name, cursor_id = ?config.cursor_id, resumed = %resume_token.is_some(), snapshot = %pending_resume.is_some(), "MongoDB CDC reader initialized");
Ok(Self {
collection,
db,
collection_name: collection_name.to_string(),
checkpoint,
cursor_id: config.cursor_id.clone(),
receive_query,
pipeline,
stream: tokio::sync::Mutex::new(stream),
pending_resume: Mutex::new(pending_resume),
snapshot_last_id: Arc::new(Mutex::new(None)),
inflight: Arc::new(AtomicUsize::new(0)),
refresh_clean: Arc::new(AtomicBool::new(true)),
last_saved_token: Arc::new(Mutex::new(
resume_token
.as_ref()
.and_then(|t| encode_resume_token(t).ok()),
)),
})
}
async fn snapshot_batch(
&self,
max_messages: usize,
) -> Result<Option<ReceivedBatch>, ConsumerError> {
let resume_from = self.snapshot_last_id.lock().unwrap().clone();
let last = resume_from.clone();
let mut filter = match &last {
Some(v) => doc! { "_id": { "$gt": v.clone() }, "seq_counter": { "$exists": false } },
None => doc! { "seq_counter": { "$exists": false } },
};
if let Some(extra) = &self.receive_query {
filter = doc! { "$and": [filter, extra.clone()] };
}
let find_options = FindOptions::builder()
.sort(doc! { "_id": 1 })
.limit(max_messages as i64)
.build();
let mut cursor = self
.collection
.find(filter)
.with_options(find_options)
.await
.map_err(|e| ConsumerError::Connection(e.into()))?;
let mut messages = Vec::new();
let mut ids: Vec<Bson> = Vec::new();
while let Some(result) = cursor.next().await {
let doc = result.map_err(|e| ConsumerError::Connection(e.into()))?;
let Some(id) = doc.get("_id").cloned() else {
warn!("MongoDB snapshot document without an `_id`; skipping");
continue;
};
match serde_json::to_vec(&doc) {
Ok(payload) => {
let mut msg = CanonicalMessage::new(payload, None);
msg.metadata
.insert("mongodb.operation".to_string(), "insert".to_string());
msg.metadata
.insert("mongodb.snapshot".to_string(), "true".to_string());
if let Some(enc) = encode_id(&id) {
msg.metadata.insert("mongodb.document_id".to_string(), enc);
}
messages.push(msg);
ids.push(id.clone());
}
Err(e) => warn!(error = %e, "Skipping unserializable MongoDB snapshot document"),
}
*self.snapshot_last_id.lock().unwrap() = Some(id);
}
if messages.is_empty() {
return Ok(None);
}
let last_id = self.snapshot_last_id.clone();
let inflight = self.inflight.clone();
let refresh_clean = self.refresh_clean.clone();
let commit = Box::new(move |dispositions: Vec<MessageDisposition>| {
Box::pin(async move {
let mut acked = 0usize;
for disp in dispositions.iter().take(ids.len()) {
if matches!(disp, MessageDisposition::Ack | MessageDisposition::Reply(_)) {
acked += 1;
} else {
break;
}
}
if acked < ids.len() {
let boundary = if acked == 0 {
resume_from
} else {
Some(ids[acked - 1].clone())
};
*last_id.lock().unwrap() = boundary;
refresh_clean.store(false, Ordering::Release);
}
inflight.fetch_sub(1, Ordering::AcqRel);
Ok(())
}) as BoxFuture<'static, anyhow::Result<()>>
});
self.inflight.fetch_add(1, Ordering::AcqRel);
Ok(Some(ReceivedBatch { messages, commit }))
}
fn event_to_message(event: &ChangeStreamEvent<Document>) -> Option<CanonicalMessage> {
let (op, payload) = match event.operation_type {
OperationType::Insert | OperationType::Update | OperationType::Replace => {
let doc = event.full_document.as_ref()?;
if doc.contains_key("seq_counter") {
return None;
}
(op_str(&event.operation_type), serde_json::to_vec(doc).ok()?)
}
OperationType::Delete => {
let key = event.document_key.clone().unwrap_or_default();
("delete", serde_json::to_vec(&key).ok()?)
}
_ => return None, };
let mut msg = CanonicalMessage::new(payload, None);
msg.metadata
.insert("mongodb.operation".to_string(), op.to_string());
if let Some(id) = event.document_key.as_ref().and_then(|k| k.get("_id")) {
if let Some(enc) = encode_id(id) {
msg.metadata.insert("mongodb.document_id".to_string(), enc);
}
}
Some(msg)
}
async fn refresh_idle_checkpoint(&self, token: Option<ResumeToken>) {
let Some(cp) = &self.checkpoint else { return };
if !self.refresh_clean.load(Ordering::Acquire) {
return;
}
if self.inflight.load(Ordering::Acquire) != 0 {
return;
}
let Some(token) = token else {
return;
};
let encoded = match encode_resume_token(&token) {
Ok(s) => s,
Err(_) => return,
};
if self.last_saved_token.lock().unwrap().as_deref() == Some(encoded.as_str()) {
return;
}
if let Err(e) = cp.save(&encoded).await {
tracing::warn!(error = %e, "Failed to persist idle mongo resume token");
return;
}
*self.last_saved_token.lock().unwrap() = Some(encoded);
}
}
pub(crate) fn full_document_match(query: &Document) -> Document {
let mut out = Document::new();
for (key, value) in query {
if key.starts_with('$') {
out.insert(key.clone(), rewrite_operator_value(value));
} else {
out.insert(format!("fullDocument.{key}"), value.clone());
}
}
out
}
fn rewrite_operator_value(value: &Bson) -> Bson {
match value {
Bson::Array(items) => Bson::Array(
items
.iter()
.map(|item| match item {
Bson::Document(d) => Bson::Document(full_document_match(d)),
other => other.clone(),
})
.collect(),
),
Bson::Document(d) => Bson::Document(full_document_match(d)),
other => other.clone(),
}
}
fn op_str(op: &OperationType) -> &'static str {
match op {
OperationType::Insert => "insert",
OperationType::Update => "update",
OperationType::Replace => "replace",
OperationType::Delete => "delete",
_ => "other",
}
}
#[async_trait]
impl MessageConsumer for MongoDbChangeStreamReader {
async fn receive_batch(&mut self, max_messages: usize) -> Result<ReceivedBatch, ConsumerError> {
if max_messages == 0 {
return Ok(ReceivedBatch {
messages: Vec::new(),
commit: Box::new(|_| Box::pin(async { Ok(()) })),
});
}
let mut stream_guard = self.stream.lock().await;
if stream_guard.is_none() {
if let Some(batch) = self.snapshot_batch(max_messages).await? {
return Ok(batch);
}
let token = self.pending_resume.lock().unwrap().take();
let opened = open_change_stream(&self.collection, &self.pipeline, token)
.await
.map_err(ConsumerError::Connection)?;
info!(collection = %self.collection_name, "MongoDB CDC snapshot complete; streaming changes");
*stream_guard = Some(opened);
}
let stream = stream_guard.as_mut().expect("stream opened above");
let mut messages = Vec::new();
let mut tokens: Vec<ResumeToken> = Vec::new();
loop {
match tokio::time::timeout(IDLE_RESUME_REFRESH, stream.next()).await {
Ok(Some(Ok(event))) => {
let token = event.id.clone();
if let Some(msg) = Self::event_to_message(&event) {
messages.push(msg);
tokens.push(token);
}
if !messages.is_empty() {
break;
}
}
Ok(Some(Err(e))) => return Err(ConsumerError::Connection(e.into())),
Ok(None) => return Err(anyhow!("MongoDB change stream ended unexpectedly").into()),
Err(_) => {
let token = stream.resume_token();
self.refresh_idle_checkpoint(token).await;
}
}
}
while messages.len() < max_messages {
match tokio::time::timeout(Duration::from_millis(10), stream.next()).await {
Ok(Some(Ok(event))) => {
let token = event.id.clone();
if let Some(msg) = Self::event_to_message(&event) {
messages.push(msg);
tokens.push(token);
}
}
Ok(Some(Err(e))) => return Err(ConsumerError::Connection(e.into())),
Ok(None) => return Err(anyhow!("MongoDB change stream ended unexpectedly").into()),
Err(_) => break, }
}
trace!(count = messages.len(), collection = %self.collection_name, "Received batch of MongoDB change events");
let checkpoint = self.checkpoint.clone();
let inflight = self.inflight.clone();
let refresh_clean = self.refresh_clean.clone();
let last_saved_token = self.last_saved_token.clone();
let commit = Box::new(move |dispositions: Vec<MessageDisposition>| {
Box::pin(async move {
let mut acked = 0usize;
for disp in dispositions.iter().take(tokens.len()) {
if matches!(disp, MessageDisposition::Ack | MessageDisposition::Reply(_)) {
acked += 1;
} else {
break;
}
}
let prior_gap = !refresh_clean.load(Ordering::Acquire);
if acked > 0 && !prior_gap {
if let Some(cp) = checkpoint {
match encode_resume_token(&tokens[acked - 1]) {
Ok(s) => {
if let Err(e) = cp.save(&s).await {
tracing::warn!(error = %e, "Failed to persist mongo resume token. Changes may be reprocessed on restart.");
} else {
*last_saved_token.lock().unwrap() = Some(s);
}
}
Err(e) => {
tracing::warn!(error = %e, "Failed to encode mongo resume token; not checkpointing")
}
}
}
}
if acked < tokens.len() {
refresh_clean.store(false, Ordering::Release);
}
inflight.fetch_sub(1, Ordering::AcqRel);
Ok(())
}) as BoxFuture<'static, anyhow::Result<()>>
});
self.inflight.fetch_add(1, Ordering::AcqRel);
Ok(ReceivedBatch { messages, commit })
}
async fn status(&self) -> EndpointStatus {
let (healthy, error) = match self.db.run_command(doc! { "ping": 1 }).await {
Ok(_) => (true, None),
Err(e) => (false, Some(e.to_string())),
};
let phase = match self.stream.try_lock() {
Ok(g) if g.is_none() => "snapshot",
Ok(_) => "streaming",
Err(_) => "streaming", };
let resume_token = self.last_saved_token.lock().unwrap().clone();
EndpointStatus {
healthy,
target: self.collection_name.clone(),
error,
details: serde_json::json!({
"cursor_id": self.cursor_id,
"mode": "cdc",
"phase": phase,
"in_flight_batches": self.inflight.load(Ordering::Acquire),
"resume_token": resume_token,
}),
..Default::default()
}
}
fn as_any(&self) -> &dyn Any {
self
}
}