use ailake_core::{AilakeResult, Centroid, VectorStoragePolicy};
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
pub use crate::metadata::{IcebergMetadata, IcebergSnapshot};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
#[serde(rename_all = "lowercase")]
pub enum IndexStatus {
#[default]
Ready,
Indexing,
Failed,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct TableIdent {
pub namespace: String,
pub name: String,
}
impl TableIdent {
pub fn new(namespace: &str, name: &str) -> Self {
Self {
namespace: namespace.to_string(),
name: name.to_string(),
}
}
}
pub type SnapshotId = i64;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeletionVector {
pub path: String,
pub offset: u64,
pub length: u64,
pub cardinality: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExtraVectorIndex {
pub column: String,
pub dim: u32,
pub hnsw_offset: u64,
pub hnsw_len: u64,
pub centroid_b64: Option<String>,
pub radius: Option<f32>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DataFileEntry {
pub path: String,
pub record_count: u64,
pub file_size_bytes: u64,
pub centroid_b64: Option<String>,
pub radius: Option<f32>,
pub hnsw_offset: Option<u64>,
pub hnsw_len: Option<u64>,
pub vector_column: Option<String>,
pub vector_dim: Option<u32>,
#[serde(default)]
pub extra_vector_indexes: Vec<ExtraVectorIndex>,
#[serde(default)]
pub index_status: IndexStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub index_error: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub batch_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub embedding_model: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partition_value: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deletion_vector: Option<DeletionVector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub first_row_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column_stats: Option<String>,
#[serde(default)]
pub sequence_number: i64,
}
impl DataFileEntry {
pub fn is_foreign(&self) -> bool {
self.centroid_b64.is_none()
}
pub fn batch_ids(&self) -> Vec<String> {
match &self.batch_id {
None => vec![],
Some(raw) => {
serde_json::from_str::<Vec<String>>(raw).unwrap_or_else(|_| vec![raw.clone()])
}
}
}
pub fn merge_batch_ids(sources: &[DataFileEntry]) -> Option<String> {
let mut seen = std::collections::HashSet::new();
let ids: Vec<String> = sources
.iter()
.flat_map(DataFileEntry::batch_ids)
.filter(|id| seen.insert(id.clone()))
.collect();
if ids.is_empty() {
None
} else {
Some(serde_json::to_string(&ids).expect("Vec<String> always serializes"))
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SchemaField {
pub id: i32,
pub name: String,
pub required: bool,
pub iceberg_type: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub initial_default: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub write_default: Option<serde_json::Value>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TableMetadata {
pub table_uuid: String,
pub format_version: i32,
pub location: String,
pub properties: HashMap<String, String>,
pub current_snapshot_id: Option<SnapshotId>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current_statistics_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub schema_fields: Vec<SchemaField>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub equality_delete_files: Vec<EqualityDeleteFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub partition_spec: Option<PartitionSpec>,
}
#[derive(Debug, Clone)]
pub struct IcebergSchemaUpdate {
pub fields: Vec<serde_json::Value>,
pub last_column_id: i32,
pub name_mapping_json: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct EqualityDeleteFile {
pub path: String,
pub equality_ids: Vec<i32>,
pub record_count: u64,
pub file_size_bytes: u64,
#[serde(default, skip_serializing)]
pub inline_values: Option<(String, Vec<String>)>,
#[serde(default)]
pub sequence_number: i64,
}
#[derive(Debug, Clone)]
pub struct NewSnapshot {
pub snapshot_id: SnapshotId,
pub parent_snapshot_id: Option<SnapshotId>,
pub files: Vec<DataFileEntry>,
pub operation: SnapshotOperation,
pub iceberg_schema: Option<IcebergSchemaUpdate>,
pub extra_properties: HashMap<String, String>,
pub bloom_filters: Vec<(String, Vec<u8>)>,
pub equality_delete_files: Vec<EqualityDeleteFile>,
}
#[derive(Debug, Clone)]
pub enum SnapshotOperation {
Append,
Overwrite,
Delete,
Replace,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartitionField {
pub source_id: i32,
pub field_id: i32,
pub name: String,
pub transform: String,
pub source_type: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PartitionSpec {
pub spec_id: i32,
pub fields: Vec<PartitionField>,
}
impl PartitionSpec {
pub fn is_unpartitioned(&self) -> bool {
self.fields.is_empty()
}
}
#[derive(Debug, Clone)]
pub struct TableProperties {
pub policy: VectorStoragePolicy,
pub extra: HashMap<String, String>,
pub format_version: u8,
pub partition_column_type: Option<String>,
}
#[async_trait]
pub trait CatalogProvider: Send + Sync {
async fn create_table(&self, name: &TableIdent, props: &TableProperties) -> AilakeResult<()>;
async fn load_table(&self, name: &TableIdent) -> AilakeResult<TableMetadata>;
async fn commit_snapshot(
&self,
table: &TableIdent,
snapshot: NewSnapshot,
) -> AilakeResult<SnapshotId>;
async fn list_files(
&self,
table: &TableIdent,
snapshot_id: Option<SnapshotId>,
) -> AilakeResult<Vec<DataFileEntry>>;
async fn drop_table(&self, name: &TableIdent) -> AilakeResult<()>;
fn retires_files_physically(&self) -> bool {
true
}
fn supports_in_place_rewrite(&self) -> bool {
true
}
async fn evolve_schema(
&self,
_table: &TableIdent,
_evolution: crate::schema_evolution::SchemaEvolution,
) -> AilakeResult<i32> {
Err(ailake_core::AilakeError::Catalog(
"evolve_schema not supported by this catalog backend".into(),
))
}
async fn list_equality_deletes(
&self,
_table: &TableIdent,
_snapshot_id: Option<SnapshotId>,
) -> AilakeResult<Vec<EqualityDeleteFile>> {
Ok(vec![])
}
async fn load_raw_metadata(&self, _table: &TableIdent) -> AilakeResult<IcebergMetadata> {
Err(ailake_core::AilakeError::Catalog(
"load_raw_metadata not supported by this catalog backend".into(),
))
}
async fn list_snapshots(&self, table: &TableIdent) -> AilakeResult<Vec<IcebergSnapshot>> {
let meta = self.load_raw_metadata(table).await?;
Ok(meta.snapshots.clone())
}
async fn add_vector_column(
&self,
table: &TableIdent,
spec: &ailake_core::VectorColSpec,
) -> AilakeResult<i32> {
use crate::schema_evolution::{AddColumnRequest, SchemaEvolution};
use std::collections::HashMap;
let mut props: HashMap<String, String> = HashMap::new();
props.insert(
format!("ailake.dim-{}", spec.column_name),
spec.dim.to_string(),
);
props.insert(
format!("ailake.metric-{}", spec.column_name),
format!("{:?}", spec.metric).to_lowercase(),
);
props.insert(
format!("ailake.precision-{}", spec.column_name),
format!("{:?}", spec.precision).to_lowercase(),
);
if spec.pre_normalize {
props.insert(
format!("ailake.pre-normalize-{}", spec.column_name),
"true".to_string(),
);
}
if let Some(m) = spec.hnsw_m {
props.insert(format!("ailake.hnsw-m-{}", spec.column_name), m.to_string());
}
if let Some(ef) = spec.hnsw_ef_construction {
props.insert(
format!("ailake.hnsw-ef-construction-{}", spec.column_name),
ef.to_string(),
);
}
let evolution = SchemaEvolution::new()
.add_column(AddColumnRequest {
name: spec.column_name.clone(),
iceberg_type: "binary".to_string(),
required: false,
initial_default: None,
write_default: None,
doc: Some(format!(
"Vector column {} dim={} metric={:?}",
spec.column_name, spec.dim, spec.metric
)),
})
.with_properties(props);
self.evolve_schema(table, evolution).await
}
}
pub struct VectorIndexInfo<'a> {
pub column: &'a str,
pub dim: u32,
pub hnsw_offset: u64,
pub hnsw_len: u64,
}
pub fn make_data_file_entry(
path: &str,
record_count: u64,
file_size_bytes: u64,
centroid: &Centroid,
index: VectorIndexInfo<'_>,
) -> DataFileEntry {
make_multi_column_data_file_entry(path, record_count, file_size_bytes, centroid, index, &[])
}
pub fn make_multi_column_data_file_entry(
path: &str,
record_count: u64,
file_size_bytes: u64,
primary_centroid: &Centroid,
primary_index: VectorIndexInfo<'_>,
extra: &[ExtraVectorIndex],
) -> DataFileEntry {
use base64::Engine;
let centroid_bytes: Vec<u8> = primary_centroid
.values
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
let centroid_b64 = base64::engine::general_purpose::STANDARD.encode(¢roid_bytes);
DataFileEntry {
path: path.to_string(),
record_count,
file_size_bytes,
centroid_b64: Some(centroid_b64),
radius: Some(primary_centroid.radius),
hnsw_offset: Some(primary_index.hnsw_offset),
hnsw_len: Some(primary_index.hnsw_len),
vector_column: Some(primary_index.column.to_string()),
vector_dim: Some(primary_index.dim),
extra_vector_indexes: extra.to_vec(),
index_status: IndexStatus::Ready,
index_error: None,
batch_id: None,
embedding_model: None,
partition_value: None,
deletion_vector: None,
first_row_id: None,
column_stats: None,
sequence_number: 0,
}
}
pub fn make_data_file_entry_indexing(
path: &str,
record_count: u64,
file_size_bytes: u64,
centroid: &Centroid,
column: &str,
dim: u32,
) -> DataFileEntry {
use base64::Engine;
let centroid_bytes: Vec<u8> = centroid
.values
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
let centroid_b64 = base64::engine::general_purpose::STANDARD.encode(¢roid_bytes);
DataFileEntry {
path: path.to_string(),
record_count,
file_size_bytes,
centroid_b64: Some(centroid_b64),
radius: Some(centroid.radius),
hnsw_offset: None,
hnsw_len: None,
vector_column: Some(column.to_string()),
vector_dim: Some(dim),
extra_vector_indexes: vec![],
index_status: IndexStatus::Indexing,
index_error: None,
batch_id: None,
embedding_model: None,
partition_value: None,
deletion_vector: None,
first_row_id: None,
column_stats: None,
sequence_number: 0,
}
}
pub fn encode_centroid_b64(centroid: &Centroid) -> String {
use base64::Engine;
let bytes: Vec<u8> = centroid
.values
.iter()
.flat_map(|v| v.to_le_bytes())
.collect();
base64::engine::general_purpose::STANDARD.encode(&bytes)
}
pub fn decode_centroid(
entry: &DataFileEntry,
metric: ailake_core::VectorMetric,
) -> Option<Centroid> {
use base64::Engine;
let b64 = entry.centroid_b64.as_ref()?;
let bytes = base64::engine::general_purpose::STANDARD.decode(b64).ok()?;
let values: Vec<f32> = bytes
.chunks_exact(4)
.map(|b| {
f32::from_le_bytes(
b.try_into()
.expect("chunks_exact(4) guarantees 4-byte slices"),
)
})
.collect();
Some(Centroid {
values,
radius: entry.radius.unwrap_or(0.0),
metric,
})
}
pub fn new_snapshot_id() -> SnapshotId {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_micros() as i64
}
#[cfg(test)]
mod batch_id_tests {
use super::*;
fn entry_with_batch_id(batch_id: Option<&str>) -> DataFileEntry {
DataFileEntry {
path: "data/x.parquet".into(),
record_count: 1,
file_size_bytes: 1,
centroid_b64: None,
radius: None,
hnsw_offset: None,
hnsw_len: None,
vector_column: None,
vector_dim: None,
extra_vector_indexes: vec![],
index_status: IndexStatus::Ready,
index_error: None,
batch_id: batch_id.map(String::from),
embedding_model: None,
partition_value: None,
deletion_vector: None,
first_row_id: None,
column_stats: None,
sequence_number: 0,
}
}
#[test]
fn batch_ids_decodes_plain_string_as_single_key() {
let e = entry_with_batch_id(Some("dag_run_2026-05-28_taskA"));
assert_eq!(e.batch_ids(), vec!["dag_run_2026-05-28_taskA".to_string()]);
}
#[test]
fn batch_ids_empty_when_none() {
assert!(entry_with_batch_id(None).batch_ids().is_empty());
}
#[test]
fn merge_batch_ids_aggregates_plain_source_keys() {
let sources = vec![
entry_with_batch_id(Some("a")),
entry_with_batch_id(Some("b")),
];
let merged = DataFileEntry::merge_batch_ids(&sources);
let mut merged_entry = entry_with_batch_id(None);
merged_entry.batch_id = merged;
assert_eq!(
merged_entry.batch_ids(),
vec!["a".to_string(), "b".to_string()]
);
}
#[test]
fn merge_batch_ids_none_when_no_source_has_one() {
let sources = vec![entry_with_batch_id(None), entry_with_batch_id(None)];
assert_eq!(DataFileEntry::merge_batch_ids(&sources), None);
}
#[test]
fn merge_batch_ids_dedups_repeated_keys() {
let sources = vec![
entry_with_batch_id(Some("a")),
entry_with_batch_id(Some("a")),
];
let mut merged_entry = entry_with_batch_id(None);
merged_entry.batch_id = DataFileEntry::merge_batch_ids(&sources);
assert_eq!(merged_entry.batch_ids(), vec!["a".to_string()]);
}
#[test]
fn merge_batch_ids_flattens_a_previous_merge_without_nesting() {
let first_pass = vec![
entry_with_batch_id(Some("a")),
entry_with_batch_id(Some("b")),
];
let mut already_merged = entry_with_batch_id(None);
already_merged.batch_id = DataFileEntry::merge_batch_ids(&first_pass);
let second_pass = vec![already_merged, entry_with_batch_id(Some("c"))];
let mut twice_merged = entry_with_batch_id(None);
twice_merged.batch_id = DataFileEntry::merge_batch_ids(&second_pass);
assert_eq!(
twice_merged.batch_ids(),
vec!["a".to_string(), "b".to_string(), "c".to_string()]
);
assert_eq!(twice_merged.batch_id.as_deref(), Some(r#"["a","b","c"]"#));
}
}