use std::pin::Pin;
use std::sync::Arc;
use async_trait::async_trait;
use futures::Stream;
use super::{CdcCheckpoint, CdcConfig, CdcError, ChangeEvent, CheckpointPosition, DbType};
#[async_trait]
pub trait DialectCapturer: Send + Sync {
async fn start_capture(
&self,
checkpoint: Option<CdcCheckpoint>,
) -> Result<Pin<Box<dyn Stream<Item = ChangeEvent> + Send>>, CdcError>;
fn dialect(&self) -> DbType;
}
pub struct WalCapturer {
config: CdcConfig,
conn_string: String,
slot_name: String,
wal_level: String,
}
impl WalCapturer {
pub fn new(
config: CdcConfig,
conn_string: String,
slot_name: String,
wal_level: String,
) -> Self {
Self {
config,
conn_string,
slot_name,
wal_level,
}
}
pub fn config(&self) -> &CdcConfig {
&self.config
}
pub fn wal_level(&self) -> &str {
&self.wal_level
}
}
#[async_trait]
impl DialectCapturer for WalCapturer {
async fn start_capture(
&self,
checkpoint: Option<CdcCheckpoint>,
) -> Result<Pin<Box<dyn Stream<Item = ChangeEvent> + Send>>, CdcError> {
if self.wal_level != "logical" {
return Err(CdcError::WalNotConfigured);
}
let _ = (&self.conn_string, &self.slot_name, checkpoint);
Err(CdcError::CaptureError {
reason: "WAL capture requires live PostgreSQL connection".to_string(),
})
}
fn dialect(&self) -> DbType {
DbType::Postgres
}
}
pub struct BinlogCapturer {
config: CdcConfig,
conn_string: String,
server_id: u32,
binlog_enabled: bool,
}
impl BinlogCapturer {
pub fn new(
config: CdcConfig,
conn_string: String,
server_id: u32,
binlog_enabled: bool,
) -> Self {
Self {
config,
conn_string,
server_id,
binlog_enabled,
}
}
pub fn binlog_enabled(&self) -> bool {
self.binlog_enabled
}
pub fn config(&self) -> &CdcConfig {
&self.config
}
}
#[async_trait]
impl DialectCapturer for BinlogCapturer {
async fn start_capture(
&self,
checkpoint: Option<CdcCheckpoint>,
) -> Result<Pin<Box<dyn Stream<Item = ChangeEvent> + Send>>, CdcError> {
if !self.binlog_enabled {
return Err(CdcError::BinlogNotEnabled);
}
let _ = (&self.conn_string, self.server_id, checkpoint);
Err(CdcError::CaptureError {
reason: "binlog capture requires live MySQL connection".to_string(),
})
}
fn dialect(&self) -> DbType {
DbType::Mysql
}
}
pub struct TriggerCapturer {
config: CdcConfig,
db_path: String,
}
impl TriggerCapturer {
pub fn new(config: CdcConfig, db_path: String) -> Self {
Self { config, db_path }
}
pub fn config(&self) -> &CdcConfig {
&self.config
}
}
#[async_trait]
impl DialectCapturer for TriggerCapturer {
async fn start_capture(
&self,
_checkpoint: Option<CdcCheckpoint>,
) -> Result<Pin<Box<dyn Stream<Item = ChangeEvent> + Send>>, CdcError> {
let _ = &self.db_path;
Err(CdcError::CaptureError {
reason: "trigger capture requires live SQLite connection".to_string(),
})
}
fn dialect(&self) -> DbType {
DbType::Sqlite
}
}
pub struct LogMinerCapturer {
config: CdcConfig,
conn_string: String,
}
impl LogMinerCapturer {
pub fn new(config: CdcConfig, conn_string: String) -> Self {
Self {
config,
conn_string,
}
}
pub fn config(&self) -> &CdcConfig {
&self.config
}
}
#[async_trait]
impl DialectCapturer for LogMinerCapturer {
async fn start_capture(
&self,
_checkpoint: Option<CdcCheckpoint>,
) -> Result<Pin<Box<dyn Stream<Item = ChangeEvent> + Send>>, CdcError> {
let _ = &self.conn_string;
Err(CdcError::CaptureError {
reason: "LogMiner capture requires live Oracle connection".to_string(),
})
}
fn dialect(&self) -> DbType {
DbType::Oracle
}
}
pub struct MssqlCdcCapturer {
config: CdcConfig,
conn_string: String,
}
impl MssqlCdcCapturer {
pub fn new(config: CdcConfig, conn_string: String) -> Self {
Self {
config,
conn_string,
}
}
pub fn config(&self) -> &CdcConfig {
&self.config
}
}
#[async_trait]
impl DialectCapturer for MssqlCdcCapturer {
async fn start_capture(
&self,
_checkpoint: Option<CdcCheckpoint>,
) -> Result<Pin<Box<dyn Stream<Item = ChangeEvent> + Send>>, CdcError> {
let _ = &self.conn_string;
Err(CdcError::CaptureError {
reason: "MSSQL CDC capture requires live MSSQL connection".to_string(),
})
}
fn dialect(&self) -> DbType {
DbType::Mssql
}
}
pub fn create_capturer(
config: CdcConfig,
conn_string: &str,
) -> Result<Box<dyn DialectCapturer>, CdcError> {
match config.dialect {
DbType::Postgres => Ok(Box::new(WalCapturer::new(
config,
conn_string.to_string(),
"sz_orm_cdc_slot".to_string(),
"logical".to_string(),
))),
DbType::Mysql => Ok(Box::new(BinlogCapturer::new(
config,
conn_string.to_string(),
1001,
true,
))),
DbType::Sqlite => Ok(Box::new(TriggerCapturer::new(
config,
conn_string.to_string(),
))),
DbType::Oracle => Ok(Box::new(LogMinerCapturer::new(
config,
conn_string.to_string(),
))),
DbType::Mssql => Ok(Box::new(MssqlCdcCapturer::new(
config,
conn_string.to_string(),
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cdc::{CheckpointStoreConfig, DownstreamConfig};
fn test_config(dialect: DbType) -> CdcConfig {
CdcConfig {
tables: vec!["users".to_string()],
dialect,
downstream: vec![DownstreamConfig::Kafka {
topic: "cdc".to_string(),
}],
checkpoint_store: CheckpointStoreConfig::Memory,
masking: None,
}
}
#[test]
fn test_wal_capturer_dialect() {
let capturer = WalCapturer::new(
test_config(DbType::Postgres),
"host=localhost".to_string(),
"slot1".to_string(),
"logical".to_string(),
);
assert_eq!(capturer.dialect(), DbType::Postgres);
}
#[test]
fn test_binlog_capturer_dialect() {
let capturer = BinlogCapturer::new(
test_config(DbType::Mysql),
"host=localhost".to_string(),
1001,
true,
);
assert_eq!(capturer.dialect(), DbType::Mysql);
}
#[test]
fn test_trigger_capturer_dialect() {
let capturer = TriggerCapturer::new(test_config(DbType::Sqlite), "test.db".to_string());
assert_eq!(capturer.dialect(), DbType::Sqlite);
}
#[test]
fn test_logminer_capturer_dialect() {
let capturer =
LogMinerCapturer::new(test_config(DbType::Oracle), "oracle_conn".to_string());
assert_eq!(capturer.dialect(), DbType::Oracle);
}
#[test]
fn test_mssql_cdc_capturer_dialect() {
let capturer = MssqlCdcCapturer::new(test_config(DbType::Mssql), "mssql_conn".to_string());
assert_eq!(capturer.dialect(), DbType::Mssql);
}
#[tokio::test]
async fn test_wal_not_configured_error() {
let capturer = WalCapturer::new(
test_config(DbType::Postgres),
"host=localhost".to_string(),
"slot1".to_string(),
"replica".to_string(),
);
let result = capturer.start_capture(None).await;
assert!(matches!(result, Err(CdcError::WalNotConfigured)));
}
#[tokio::test]
async fn test_binlog_not_enabled_error() {
let capturer = BinlogCapturer::new(
test_config(DbType::Mysql),
"host=localhost".to_string(),
1001,
false,
);
let result = capturer.start_capture(None).await;
assert!(matches!(result, Err(CdcError::BinlogNotEnabled)));
}
#[test]
fn test_create_capturer_postgres() {
let capturer = create_capturer(test_config(DbType::Postgres), "conn").unwrap();
assert_eq!(capturer.dialect(), DbType::Postgres);
}
#[test]
fn test_create_capturer_all_dialects() {
for dialect in [
DbType::Postgres,
DbType::Mysql,
DbType::Sqlite,
DbType::Oracle,
DbType::Mssql,
] {
let capturer = create_capturer(test_config(dialect), "conn").unwrap();
assert_eq!(capturer.dialect(), dialect);
}
}
}
use std::collections::VecDeque;
use std::time::Duration;
pub type PollFn =
dyn Fn(&CdcCheckpoint) -> Result<(Vec<ChangeEvent>, CdcCheckpoint), CdcError> + Send + Sync;
pub struct PollingCapturer {
dialect: DbType,
poll_interval: Duration,
poll_fn: Arc<PollFn>,
}
impl PollingCapturer {
pub fn new(dialect: DbType, poll_interval: Duration, poll_fn: Arc<PollFn>) -> Self {
Self {
dialect,
poll_interval,
poll_fn,
}
}
pub fn dialect(&self) -> DbType {
self.dialect
}
pub async fn poll_once(
&self,
checkpoint: Option<CdcCheckpoint>,
) -> Result<(Vec<ChangeEvent>, CdcCheckpoint), CdcError> {
let initial = CdcCheckpoint {
dialect: self.dialect,
position: CheckpointPosition::TriggerSeq(0),
updated_at: 0,
};
let cp = checkpoint.unwrap_or(initial);
(self.poll_fn)(&cp)
}
pub async fn start_capture(
&self,
checkpoint: Option<CdcCheckpoint>,
) -> Result<Pin<Box<dyn Stream<Item = ChangeEvent> + Send>>, CdcError> {
let poll_fn = Arc::clone(&self.poll_fn);
let interval = self.poll_interval;
let dialect = self.dialect;
let initial_cp = CdcCheckpoint {
dialect,
position: CheckpointPosition::TriggerSeq(0),
updated_at: 0,
};
let stream = futures::stream::unfold(
(VecDeque::new(), checkpoint.unwrap_or(initial_cp)),
move |(mut pending, mut cp)| {
let poll_fn = Arc::clone(&poll_fn);
async move {
loop {
if let Some(ev) = pending.pop_front() {
return Some((ev, (pending, cp)));
}
match (poll_fn)(&cp) {
Ok((events, new_cp)) => {
cp = new_cp;
pending.extend(events);
if pending.is_empty() {
tokio::time::sleep(interval).await;
continue;
}
}
Err(_) => return None, }
}
}
},
);
Ok(Box::pin(stream))
}
}
#[cfg(test)]
mod polling_tests {
use super::*;
use crate::cdc::{ChangeOp, Row};
use std::collections::VecDeque;
use std::sync::Mutex;
struct FakeChangeSource {
events: Mutex<VecDeque<ChangeEvent>>,
next_seq: Mutex<u64>,
}
impl FakeChangeSource {
fn new(events: Vec<ChangeEvent>) -> Self {
Self {
events: Mutex::new(events.into()),
next_seq: Mutex::new(0),
}
}
fn poll(&self, cp: &CdcCheckpoint) -> Result<(Vec<ChangeEvent>, CdcCheckpoint), CdcError> {
let mut events = self.events.lock().unwrap_or_else(|e| e.into_inner());
let mut next = self.next_seq.lock().unwrap_or_else(|e| e.into_inner());
let mut batch = vec![];
while let Some(ev) = events.pop_front() {
batch.push(ev);
*next += 1;
}
let new_cp = CdcCheckpoint {
dialect: cp.dialect,
position: CheckpointPosition::TriggerSeq(*next),
updated_at: 0,
};
Ok((batch, new_cp))
}
}
fn make_event(seq: u64, name: &str) -> ChangeEvent {
let mut row = Row::new();
row.insert("id".to_string(), serde_json::json!(seq as i64));
row.insert("name".to_string(), serde_json::json!(name));
ChangeEvent {
op: ChangeOp::Insert,
before: None,
after: Some(row),
timestamp: seq,
transaction_id: format!("tx-{seq}"),
table: "users".to_string(),
schema: "public".to_string(),
}
}
#[tokio::test]
async fn test_polling_capturer_poll_once_incremental() {
let source = std::sync::Arc::new(FakeChangeSource::new(vec![
make_event(1, "alice"),
make_event(2, "bob"),
make_event(3, "carol"),
]));
let poll_fn = {
let s = Arc::clone(&source);
Arc::new(move |cp: &CdcCheckpoint| s.poll(cp))
};
let capturer = PollingCapturer::new(DbType::Sqlite, Duration::from_millis(10), poll_fn);
let (batch1, cp1) = capturer.poll_once(None).await.unwrap();
assert_eq!(batch1.len(), 3, "首次拉取应拿到全部 3 条");
assert_eq!(cp1.position, CheckpointPosition::TriggerSeq(3));
let (batch2, cp2) = capturer.poll_once(Some(cp1)).await.unwrap();
assert_eq!(batch2.len(), 0, "增量拉取:checkpoint 后无新事件");
assert_eq!(cp2.position, CheckpointPosition::TriggerSeq(3));
}
#[tokio::test]
async fn test_polling_capturer_stream_output() {
let source = std::sync::Arc::new(FakeChangeSource::new(vec![
make_event(1, "alice"),
make_event(2, "bob"),
]));
let poll_fn = {
let s = Arc::clone(&source);
Arc::new(move |cp: &CdcCheckpoint| s.poll(cp))
};
let capturer = PollingCapturer::new(DbType::Sqlite, Duration::from_millis(10), poll_fn);
let mut stream = capturer.start_capture(None).await.unwrap();
let mut seen = vec![];
for _ in 0..2 {
if let Some(ev) = futures::StreamExt::next(&mut stream).await {
seen.push(ev.table.clone());
}
}
assert_eq!(seen, vec!["users".to_string(), "users".to_string()]);
}
#[test]
fn test_polling_capturer_dialect() {
let source = std::sync::Arc::new(FakeChangeSource::new(vec![]));
let poll_fn = {
let s = Arc::clone(&source);
Arc::new(move |cp: &CdcCheckpoint| s.poll(cp))
};
let capturer = PollingCapturer::new(DbType::Postgres, Duration::from_secs(1), poll_fn);
assert_eq!(capturer.dialect(), DbType::Postgres);
}
}