use std::pin::Pin;
use async_trait::async_trait;
use futures::Stream;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CdcEvent {
pub op: String,
pub source: String,
pub tenant_id: String,
pub project_id: String,
pub after: Option<serde_json::Value>,
pub before: Option<serde_json::Value>,
pub source_offset: String,
pub source_ts_unix_ms: i64,
}
impl CdcEvent {
pub fn insert(
source: impl Into<String>,
after: serde_json::Value,
source_offset: impl Into<String>,
) -> Self {
let (tenant_id, project_id) = extract_context(&after);
Self {
op: "insert".to_string(),
source: source.into(),
tenant_id,
project_id,
after: Some(after),
before: None,
source_offset: source_offset.into(),
source_ts_unix_ms: now_ms(),
}
}
pub fn update(
source: impl Into<String>,
before: serde_json::Value,
after: serde_json::Value,
source_offset: impl Into<String>,
) -> Self {
let (tenant_id, project_id) = extract_context(&after);
Self {
op: "update".to_string(),
source: source.into(),
tenant_id,
project_id,
after: Some(after),
before: Some(before),
source_offset: source_offset.into(),
source_ts_unix_ms: now_ms(),
}
}
pub fn delete(
source: impl Into<String>,
before: serde_json::Value,
source_offset: impl Into<String>,
) -> Self {
let (tenant_id, project_id) = extract_context(&before);
Self {
op: "delete".to_string(),
source: source.into(),
tenant_id,
project_id,
after: None,
before: Some(before),
source_offset: source_offset.into(),
source_ts_unix_ms: now_ms(),
}
}
}
fn extract_context(row: &serde_json::Value) -> (String, String) {
let obj = row.as_object();
let tenant_id = obj
.and_then(|o| o.get("_tenant_id").or_else(|| o.get("tenant_id")))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let project_id = obj
.and_then(|o| o.get("_project_id").or_else(|| o.get("project_id")))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
(tenant_id, project_id)
}
fn now_ms() -> i64 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[async_trait]
pub trait CdcSource: Send + Sync {
fn backend_label(&self) -> &str;
async fn open(
&self,
from_offset: &str,
) -> Result<Pin<Box<dyn Stream<Item = Result<CdcEvent, String>> + Send>>, String>;
async fn health(&self) -> Result<(), String>;
fn requires_replication_setup(&self) -> bool {
false
}
}
#[cfg(feature = "kafka")]
pub struct PostgresCdcSource {
pub dsn: String,
pub publication: String,
pub slot: String,
}
#[cfg(feature = "kafka")]
#[async_trait]
impl CdcSource for PostgresCdcSource {
fn backend_label(&self) -> &str {
"postgres"
}
fn requires_replication_setup(&self) -> bool {
true
}
async fn open(
&self,
_from_offset: &str,
) -> Result<Pin<Box<dyn Stream<Item = Result<CdcEvent, String>> + Send>>, String> {
Err(
"PostgresCdcSource::open is not yet routed through the trait; \
the engine still calls engine_tail::stream_cdc directly. \
See runtime/cdc/source.rs for the migration plan."
.into(),
)
}
async fn health(&self) -> Result<(), String> {
use sqlx::Connection;
let mut conn = sqlx::PgConnection::connect(&self.dsn)
.await
.map_err(|e| format!("postgres cdc health: connect failed: {e}"))?;
let row: Option<(bool,)> =
sqlx::query_as("SELECT active FROM pg_replication_slots WHERE slot_name = $1 LIMIT 1")
.bind(&self.slot)
.fetch_optional(&mut conn)
.await
.map_err(|e| format!("postgres cdc health: slot lookup failed: {e}"))?;
match row {
Some((true,)) => Ok(()),
Some((false,)) => Err(format!("replication slot '{}' is inactive", self.slot)),
None => Err(format!("replication slot '{}' does not exist", self.slot)),
}
}
}
#[cfg(feature = "mongodb-native")]
pub struct MongoCdcSource {
pub uri: String,
pub database: String,
pub collection: Option<String>,
}
#[cfg(feature = "mongodb-native")]
#[async_trait]
impl CdcSource for MongoCdcSource {
fn backend_label(&self) -> &str {
"mongodb"
}
async fn open(
&self,
from_offset: &str,
) -> Result<Pin<Box<dyn Stream<Item = Result<CdcEvent, String>> + Send>>, String> {
use futures::StreamExt;
use mongodb_driver::{Client, options::ChangeStreamOptions};
let client = Client::with_uri_str(&self.uri)
.await
.map_err(|e| format!("mongo cdc connect failed: {e}"))?;
let db = client.database(&self.database);
let mut opts = ChangeStreamOptions::default();
if !from_offset.is_empty() {
let token_doc: mongodb_driver::bson::Document = serde_json::from_str(from_offset)
.map_err(|e| format!("mongo cdc resume token parse failed: {e}"))?;
let token: mongodb_driver::change_stream::event::ResumeToken =
mongodb_driver::bson::from_document(token_doc)
.map_err(|e| format!("mongo cdc resume token deserialise failed: {e}"))?;
opts.resume_after = Some(token);
}
let cs = match &self.collection {
Some(coll) => {
db.collection::<mongodb_driver::bson::Document>(coll)
.watch()
.with_options(opts)
.await
}
None => db.watch().with_options(opts).await,
}
.map_err(|e| format!("mongo cdc watch failed: {e}"))?;
let source_label = format!(
"{}.{}",
self.database,
self.collection.as_deref().unwrap_or("*")
);
let stream = cs.map(move |evt_res| {
let evt = evt_res.map_err(|e| format!("mongo cdc stream error: {e}"))?;
let op = match evt.operation_type {
mongodb_driver::change_stream::event::OperationType::Insert => "insert",
mongodb_driver::change_stream::event::OperationType::Update => "update",
mongodb_driver::change_stream::event::OperationType::Replace => "update",
mongodb_driver::change_stream::event::OperationType::Delete => "delete",
_ => "other",
};
let token = mongodb_driver::bson::to_document(&evt.id)
.ok()
.and_then(|d| serde_json::to_string(&d).ok())
.unwrap_or_default();
let after = evt
.full_document
.as_ref()
.and_then(|d| mongodb_driver::bson::to_bson(d).ok())
.and_then(|b| serde_json::to_value(b).ok());
let before = evt
.full_document_before_change
.as_ref()
.and_then(|d| mongodb_driver::bson::to_bson(d).ok())
.and_then(|b| serde_json::to_value(b).ok());
let (tenant_id, project_id) = extract_context(
after
.as_ref()
.or(before.as_ref())
.unwrap_or(&serde_json::Value::Null),
);
Ok::<_, String>(CdcEvent {
op: op.to_string(),
source: source_label.clone(),
tenant_id,
project_id,
after,
before,
source_offset: token,
source_ts_unix_ms: now_ms(),
})
});
Ok(Box::pin(stream))
}
async fn health(&self) -> Result<(), String> {
use mongodb_driver::Client;
use mongodb_driver::bson::doc;
let client = Client::with_uri_str(&self.uri)
.await
.map_err(|e| format!("mongo cdc connect failed: {e}"))?;
let db = client.database(&self.database);
let resp = db
.run_command(doc! {"hello": 1})
.await
.map_err(|e| format!("mongo cdc hello failed: {e}"))?;
let is_replica = resp.contains_key("setName");
let is_sharded = resp
.get_str("msg")
.map(|m| m == "isdbgrid")
.unwrap_or(false);
if !is_replica && !is_sharded {
return Err("mongo cdc requires a replica set or sharded cluster; \
standalone mongod does not support change streams"
.into());
}
Ok(())
}
}
#[cfg(feature = "mysql")]
pub struct MysqlBinlogSource {
pub dsn: String,
pub server_id: u32,
}
#[cfg(feature = "mysql")]
#[async_trait]
impl CdcSource for MysqlBinlogSource {
fn backend_label(&self) -> &str {
"mysql"
}
async fn open(
&self,
_from_offset: &str,
) -> Result<Pin<Box<dyn Stream<Item = Result<CdcEvent, String>> + Send>>, String> {
Err(format!(
"MysqlBinlogSource is a trait surface; no in-tree implementation. \
For production MySQL CDC, deploy Debezium → Kafka and consume \
events as a downstream Kafka source. Server-id reserved: {}",
self.server_id
))
}
async fn health(&self) -> Result<(), String> {
use sqlx::Connection;
let mut conn = sqlx::MySqlConnection::connect(&self.dsn)
.await
.map_err(|e| format!("mysql cdc health: connect failed: {e}"))?;
let row: (String, String) =
sqlx::query_as("SELECT @@log_bin AS log_bin, @@binlog_format AS binlog_format")
.fetch_one(&mut conn)
.await
.map_err(|e| format!("mysql cdc health: settings query failed: {e}"))?;
if row.0 != "1" && row.0.to_lowercase() != "on" {
return Err("mysql @@log_bin must be ON for CDC".into());
}
if !row.1.eq_ignore_ascii_case("ROW") {
return Err(format!(
"mysql @@binlog_format must be ROW for CDC; current: {}",
row.1
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn cdc_event_insert_extracts_tenant_from_after() {
let evt = CdcEvent::insert(
"public.orders",
json!({"id": "abc", "_tenant_id": "acme", "_project_id": "p1"}),
"0/A1B2C3",
);
assert_eq!(evt.op, "insert");
assert_eq!(evt.tenant_id, "acme");
assert_eq!(evt.project_id, "p1");
assert!(evt.before.is_none());
assert_eq!(evt.source_offset, "0/A1B2C3");
}
#[test]
fn cdc_event_delete_extracts_tenant_from_before() {
let evt = CdcEvent::delete(
"public.orders",
json!({"id": "abc", "tenant_id": "acme"}),
"0/A1B2C3",
);
assert_eq!(evt.op, "delete");
assert_eq!(evt.tenant_id, "acme");
assert!(evt.after.is_none());
}
#[test]
fn cdc_event_extract_context_handles_missing_fields() {
let evt = CdcEvent::insert(
"public.t",
json!({"id": "x"}), "off",
);
assert_eq!(evt.tenant_id, "");
assert_eq!(evt.project_id, "");
}
pub struct InMemoryCdcSource {
pub label: String,
pub events: Vec<CdcEvent>,
}
#[async_trait]
impl CdcSource for InMemoryCdcSource {
fn backend_label(&self) -> &str {
&self.label
}
async fn open(
&self,
from_offset: &str,
) -> Result<Pin<Box<dyn Stream<Item = Result<CdcEvent, String>> + Send>>, String> {
let from = from_offset.to_string();
let events: Vec<CdcEvent> = self
.events
.iter()
.filter(|e| from.is_empty() || e.source_offset.as_str() > from.as_str())
.cloned()
.collect();
let stream = futures::stream::iter(events.into_iter().map(Ok));
Ok(Box::pin(stream))
}
async fn health(&self) -> Result<(), String> {
Ok(())
}
}
#[tokio::test]
async fn d_in_memory_source_open_streams_events_after_offset() {
use futures::StreamExt;
let source = InMemoryCdcSource {
label: "test".into(),
events: vec![
CdcEvent::insert("t", json!({"id": "a"}), "1"),
CdcEvent::insert("t", json!({"id": "b"}), "2"),
CdcEvent::insert("t", json!({"id": "c"}), "3"),
],
};
let mut s = source.open("").await.unwrap();
let mut all = Vec::new();
while let Some(evt) = s.next().await {
all.push(evt.unwrap());
}
assert_eq!(all.len(), 3);
let mut s = source.open("2").await.unwrap();
let mut after = Vec::new();
while let Some(evt) = s.next().await {
after.push(evt.unwrap());
}
assert_eq!(after.len(), 1);
assert_eq!(after[0].source_offset, "3");
}
#[test]
fn d_in_memory_source_label_matches_constructor() {
let s = InMemoryCdcSource {
label: "fake".into(),
events: vec![],
};
assert_eq!(s.backend_label(), "fake");
}
#[test]
fn cdc_event_update_carries_both_before_and_after() {
let evt = CdcEvent::update(
"public.t",
json!({"id": "x", "v": 1}),
json!({"id": "x", "v": 2}),
"off",
);
assert_eq!(evt.op, "update");
assert_eq!(evt.before.unwrap()["v"], 1);
assert_eq!(evt.after.unwrap()["v"], 2);
}
}