use faucet_core::FaucetError;
use iceberg::{NamespaceIdent, TableIdent};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub use faucet_common_iceberg::{CatalogConfig, CatalogInner};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ReadMode {
#[default]
Full,
Incremental,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OnRewrite {
#[default]
Fail,
FullRefresh,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum OnExpired {
#[default]
Fail,
FullRefresh,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct IcebergSourceConfig {
pub catalog: CatalogConfig,
pub table: String,
#[serde(default)]
pub columns: Vec<String>,
#[serde(default)]
pub filter: Option<String>,
#[serde(default)]
pub snapshot_id: Option<i64>,
#[serde(default)]
pub as_of_timestamp: Option<String>,
#[serde(default = "default_batch_size")]
pub batch_size: usize,
#[serde(default = "default_concurrency")]
pub concurrency: usize,
#[serde(default)]
pub mode: ReadMode,
#[serde(default)]
pub on_rewrite: OnRewrite,
#[serde(default)]
pub on_expired: OnExpired,
}
fn default_batch_size() -> usize {
faucet_core::DEFAULT_BATCH_SIZE
}
fn default_concurrency() -> usize {
4
}
pub fn parse_table(table: &str) -> Result<TableIdent, FaucetError> {
let parts: Vec<&str> = table.split('.').map(str::trim).collect();
if parts.len() < 2 || parts.iter().any(|p| p.is_empty()) {
return Err(FaucetError::Config(format!(
"iceberg: `table` must be `namespace.table` with no empty segment, got {table:?}"
)));
}
let (name, ns) = parts.split_last().expect("at least two parts");
let ns = NamespaceIdent::from_strs(ns.iter().copied()).map_err(|e| {
FaucetError::Config(format!("iceberg: invalid namespace in {table:?}: {e}"))
})?;
Ok(TableIdent::new(ns, (*name).to_string()))
}
pub fn parse_timestamp_ms(ts: &str) -> Result<i64, FaucetError> {
chrono::DateTime::parse_from_rfc3339(ts.trim())
.map(|dt| dt.timestamp_millis())
.map_err(|e| {
FaucetError::Config(format!(
"iceberg: `as_of_timestamp` {ts:?} is not an RFC 3339 instant: {e}"
))
})
}
impl IcebergSourceConfig {
pub fn validate(&self) -> Result<(), FaucetError> {
self.catalog.validate_connection()?;
parse_table(&self.table)?;
faucet_core::validate_batch_size(self.batch_size)?;
if self.concurrency == 0 {
return Err(FaucetError::Config(
"iceberg: `concurrency` must be > 0".to_string(),
));
}
let mut seen = std::collections::HashSet::new();
for c in &self.columns {
if c.trim().is_empty() {
return Err(FaucetError::Config(
"iceberg: `columns` entries must not be empty".to_string(),
));
}
if !seen.insert(c.as_str()) {
return Err(FaucetError::Config(format!(
"iceberg: column {c:?} is listed twice in `columns`"
)));
}
}
if let Some(f) = &self.filter {
crate::filter::parse(f)?;
}
if self.snapshot_id.is_some() && self.as_of_timestamp.is_some() {
return Err(FaucetError::Config(
"iceberg: set at most one of `snapshot_id` and `as_of_timestamp`".to_string(),
));
}
if let Some(ts) = &self.as_of_timestamp {
parse_timestamp_ms(ts)?;
}
if self.mode == ReadMode::Incremental
&& (self.snapshot_id.is_some() || self.as_of_timestamp.is_some())
{
return Err(FaucetError::Config(
"iceberg: time travel (`snapshot_id` / `as_of_timestamp`) is only supported \
with `mode: full`; incremental reads always advance to the current snapshot"
.to_string(),
));
}
Ok(())
}
pub fn table_ident(&self) -> Result<TableIdent, FaucetError> {
parse_table(&self.table)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn parse(v: serde_json::Value) -> IcebergSourceConfig {
serde_json::from_value(v).expect("config parses")
}
fn base() -> serde_json::Value {
json!({
"catalog": { "type": "sql", "uri": "sqlite::memory:", "warehouse": "/tmp/wh" },
"table": "db.events"
})
}
fn with(key: &str, value: serde_json::Value) -> IcebergSourceConfig {
let mut v = base();
v[key] = value;
parse(v)
}
#[test]
fn defaults_apply() {
let c = parse(base());
assert_eq!(c.batch_size, faucet_core::DEFAULT_BATCH_SIZE);
assert_eq!(c.concurrency, 4);
assert_eq!(c.mode, ReadMode::Full);
assert_eq!(c.on_rewrite, OnRewrite::Fail);
assert_eq!(c.on_expired, OnExpired::Fail);
assert!(c.columns.is_empty() && c.filter.is_none());
c.validate().expect("valid");
}
#[test]
fn enums_parse_snake_case() {
let mut v = base();
v["mode"] = json!("incremental");
v["on_rewrite"] = json!("full_refresh");
v["on_expired"] = json!("full_refresh");
let c = parse(v);
assert_eq!(c.mode, ReadMode::Incremental);
assert_eq!(c.on_rewrite, OnRewrite::FullRefresh);
assert_eq!(c.on_expired, OnExpired::FullRefresh);
}
#[test]
fn unknown_field_rejected() {
let mut v = base();
v["bogus"] = json!(1);
assert!(serde_json::from_value::<IcebergSourceConfig>(v).is_err());
}
#[test]
fn parse_table_splits_namespace() {
let t = parse_table("lake.analytics.events").unwrap();
assert_eq!(t.name(), "events");
assert_eq!(
t.namespace().as_ref(),
&vec!["lake".to_string(), "analytics".to_string()]
);
for bad in ["events", "", "db.", ".events", "a..b"] {
assert!(parse_table(bad).is_err(), "{bad:?} should fail");
}
}
#[test]
fn timestamp_parsing() {
assert_eq!(parse_timestamp_ms("1970-01-01T00:00:01Z").unwrap(), 1000);
assert!(parse_timestamp_ms("yesterday").is_err());
}
#[test]
fn validation_errors() {
let cases: Vec<(IcebergSourceConfig, &str)> = vec![
(with("table", json!("events")), "namespace.table"),
(
with("batch_size", json!(faucet_core::MAX_BATCH_SIZE + 1)),
"batch_size",
),
(with("concurrency", json!(0)), "concurrency"),
(with("columns", json!(["a", " "])), "must not be empty"),
(with("columns", json!(["a", "a"])), "listed twice"),
(with("filter", json!("a = ")), "filter"),
(with("as_of_timestamp", json!("nope")), "RFC 3339"),
(
with("catalog", json!({ "type": "rest" })),
"requires a non-empty `uri`",
),
];
for (cfg, needle) in cases {
let err = cfg.validate().unwrap_err().to_string();
assert!(err.contains(needle), "{err:?} should contain {needle:?}");
}
let mut v = base();
v["snapshot_id"] = json!(1);
v["as_of_timestamp"] = json!("2026-01-01T00:00:00Z");
assert!(
parse(v)
.validate()
.unwrap_err()
.to_string()
.contains("at most one")
);
for (k, val) in [
("snapshot_id", json!(1)),
("as_of_timestamp", json!("2026-01-01T00:00:00Z")),
] {
let mut v = base();
v["mode"] = json!("incremental");
v[k] = val;
assert!(
parse(v)
.validate()
.unwrap_err()
.to_string()
.contains("mode: full")
);
}
}
#[test]
fn valid_time_travel_and_filter() {
let mut v = base();
v["snapshot_id"] = json!(42);
v["filter"] = json!("id > 3 and name = 'x'");
v["columns"] = json!(["id", "name"]);
let c = parse(v);
c.validate().unwrap();
assert_eq!(c.table_ident().unwrap().name(), "events");
}
}