use std::sync::Arc;
use arrow_array::{
Array, ArrayRef, Int64Array, RecordBatch, StringArray, StructArray, TimestampMicrosecondArray,
};
use arrow_schema::{DataType, Field, Fields, Schema, SchemaRef, TimeUnit};
use crate::{
error::{BifrostError, Result},
mapping::{RowFilter, SyncMode},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ChangeOp {
Insert,
Delete,
UpdateBefore,
UpdateAfter,
Update,
}
impl ChangeOp {
pub const fn as_str(self) -> &'static str {
match self {
ChangeOp::Insert => "insert",
ChangeOp::Delete => "delete",
ChangeOp::UpdateBefore => "update_before",
ChangeOp::UpdateAfter => "update_after",
ChangeOp::Update => "update",
}
}
pub fn parse(s: &str) -> Option<ChangeOp> {
match s.to_ascii_lowercase().as_str() {
"insert" => Some(ChangeOp::Insert),
"delete" => Some(ChangeOp::Delete),
"update_before" | "update_preimage" => Some(ChangeOp::UpdateBefore),
"update_after" | "update_postimage" => Some(ChangeOp::UpdateAfter),
"update" => Some(ChangeOp::Update),
_ => None,
}
}
pub const fn has_before(self) -> bool {
matches!(
self,
ChangeOp::Delete | ChangeOp::UpdateBefore | ChangeOp::Update
)
}
pub const fn has_after(self) -> bool {
matches!(
self,
ChangeOp::Insert | ChangeOp::UpdateAfter | ChangeOp::Update
)
}
}
pub mod col {
pub const OP: &str = "op";
pub const BEFORE: &str = "before";
pub const AFTER: &str = "after";
pub const SNAPSHOT: &str = "snapshot";
pub const TS: &str = "ts";
}
pub mod meta {
pub const CHANGE_TYPE: &str = "_change_type";
pub const COMMIT_VERSION: &str = "_commit_version";
pub const COMMIT_TIMESTAMP: &str = "_commit_timestamp";
pub const COMMIT_SNAPSHOT_ID: &str = "_commit_snapshot_id";
pub const CHANGE_ORDINAL: &str = "_change_ordinal";
pub const ALL: [&str; 5] = [
CHANGE_TYPE,
COMMIT_VERSION,
COMMIT_TIMESTAMP,
COMMIT_SNAPSHOT_ID,
CHANGE_ORDINAL,
];
pub fn is_meta(name: &str) -> bool {
ALL.contains(&name)
}
}
fn data_fields(wide: &Schema) -> Fields {
wide.fields()
.iter()
.filter(|f| !meta::is_meta(f.name()))
.map(|f| Arc::new(f.as_ref().clone().with_nullable(true)))
.collect()
}
pub fn change_row_schema(wide: &Schema) -> SchemaRef {
let fields = data_fields(wide);
Arc::new(Schema::new(vec![
Field::new(col::OP, DataType::Utf8, false),
Field::new(col::BEFORE, DataType::Struct(fields.clone()), true),
Field::new(col::AFTER, DataType::Struct(fields), true),
Field::new(col::SNAPSHOT, DataType::Int64, true),
Field::new(
col::TS,
DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
true,
),
]))
}
pub fn data_columns(wide: &Schema) -> Vec<String> {
wide.fields()
.iter()
.filter(|f| !meta::is_meta(f.name()))
.map(|f| f.name().clone())
.collect()
}
fn take_rows(array: &ArrayRef, idx: &[u32]) -> Result<ArrayRef> {
let indices = arrow_array::UInt32Array::from(idx.to_vec());
arrow::compute::take(array.as_ref(), &indices, None)
.map_err(|e| BifrostError::Source(format!("cdc take: {e}")))
}
fn image_struct(
wide: &RecordBatch,
fields: &Fields,
rows: &[u32],
valid: &[bool],
) -> Result<ArrayRef> {
let mut cols: Vec<ArrayRef> = Vec::with_capacity(fields.len());
for f in fields.iter() {
let src = wide.column_by_name(f.name()).ok_or_else(|| {
BifrostError::Source(format!("changelog batch is missing column `{}`", f.name()))
})?;
let gathered = take_rows(src, rows)?;
let gathered = if gathered.data_type() == f.data_type() {
gathered
} else {
arrow::compute::cast(gathered.as_ref(), f.data_type())
.map_err(|e| BifrostError::Source(format!("cdc cast {}: {e}", f.name())))?
};
cols.push(gathered);
}
let nulls = arrow::buffer::NullBuffer::from(valid.to_vec());
Ok(Arc::new(
StructArray::try_new(fields.clone(), cols, Some(nulls))
.map_err(|e| BifrostError::Source(format!("cdc struct: {e}")))?,
))
}
fn ops_of(wide: &RecordBatch) -> Result<Vec<ChangeOp>> {
let idx = wide.schema().index_of(meta::CHANGE_TYPE).map_err(|_| {
BifrostError::Source(format!(
"changelog batch has no `{}` column",
meta::CHANGE_TYPE
))
})?;
let kinds = wide
.column(idx)
.as_any()
.downcast_ref::<StringArray>()
.ok_or_else(|| {
BifrostError::Source(format!("`{}` must be a string column", meta::CHANGE_TYPE))
})?;
(0..wide.num_rows())
.map(|r| {
if kinds.is_null(r) {
return Err(BifrostError::Source(format!(
"null `{}` at row {r}",
meta::CHANGE_TYPE
)));
}
ChangeOp::parse(kinds.value(r)).ok_or_else(|| {
BifrostError::Source(format!(
"unknown `{}` `{}` at row {r} — refusing to drop a change row",
meta::CHANGE_TYPE,
kinds.value(r)
))
})
})
.collect()
}
fn snapshots_of(wide: &RecordBatch, default_snapshot: Option<i64>) -> Result<ArrayRef> {
for name in [meta::COMMIT_SNAPSHOT_ID, meta::COMMIT_VERSION] {
let Some(c) = wide.column_by_name(name) else {
continue;
};
return arrow::compute::cast(c.as_ref(), &DataType::Int64)
.map_err(|e| BifrostError::Source(format!("cdc snapshot cast {name}: {e}")));
}
Ok(Arc::new(Int64Array::from(vec![
default_snapshot;
wide.num_rows()
])))
}
fn timestamps_of(wide: &RecordBatch) -> Result<ArrayRef> {
let want = DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into()));
match wide.column_by_name(meta::COMMIT_TIMESTAMP) {
Some(c) => arrow::compute::cast(c.as_ref(), &want)
.map_err(|e| BifrostError::Source(format!("cdc ts cast: {e}"))),
None => Ok(Arc::new(
TimestampMicrosecondArray::from(vec![None::<i64>; wide.num_rows()])
.with_timezone("UTC"),
)),
}
}
pub fn encode_change_rows(
wide: &RecordBatch,
default_snapshot: Option<i64>,
) -> Result<RecordBatch> {
let schema = change_row_schema(wide.schema_ref());
let DataType::Struct(fields) = schema.field(1).data_type().clone() else {
unreachable!("change_row_schema builds `before` as a Struct");
};
let ops = ops_of(wide)?;
let rows: Vec<u32> = (0..wide.num_rows() as u32).collect();
let before_valid: Vec<bool> = ops.iter().map(|o| o.has_before()).collect();
let after_valid: Vec<bool> = ops.iter().map(|o| o.has_after()).collect();
let op_col = StringArray::from(ops.iter().map(|o| o.as_str()).collect::<Vec<_>>());
let before = image_struct(wide, &fields, &rows, &before_valid)?;
let after = image_struct(wide, &fields, &rows, &after_valid)?;
let snapshot = snapshots_of(wide, default_snapshot)?;
let ts = timestamps_of(wide)?;
RecordBatch::try_new(schema, vec![Arc::new(op_col), before, after, snapshot, ts])
.map_err(|e| BifrostError::Source(format!("cdc encode: {e}")))
}
pub fn fold_updates(rows: &RecordBatch, identity: &[String]) -> Result<RecordBatch> {
if identity.is_empty() || rows.num_rows() == 0 {
return Ok(rows.clone());
}
let op = rows
.column_by_name(col::OP)
.and_then(|c| c.as_any().downcast_ref::<StringArray>())
.ok_or_else(|| BifrostError::Source("change rows missing `op`".into()))?;
let before = struct_col(rows, col::BEFORE)?;
let after = struct_col(rows, col::AFTER)?;
let snapshot = rows
.column_by_name(col::SNAPSHOT)
.and_then(|c| c.as_any().downcast_ref::<Int64Array>())
.ok_or_else(|| BifrostError::Source("change rows missing `snapshot`".into()))?;
let mut pending: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
let mut plan: Vec<(ChangeOp, Option<usize>, Option<usize>)> = Vec::new();
let mut slot: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
for r in 0..rows.num_rows() {
let kind = ChangeOp::parse(op.value(r))
.ok_or_else(|| BifrostError::Source(format!("unknown op `{}`", op.value(r))))?;
match kind {
ChangeOp::UpdateBefore => {
let key = image_key(before, identity, r, snapshot, r)?;
slot.insert(key.clone(), plan.len());
pending.insert(key, r);
plan.push((ChangeOp::UpdateBefore, Some(r), None));
}
ChangeOp::UpdateAfter => {
let key = image_key(after, identity, r, snapshot, r)?;
match (pending.remove(&key), slot.remove(&key)) {
(Some(pre), Some(at)) => plan[at] = (ChangeOp::Update, Some(pre), Some(r)),
_ => plan.push((ChangeOp::UpdateAfter, None, Some(r))),
}
}
other => plan.push((
other,
other.has_before().then_some(r),
other.has_after().then_some(r),
)),
}
}
if plan.len() == rows.num_rows() {
return Ok(rows.clone()); }
let ops: Vec<&str> = plan.iter().map(|(o, _, _)| o.as_str()).collect();
let b_idx: Vec<u32> = plan.iter().map(|(_, b, _)| b.unwrap_or(0) as u32).collect();
let b_valid: Vec<bool> = plan.iter().map(|(_, b, _)| b.is_some()).collect();
let a_idx: Vec<u32> = plan.iter().map(|(_, _, a)| a.unwrap_or(0) as u32).collect();
let a_valid: Vec<bool> = plan.iter().map(|(_, _, a)| a.is_some()).collect();
let m_idx: Vec<u32> = plan
.iter()
.map(|(_, b, a)| b.or(*a).unwrap_or(0) as u32)
.collect();
let fields = struct_fields(rows, col::BEFORE)?;
RecordBatch::try_new(
rows.schema(),
vec![
Arc::new(StringArray::from(ops)),
gather_struct(before, &fields, &b_idx, &b_valid)?,
gather_struct(after, &fields, &a_idx, &a_valid)?,
take_rows(rows.column(3), &m_idx)?,
take_rows(rows.column(4), &m_idx)?,
],
)
.map_err(|e| BifrostError::Source(format!("cdc fold: {e}")))
}
fn struct_col<'a>(rows: &'a RecordBatch, name: &str) -> Result<&'a StructArray> {
rows.column_by_name(name)
.and_then(|c| c.as_any().downcast_ref::<StructArray>())
.ok_or_else(|| BifrostError::Source(format!("change rows missing `{name}` struct")))
}
fn struct_fields(rows: &RecordBatch, name: &str) -> Result<Fields> {
match rows
.schema()
.field_with_name(name)
.map(|f| f.data_type().clone())
{
Ok(DataType::Struct(f)) => Ok(f),
_ => Err(BifrostError::Source(format!("`{name}` is not a struct"))),
}
}
fn image_key(
image: &StructArray,
identity: &[String],
r: usize,
snapshot: &Int64Array,
snap_row: usize,
) -> Result<String> {
let mut key = String::new();
if snapshot.is_valid(snap_row) {
key.push_str(&snapshot.value(snap_row).to_string());
}
for name in identity {
key.push('\u{1f}');
let Some(c) = image.column_by_name(name) else {
continue;
};
key.push_str(
&arrow::util::display::array_value_to_string(c, r)
.map_err(|e| BifrostError::Source(format!("cdc key {name}: {e}")))?,
);
}
Ok(key)
}
fn gather_struct(
src: &StructArray,
fields: &Fields,
idx: &[u32],
valid: &[bool],
) -> Result<ArrayRef> {
let mut cols: Vec<ArrayRef> = Vec::with_capacity(fields.len());
for f in fields.iter() {
let c = src
.column_by_name(f.name())
.ok_or_else(|| BifrostError::Source(format!("image struct missing `{}`", f.name())))?;
cols.push(take_rows(c, idx)?);
}
let nulls = arrow::buffer::NullBuffer::from(valid.to_vec());
Ok(Arc::new(
StructArray::try_new(fields.clone(), cols, Some(nulls))
.map_err(|e| BifrostError::Source(format!("cdc gather: {e}")))?,
))
}
#[derive(Debug, Clone, Default)]
pub struct ChangeRequest {
pub table: String,
pub from_snapshot: Option<i64>,
pub to_snapshot: Option<i64>,
pub columns: Vec<String>,
pub filter: Option<RowFilter>,
pub net_changes: bool,
pub identity: Vec<String>,
}
impl ChangeRequest {
pub fn new(table: impl Into<String>, from: Option<i64>) -> Self {
Self {
table: table.into(),
from_snapshot: from,
..Default::default()
}
}
pub fn with_columns(mut self, columns: Vec<String>) -> Self {
self.columns = columns;
self
}
pub fn with_filter(mut self, filter: Option<RowFilter>) -> Self {
self.filter = filter;
self
}
pub fn to(mut self, to: Option<i64>) -> Self {
self.to_snapshot = to;
self
}
pub fn with_identity(mut self, identity: Vec<String>) -> Self {
self.identity = identity;
self
}
pub fn engine_columns(&self) -> Vec<String> {
if self.columns.is_empty() {
return Vec::new();
}
let mut out = self.columns.clone();
for id in &self.identity {
if !out.iter().any(|c| c == id) {
out.push(id.clone());
}
}
if let Some(f) = &self.filter
&& !out.iter().any(|c| c == &f.column)
{
out.push(f.column.clone());
}
out
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CdcKind {
SparkNative,
IcebergChangelogView,
SkadeDelta,
}
impl CdcKind {
pub const fn as_str(self) -> &'static str {
match self {
CdcKind::SparkNative => "spark_native",
CdcKind::IcebergChangelogView => "iceberg_changelog_view",
CdcKind::SkadeDelta => "skade_delta",
}
}
}
#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct CdcCapability {
pub chosen: CdcKind,
pub server_version: Option<String>,
pub server_major_minor: Option<(u32, u32)>,
pub native_expected: bool,
pub native_usable: bool,
pub fell_back: bool,
pub reason: String,
}
impl CdcCapability {
pub fn skade() -> Self {
Self {
chosen: CdcKind::SkadeDelta,
server_version: None,
server_major_minor: None,
native_expected: false,
native_usable: false,
fell_back: false,
reason: "no Spark Connect session — reading Iceberg changelog in-process via skade"
.into(),
}
}
pub fn to_json(&self) -> serde_json::Value {
serde_json::json!({
"cdc_backend": self.chosen.as_str(),
"server_version": self.server_version,
"server_major_minor": self.server_major_minor.map(|(a, b)| format!("{a}.{b}")),
"native_expected": self.native_expected,
"native_usable": self.native_usable,
"fell_back": self.fell_back,
"reason": self.reason,
})
}
pub fn log(&self) {
if self.fell_back {
tracing::warn!(
target: "bifrost::cdc",
event = "cdc_backend",
backend = self.chosen.as_str(),
server_version = ?self.server_version,
native_expected = self.native_expected,
native_usable = self.native_usable,
fell_back = true,
reason = %self.reason,
"native Spark CDC was expected but is not usable here — FELL BACK",
);
} else {
tracing::info!(
target: "bifrost::cdc",
event = "cdc_backend",
backend = self.chosen.as_str(),
server_version = ?self.server_version,
native_expected = self.native_expected,
native_usable = self.native_usable,
fell_back = false,
reason = %self.reason,
"selected CDC backend",
);
}
}
}
pub fn parse_major_minor(v: &str) -> Option<(u32, u32)> {
let mut it = v.split(['.', '-', '+']);
let major = it.next()?.trim().parse().ok()?;
let minor = it.next()?.trim().parse().ok()?;
Some((major, minor))
}
pub fn has_native_cdc_engine(major_minor: (u32, u32)) -> bool {
major_minor >= (4, 2)
}
#[async_trait::async_trait]
pub trait CdcBackend: Send + Sync {
fn kind(&self) -> CdcKind;
async fn changes(&self, req: &ChangeRequest) -> Result<ChangeStream>;
}
pub struct ChangeStream {
pub schema: SchemaRef,
pub capability: CdcCapability,
rx: tokio::sync::mpsc::Receiver<Result<RecordBatch>>,
}
pub struct ChangeSink(tokio::sync::mpsc::Sender<Result<RecordBatch>>);
impl ChangeSink {
pub async fn send(&self, batch: Result<RecordBatch>) -> bool {
self.0.send(batch).await.is_ok()
}
}
impl ChangeStream {
pub fn channel(
schema: SchemaRef,
capability: CdcCapability,
capacity: usize,
) -> (ChangeSink, ChangeStream) {
let (tx, rx) = tokio::sync::mpsc::channel(capacity.max(1));
(
ChangeSink(tx),
ChangeStream {
schema,
capability,
rx,
},
)
}
pub fn ready(
schema: SchemaRef,
capability: CdcCapability,
batches: Vec<RecordBatch>,
) -> ChangeStream {
let (tx, rx) = tokio::sync::mpsc::channel(batches.len().max(1));
for b in batches {
let _ = tx.try_send(Ok(b));
}
drop(tx);
ChangeStream {
schema,
capability,
rx,
}
}
pub async fn next(&mut self) -> Option<Result<RecordBatch>> {
self.rx.recv().await
}
pub async fn collect(mut self) -> Result<Vec<RecordBatch>> {
let mut out = Vec::new();
while let Some(b) = self.next().await {
out.push(b?);
}
Ok(out)
}
}
pub fn post_filter(wide: &RecordBatch, filter: &RowFilter) -> Result<RecordBatch> {
let out = crate::mapping::apply_row_filter_with(
std::slice::from_ref(wide),
filter,
crate::mapping::FilterNulls::Keep,
)?;
Ok(out.into_iter().next().unwrap_or_else(|| wide.clone()))
}
const _: () = assert!(
matches!(SyncMode::CHANGE_TYPE_COLUMN.as_bytes(), b"_change_type"),
"SyncMode::CHANGE_TYPE_COLUMN must equal cdc::meta::CHANGE_TYPE"
);
#[cfg(feature = "spark")]
pub mod spark;
#[cfg(feature = "spark")]
pub use spark::{SparkIcebergCdc, SparkNativeCdc, backend_for, detect};
#[cfg(feature = "skade")]
pub mod skade;
#[cfg(feature = "skade")]
pub use skade::SkadeCdc;
#[cfg(test)]
mod tests;