1use 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 #[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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
60 pub statistics: Vec<IcebergStatisticsRef>,
61 #[serde(
65 rename = "partition-statistics",
66 default,
67 skip_serializing_if = "Vec::is_empty"
68 )]
69 pub partition_statistics: Vec<IcebergPartitionStatsRef>,
70}
71
72#[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 #[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#[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#[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 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 let (schemas, last_column_id, partition_specs, default_spec_id, last_partition_id) =
205 if !partition_fields.is_empty() {
206 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 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 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 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 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 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 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![], 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 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 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 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}