Skip to main content

ailake_catalog/
metadata.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Iceberg Spec v2 metadata.json read/write.
3// Only the fields needed by AI-Lake are modelled — the rest are passed through as JSON.
4
5use std::collections::HashMap;
6
7use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, VectorStoragePolicy};
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use uuid::Uuid;
11
12use crate::provider::{PartitionField, PartitionSpec, SnapshotId, TableMetadata};
13
14#[derive(Debug, Serialize, Deserialize)]
15pub struct IcebergMetadata {
16    #[serde(rename = "format-version")]
17    pub format_version: i32,
18    #[serde(rename = "table-uuid")]
19    pub table_uuid: String,
20    pub location: String,
21    #[serde(rename = "last-sequence-number", default)]
22    pub last_sequence_number: i64,
23    /// Iceberg V3 Row Lineage: next available globally-unique row ID.
24    /// Incremented by record_count for each new data file at commit time.
25    /// Absent (0) in V2 tables — field is ignored when format-version < 3.
26    #[serde(rename = "next-row-id", default)]
27    pub next_row_id: i64,
28    #[serde(rename = "last-updated-ms")]
29    pub last_updated_ms: i64,
30    #[serde(rename = "last-column-id", default)]
31    pub last_column_id: i32,
32    #[serde(default)]
33    pub schemas: Vec<Value>,
34    #[serde(rename = "current-schema-id", default)]
35    pub current_schema_id: i32,
36    #[serde(rename = "partition-specs", default)]
37    pub partition_specs: Vec<Value>,
38    #[serde(rename = "default-spec-id", default)]
39    pub default_spec_id: i32,
40    #[serde(rename = "last-partition-id", default)]
41    pub last_partition_id: i32,
42    #[serde(default)]
43    pub properties: HashMap<String, String>,
44    #[serde(rename = "current-snapshot-id", default)]
45    pub current_snapshot_id: Option<SnapshotId>,
46    #[serde(default)]
47    pub snapshots: Vec<IcebergSnapshot>,
48    #[serde(rename = "snapshot-log", default)]
49    pub snapshot_log: Vec<Value>,
50    #[serde(rename = "metadata-log", default)]
51    pub metadata_log: Vec<Value>,
52    #[serde(rename = "sort-orders", default)]
53    pub sort_orders: Vec<Value>,
54    #[serde(rename = "default-sort-order-id", default)]
55    pub default_sort_order_id: i32,
56    #[serde(rename = "refs", default)]
57    pub refs: HashMap<String, Value>,
58    /// Iceberg V3 statistics files (Puffin). Only written for format-version=3.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub statistics: Vec<IcebergStatisticsRef>,
61    /// Partition statistics files (Parquet). Written for partitioned tables on every commit.
62    /// Referenced under `"partition-statistics"` in metadata.json (Iceberg spec §3.6).
63    /// Enables Spark/Trino to do partition-level aggregations without scanning data files.
64    #[serde(
65        rename = "partition-statistics",
66        default,
67        skip_serializing_if = "Vec::is_empty"
68    )]
69    pub partition_statistics: Vec<IcebergPartitionStatsRef>,
70}
71
72/// Iceberg V3 statistics file reference stored in `metadata.json`.
73/// Points to a Puffin file containing table/snapshot statistics blobs.
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct IcebergStatisticsRef {
76    #[serde(rename = "snapshot-id")]
77    pub snapshot_id: i64,
78    #[serde(rename = "statistics-path")]
79    pub statistics_path: String,
80    #[serde(rename = "file-size-in-bytes")]
81    pub file_size_in_bytes: u64,
82    #[serde(rename = "file-footer-size-in-bytes")]
83    pub file_footer_size_in_bytes: u64,
84    /// Blob descriptors within the Puffin file. May be empty — readers can
85    /// always parse the Puffin footer directly for full blob metadata.
86    #[serde(
87        rename = "blob-file-references",
88        default,
89        skip_serializing_if = "Vec::is_empty"
90    )]
91    pub blob_file_references: Vec<BlobRef>,
92}
93
94/// Describes one blob within an Iceberg Puffin statistics file.
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct BlobRef {
97    #[serde(rename = "type")]
98    pub blob_type: String,
99    #[serde(rename = "snapshot-id")]
100    pub snapshot_id: i64,
101    #[serde(default)]
102    pub fields: Vec<i32>,
103    pub offset: u64,
104    pub length: u64,
105}
106
107/// Partition statistics file reference stored in `metadata.json` under `"partition-statistics"`.
108///
109/// Points to a Parquet file where each row represents one partition value and carries
110/// aggregate statistics (record_count, file_count, total_size_bytes).
111/// Written automatically by AI-Lake on every commit to a partitioned table.
112/// Spark, Trino, and PyIceberg use this to optimise partition-level aggregations.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct IcebergPartitionStatsRef {
115    #[serde(rename = "snapshot-id")]
116    pub snapshot_id: i64,
117    #[serde(rename = "statistics-path")]
118    pub statistics_path: String,
119    #[serde(rename = "file-size-in-bytes")]
120    pub file_size_in_bytes: u64,
121}
122
123#[derive(Debug, Serialize, Deserialize, Clone)]
124pub struct IcebergSnapshot {
125    #[serde(rename = "snapshot-id")]
126    pub snapshot_id: SnapshotId,
127    #[serde(rename = "parent-snapshot-id", skip_serializing_if = "Option::is_none")]
128    pub parent_snapshot_id: Option<SnapshotId>,
129    #[serde(rename = "sequence-number")]
130    pub sequence_number: i64,
131    #[serde(rename = "timestamp-ms")]
132    pub timestamp_ms: i64,
133    #[serde(rename = "manifest-list")]
134    pub manifest_list: String,
135    pub summary: HashMap<String, String>,
136    #[serde(rename = "schema-id")]
137    pub schema_id: Option<i32>,
138}
139
140impl IcebergMetadata {
141    /// Create a new metadata.json for a fresh AI-Lake table.
142    ///
143    /// `format_version`: 2 = Iceberg V2 (default); 3 = Iceberg V3 opt-in.
144    /// V3 tables are append/update compatible; equality deletes and partition
145    /// statistics require future phases (see docs/specs/ICEBERG_V3.md).
146    /// `partition_column_type`: Iceberg type of the partition column ("string", "uuid", "int", "long").
147    /// Defaults to "string" when `None`. Only used when `policy.partition_by` is set.
148    pub fn new(
149        location: &str,
150        policy: &VectorStoragePolicy,
151        format_version: u8,
152        partition_column_type: Option<&str>,
153        partition_fields: &[ailake_core::PartitionDef],
154    ) -> Self {
155        let mut properties = HashMap::new();
156        properties.insert("ailake.format-version".to_string(), "1".to_string());
157        properties.insert(
158            "ailake.vector-column".to_string(),
159            policy.column_name.clone(),
160        );
161        properties.insert("ailake.vector-dim".to_string(), policy.dim.to_string());
162        properties.insert(
163            "ailake.vector-metric".to_string(),
164            format!("{:?}", policy.metric).to_lowercase(),
165        );
166        properties.insert(
167            "ailake.vector-precision".to_string(),
168            format!("{:?}", policy.precision).to_lowercase(),
169        );
170        if let Some(m) = policy.hnsw_m {
171            properties.insert("ailake.hnsw-m".to_string(), m.to_string());
172        }
173        if let Some(ef) = policy.hnsw_ef_construction {
174            properties.insert("ailake.hnsw-ef-construction".to_string(), ef.to_string());
175        }
176        if let Some(model) = &policy.embedding_model {
177            properties.insert(
178                EmbeddingModelInfo::property_key().to_string(),
179                model.to_property_value(),
180            );
181            if let Some(dim) = model.dim {
182                properties.insert("ailake.embedding-model-dim".to_string(), dim.to_string());
183            }
184            if let Some(metric) = model.metric {
185                properties.insert(
186                    "ailake.embedding-model-metric".to_string(),
187                    format!("{:?}", metric).to_lowercase(),
188                );
189            }
190        }
191        if let Some(modality) = policy.modality {
192            properties.insert(
193                format!("ailake.modality-{}", policy.column_name),
194                modality.as_str().to_string(),
195            );
196        }
197        if let Some(col) = &policy.partition_by {
198            properties.insert("ailake.partition-by".to_string(), col.clone());
199        }
200
201        // When partition_by is set, emit a real Iceberg identity partition spec and add
202        // the partition column to the table schema so source-id resolves correctly for
203        // Spark, Trino, and PyIceberg partition pruning.
204        let (schemas, last_column_id, partition_specs, default_spec_id, last_partition_id) =
205            if !partition_fields.is_empty() {
206                // Phase K: multi-column / non-identity partition spec.
207                // Each PartitionDef becomes one schema field (source-id = index+1)
208                // and one spec field (field-id = 1000+index).
209                let schema_fields: Vec<serde_json::Value> = partition_fields
210                    .iter()
211                    .enumerate()
212                    .map(|(i, pf)| {
213                        serde_json::json!({
214                            "id": i + 1,
215                            "name": pf.column,
216                            "required": false,
217                            "type": pf.column_type
218                        })
219                    })
220                    .collect();
221                let spec_fields: Vec<serde_json::Value> = partition_fields
222                    .iter()
223                    .enumerate()
224                    .map(|(i, pf)| {
225                        serde_json::json!({
226                            "name": pf.column,
227                            "transform": pf.transform,
228                            "source-id": i + 1,
229                            "field-id": 1000 + i as i32
230                        })
231                    })
232                    .collect();
233                let schema = serde_json::json!({
234                    "schema-id": 0, "type": "struct", "fields": schema_fields
235                });
236                let spec = serde_json::json!({
237                    "spec-id": 1, "fields": spec_fields
238                });
239                let n = partition_fields.len() as i32;
240                (
241                    vec![schema],
242                    n,
243                    vec![serde_json::json!({"spec-id": 0, "fields": []}), spec],
244                    1,
245                    1000 + n - 1,
246                )
247            } else if let Some(col) = &policy.partition_by {
248                let col_type = partition_column_type.unwrap_or("string");
249                // source field in schema: id=1, the partition column
250                let schema = serde_json::json!({
251                    "schema-id": 0,
252                    "type": "struct",
253                    "fields": [{
254                        "id": 1,
255                        "name": col,
256                        "required": false,
257                        "type": col_type
258                    }]
259                });
260                // spec-id=0 is the empty (unpartitioned) spec kept for history;
261                // spec-id=1 is the identity partition spec (source-id=1 matches schema field id=1).
262                let spec = serde_json::json!({
263                    "spec-id": 1,
264                    "fields": [{
265                        "name": col,
266                        "transform": "identity",
267                        "source-id": 1,
268                        "field-id": 1000
269                    }]
270                });
271                (
272                    vec![schema],
273                    1,
274                    vec![serde_json::json!({"spec-id": 0, "fields": []}), spec],
275                    1,
276                    1000,
277                )
278            } else {
279                (
280                    vec![serde_json::json!({"schema-id": 0, "type": "struct", "fields": []})],
281                    0,
282                    vec![serde_json::json!({"spec-id": 0, "fields": []})],
283                    0,
284                    999,
285                )
286            };
287
288        let format_version = format_version.max(2) as i32;
289        if format_version >= 3 {
290            eprintln!(
291                "[ailake] WARN: creating Iceberg V3 table at {location} — \
292                 append/update workloads fully supported; \
293                 equality deletes not implemented"
294            );
295        }
296        let now_ms = now_ms();
297        IcebergMetadata {
298            format_version,
299            table_uuid: Uuid::new_v4().to_string(),
300            location: location.to_string(),
301            last_sequence_number: 0,
302            next_row_id: 0,
303            last_updated_ms: now_ms,
304            last_column_id,
305            schemas,
306            current_schema_id: 0,
307            partition_specs,
308            default_spec_id,
309            last_partition_id,
310            properties,
311            current_snapshot_id: None,
312            snapshots: vec![],
313            snapshot_log: vec![],
314            metadata_log: vec![],
315            sort_orders: vec![serde_json::json!({"order-id": 0, "fields": []})],
316            default_sort_order_id: 0,
317            refs: HashMap::new(),
318            statistics: vec![],
319            partition_statistics: vec![],
320        }
321    }
322
323    pub fn to_json(&self) -> AilakeResult<String> {
324        serde_json::to_string_pretty(self).map_err(AilakeError::Json)
325    }
326
327    pub fn from_json(s: &str) -> AilakeResult<Self> {
328        serde_json::from_str(s).map_err(AilakeError::Json)
329    }
330
331    pub fn to_table_metadata(&self) -> TableMetadata {
332        // Find the Puffin stats file for the current snapshot (most recent wins).
333        let current_statistics_path = self.current_snapshot_id.and_then(|snap_id| {
334            self.statistics
335                .iter()
336                .rev()
337                .find(|s| s.snapshot_id == snap_id)
338                .map(|s| s.statistics_path.clone())
339        });
340
341        // Phase G: parse schema fields from the current schema entry.
342        let schema_fields = self
343            .schemas
344            .iter()
345            .find(|s| s["schema-id"].as_i64() == Some(self.current_schema_id as i64))
346            .and_then(|s| s["fields"].as_array().cloned())
347            .map(|fields| {
348                fields
349                    .into_iter()
350                    .filter_map(|f| {
351                        let id = f["id"].as_i64()? as i32;
352                        let name = f["name"].as_str()?.to_string();
353                        let required = f["required"].as_bool().unwrap_or(false);
354                        let iceberg_type = match &f["type"] {
355                            Value::String(s) => s.clone(),
356                            other => other.to_string(),
357                        };
358                        let initial_default =
359                            f.get("initial-default").filter(|v| !v.is_null()).cloned();
360                        let write_default =
361                            f.get("write-default").filter(|v| !v.is_null()).cloned();
362                        Some(crate::provider::SchemaField {
363                            id,
364                            name,
365                            required,
366                            iceberg_type,
367                            initial_default,
368                            write_default,
369                        })
370                    })
371                    .collect::<Vec<_>>()
372            })
373            .unwrap_or_default();
374
375        // Phase I: parse the active partition spec from partition_specs JSON.
376        // source_type is resolved from schema_fields (Iceberg spec says type comes from schema).
377        let partition_spec = self
378            .partition_specs
379            .iter()
380            .find(|s| s["spec-id"].as_i64() == Some(self.default_spec_id as i64))
381            .and_then(|s| {
382                let spec_id = s["spec-id"].as_i64()? as i32;
383                let fields: Vec<PartitionField> = s["fields"]
384                    .as_array()?
385                    .iter()
386                    .filter_map(|f| {
387                        let source_id = f["source-id"].as_i64()? as i32;
388                        let field_id = f["field-id"].as_i64()? as i32;
389                        let name = f["name"].as_str()?.to_string();
390                        let transform = f["transform"].as_str().unwrap_or("identity").to_string();
391                        // Resolve source type from schema fields.
392                        let source_type = schema_fields
393                            .iter()
394                            .find(|sf| sf.id == source_id)
395                            .map(|sf| sf.iceberg_type.clone())
396                            .unwrap_or_else(|| "string".to_string());
397                        Some(PartitionField {
398                            source_id,
399                            field_id,
400                            name,
401                            transform,
402                            source_type,
403                        })
404                    })
405                    .collect();
406                if fields.is_empty() {
407                    None
408                } else {
409                    Some(PartitionSpec { spec_id, fields })
410                }
411            });
412
413        TableMetadata {
414            table_uuid: self.table_uuid.clone(),
415            format_version: self.format_version,
416            location: self.location.clone(),
417            properties: self.properties.clone(),
418            current_snapshot_id: self.current_snapshot_id,
419            current_statistics_path,
420            schema_fields,
421            equality_delete_files: vec![], // populated lazily via list_equality_deletes
422            partition_spec,
423        }
424    }
425}
426
427fn now_ms() -> i64 {
428    std::time::SystemTime::now()
429        .duration_since(std::time::UNIX_EPOCH)
430        .unwrap()
431        .as_millis() as i64
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437    use ailake_core::{VectorMetric, VectorPrecision};
438
439    fn make_policy() -> VectorStoragePolicy {
440        VectorStoragePolicy {
441            column_name: "embedding".to_string(),
442            dim: 4,
443            metric: VectorMetric::Cosine,
444            precision: VectorPrecision::F16,
445            pq: None,
446            keep_raw_for_reranking: true,
447            pre_normalize: false,
448            hnsw_m: None,
449            hnsw_ef_construction: None,
450            ivf_residual: false,
451            embedding_model: None,
452            modality: None,
453            partition_by: None,
454            partition_value: None,
455            partition_column_type: None,
456            partition_fields: vec![],
457        }
458    }
459
460    #[test]
461    fn roundtrip_json() {
462        let meta = IcebergMetadata::new("s3://my-lake/my_table", &make_policy(), 2, None, &[]);
463        let json = meta.to_json().unwrap();
464        let meta2 = IcebergMetadata::from_json(&json).unwrap();
465        assert_eq!(meta2.format_version, 2);
466        assert_eq!(
467            meta2.properties.get("ailake.vector-column"),
468            Some(&"embedding".to_string())
469        );
470    }
471
472    #[test]
473    fn properties_contain_ailake_keys() {
474        let meta = IcebergMetadata::new("file:///tmp/tbl", &make_policy(), 2, None, &[]);
475        assert!(meta.properties.contains_key("ailake.format-version"));
476        assert!(meta.properties.contains_key("ailake.vector-dim"));
477    }
478
479    #[test]
480    fn embedding_model_stored_in_properties() {
481        use ailake_core::EmbeddingModelInfo;
482        let mut policy = make_policy();
483        policy.embedding_model =
484            Some(EmbeddingModelInfo::new("text-embedding-3-small").with_version("2024-01"));
485        let meta = IcebergMetadata::new("file:///tmp/tbl", &policy, 2, None, &[]);
486        assert_eq!(
487            meta.properties
488                .get("ailake.embedding-model")
489                .map(|s| s.as_str()),
490            Some("text-embedding-3-small@2024-01")
491        );
492    }
493
494    #[test]
495    fn format_version_v3_emitted() {
496        let meta = IcebergMetadata::new("s3://my-lake/v3_table", &make_policy(), 3, None, &[]);
497        let json = meta.to_json().unwrap();
498        let meta2 = IcebergMetadata::from_json(&json).unwrap();
499        assert_eq!(meta2.format_version, 3);
500    }
501
502    #[test]
503    fn format_version_defaults_to_v2() {
504        let meta = IcebergMetadata::new("s3://my-lake/v2_table", &make_policy(), 2, None, &[]);
505        assert_eq!(meta.format_version, 2);
506    }
507
508    #[test]
509    fn partition_spec_written_to_metadata_json() {
510        let mut policy = make_policy();
511        policy.partition_by = Some("agent_id".to_string());
512        let meta = IcebergMetadata::new("s3://my-lake/agents", &policy, 2, Some("string"), &[]);
513
514        // Schema must contain the partition column.
515        let schema = &meta.schemas[0];
516        let fields = schema["fields"].as_array().unwrap();
517        assert_eq!(fields.len(), 1);
518        assert_eq!(fields[0]["name"].as_str(), Some("agent_id"));
519        assert_eq!(fields[0]["id"].as_i64(), Some(1));
520        assert_eq!(fields[0]["type"].as_str(), Some("string"));
521
522        // Partition spec must reference source-id=1 (schema field id).
523        assert_eq!(meta.default_spec_id, 1);
524        let active = meta
525            .partition_specs
526            .iter()
527            .find(|s| s["spec-id"] == 1)
528            .unwrap();
529        let pf = &active["fields"][0];
530        assert_eq!(pf["name"].as_str(), Some("agent_id"));
531        assert_eq!(pf["transform"].as_str(), Some("identity"));
532        assert_eq!(pf["source-id"].as_i64(), Some(1));
533        assert_eq!(pf["field-id"].as_i64(), Some(1000));
534        assert_eq!(meta.last_partition_id, 1000);
535        assert_eq!(meta.last_column_id, 1);
536
537        // to_table_metadata() must parse partition_spec.
538        let tm = meta.to_table_metadata();
539        let spec = tm.partition_spec.expect("partition_spec must be Some");
540        assert_eq!(spec.spec_id, 1);
541        assert_eq!(spec.fields.len(), 1);
542        assert_eq!(spec.fields[0].name, "agent_id");
543        assert_eq!(spec.fields[0].source_id, 1);
544        assert_eq!(spec.fields[0].field_id, 1000);
545        assert_eq!(spec.fields[0].transform, "identity");
546        assert_eq!(spec.fields[0].source_type, "string");
547    }
548
549    #[test]
550    fn unpartitioned_table_has_no_partition_spec() {
551        let meta = IcebergMetadata::new("s3://my-lake/simple", &make_policy(), 2, None, &[]);
552        let tm = meta.to_table_metadata();
553        assert!(tm.partition_spec.is_none());
554    }
555
556    #[test]
557    fn partition_spec_int_type() {
558        let mut policy = make_policy();
559        policy.partition_by = Some("shard_id".to_string());
560        let meta = IcebergMetadata::new("s3://my-lake/shards", &policy, 2, Some("int"), &[]);
561        let schema = &meta.schemas[0];
562        assert_eq!(schema["fields"][0]["type"].as_str(), Some("int"));
563        let tm = meta.to_table_metadata();
564        let spec = tm.partition_spec.unwrap();
565        assert_eq!(spec.fields[0].source_type, "int");
566    }
567}