Skip to main content

ailake_query/
writer.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::sync::atomic::{AtomicU32, Ordering};
3use std::sync::Arc;
4
5use ailake_catalog::{
6    encode_centroid_b64, make_data_file_entry, make_data_file_entry_indexing,
7    make_multi_column_data_file_entry, new_snapshot_id, CatalogProvider, DataFileEntry,
8    ExtraVectorIndex, IcebergSchemaUpdate, IndexStatus, NewSnapshot, SnapshotId, SnapshotOperation,
9    TableIdent, TableProperties, VectorIndexInfo,
10};
11use ailake_core::{AilakeError, AilakeResult, EmbeddingModelInfo, VectorStoragePolicy};
12use ailake_file::{AilakeFileReader, AilakeFileWriter, IndexType, VectorColumnBatch};
13use ailake_index::{IvfPqCodebook, IvfPqConfig};
14use ailake_store::Store;
15use ailake_vec::compute_centroid_and_radius;
16use arrow_array::Array;
17use arrow_array::RecordBatch;
18use arrow_schema::SchemaRef;
19use bytes::Bytes;
20use serde_json;
21use tracing::{error, info, warn};
22
23/// Merges `new`'s fields into `existing`, preserving `existing`'s field order and only
24/// appending fields whose name isn't already present.
25///
26/// A `TableWriter` can receive several `write_batch*` calls before `commit()` (e.g. one
27/// Airbyte destination flush per batch within a sync's commit window). Record shape can
28/// vary batch-to-batch when extra columns are inferred from whatever fields happen to be
29/// present (no fixed allowlist) — a column absent from the first batch but present in a
30/// later one must still end up in the committed Iceberg schema, or it's written into the
31/// Parquet file but invisible to any standard Iceberg reader (which projects only
32/// declared schema fields).
33fn merge_schema(existing: Option<SchemaRef>, new: &SchemaRef) -> SchemaRef {
34    match existing {
35        None => new.clone(),
36        Some(existing)
37            if existing.fields().len() >= new.fields().len()
38                && new
39                    .fields()
40                    .iter()
41                    .all(|f| existing.field_with_name(f.name()).is_ok()) =>
42        {
43            // Fast path: `new` has no fields absent from `existing` — skip a rebuild.
44            existing
45        }
46        Some(existing) => {
47            let mut fields: Vec<arrow_schema::FieldRef> =
48                existing.fields().iter().cloned().collect();
49            for f in new.fields() {
50                if existing.field_with_name(f.name()).is_err() {
51                    fields.push(f.clone());
52                }
53            }
54            Arc::new(arrow_schema::Schema::new(fields))
55        }
56    }
57}
58
59/// Apply partition transforms and return the final stored value.
60/// For multi-column specs, raw must be \x1f-separated; each part is transformed
61/// independently and the result is rejoined with \x1f.
62/// For single-column (partition_by path), raw is returned as-is (identity only).
63fn apply_partition_transforms(policy: &VectorStoragePolicy, raw: Option<&str>) -> Option<String> {
64    let raw = raw?;
65    if policy.partition_fields.is_empty() {
66        return Some(raw.to_string());
67    }
68    let parts: Vec<&str> = raw.split('\x1f').collect();
69    let transformed: Vec<String> = policy
70        .partition_fields
71        .iter()
72        .enumerate()
73        .map(|(i, pf)| {
74            let v = parts.get(i).copied().unwrap_or("");
75            pf.apply(v)
76        })
77        .collect();
78    Some(transformed.join("\x1f"))
79}
80
81/// One vector column for a multi-column write batch.
82pub struct MultiVectorBatch<'a> {
83    pub policy: VectorStoragePolicy,
84    pub embeddings: &'a [Vec<f32>],
85}
86
87pub struct TableWriter {
88    catalog: Arc<dyn CatalogProvider>,
89    store: Arc<dyn Store>,
90    policy: VectorStoragePolicy,
91    table: TableIdent,
92    part_counter: Arc<AtomicU32>,
93    /// Unix-epoch milliseconds captured at writer construction; embedded in
94    /// every part path (`data/part-<session_ts>-NNNNN.parquet`) so file names
95    /// are unique across writer sessions. A plain per-session counter alone
96    /// reused names once compaction shrank the table's file count — and under
97    /// the DuckLake catalog the retired file the name collides with still
98    /// exists physically AND is still registered (retirement is a row-DELETE,
99    /// not a deregistration), so the colliding `store.put` rewrote a
100    /// registered file in place — the exact corruption
101    /// `supports_in_place_rewrite() == false` exists to prevent.
102    session_ts: u128,
103    pending_files: Vec<DataFileEntry>,
104    parent_snapshot_id: Option<SnapshotId>,
105    /// Arrow schema captured from the first write_batch call; used to populate
106    /// Iceberg schema fields and schema.name-mapping.default on commit.
107    captured_schema: Option<SchemaRef>,
108    /// Extra vector column policies from write_batch_multi (columns beyond primary).
109    extra_vec_policies: Vec<VectorStoragePolicy>,
110    /// IVF-PQ codebook trained on the first shard and reused for all subsequent shards.
111    /// Ensures cross-shard ADC distances are comparable — no reranking needed.
112    cached_ivf_codebook: Option<Arc<IvfPqCodebook>>,
113    /// Shared codebook cell for deferred IVF-PQ builds. Cloneable Arc so each
114    /// background task can access it; OnceCell guarantees training runs exactly once.
115    deferred_ivf_codebook: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
116    /// When set, BM25 IDF stats are accumulated from this Parquet column on each
117    /// write_batch call and persisted to `metadata/ailake_bm25_stats.bin`.
118    /// Enables hybrid vector+BM25 search via `SearchConfig::hybrid`.
119    bm25_text_column: Option<String>,
120    /// Per-file Bloom filters built during write_batch when bm25_text_column is set.
121    /// Flushed to NewSnapshot::bloom_filters on commit (Phase F Puffin stats).
122    pending_blooms: Vec<(String, Vec<u8>)>,
123    /// When set, a Tantivy FTS index is embedded in each written file (AILK_FTS section).
124    fts_config: Option<ailake_fts::FtsConfig>,
125}
126
127impl TableWriter {
128    pub fn new(
129        catalog: Arc<dyn CatalogProvider>,
130        store: Arc<dyn Store>,
131        policy: VectorStoragePolicy,
132        table: TableIdent,
133    ) -> Self {
134        Self {
135            catalog,
136            store,
137            policy,
138            table,
139            part_counter: Arc::new(AtomicU32::new(0)),
140            session_ts: std::time::SystemTime::now()
141                .duration_since(std::time::UNIX_EPOCH)
142                .unwrap_or_else(|e| e.duration())
143                .as_millis(),
144            pending_files: Vec::new(),
145            parent_snapshot_id: None,
146            captured_schema: None,
147            extra_vec_policies: Vec::new(),
148            cached_ivf_codebook: None,
149            deferred_ivf_codebook: Arc::new(tokio::sync::OnceCell::new()),
150            bm25_text_column: None,
151            pending_blooms: Vec::new(),
152            fts_config: None,
153        }
154    }
155
156    /// Enable BM25 hybrid search by accumulating IDF stats from `column` on each write.
157    ///
158    /// After calling this, every `write_batch*` call will tokenize the specified column,
159    /// update the corpus IDF stats, and persist them to `metadata/ailake_bm25_stats.bin`.
160    /// This file is then loaded automatically by `SearchConfig::hybrid` at query time.
161    ///
162    /// Typical usage: `TableWriter::new(...).with_bm25("chunk_text")`.
163    pub fn with_bm25(mut self, text_column: impl Into<String>) -> Self {
164        self.bm25_text_column = Some(text_column.into());
165        self
166    }
167
168    /// Embed a Tantivy FTS index in every file written by this writer.
169    ///
170    /// Enables the `search_text()` fast path (O(log N) per file via Tantivy)
171    /// instead of the O(N) BM25 brute-force fallback for files without an FTS section.
172    pub fn with_fts_config(mut self, cfg: ailake_fts::FtsConfig) -> Self {
173        self.fts_config = Some(cfg);
174        self
175    }
176
177    pub fn with_parent_snapshot(mut self, id: SnapshotId) -> Self {
178        self.parent_snapshot_id = Some(id);
179        self
180    }
181
182    /// Write batch as Parquet-only immediately, build HNSW in background.
183    ///
184    /// Returns after the Parquet file is persisted (~LanceDB write speed).
185    /// A tokio task runs concurrently to build the HNSW index, rewrite the
186    /// file with the AILK section, and update the catalog entry.
187    ///
188    /// During the build window, `SearchSession` serves this shard via flat scan
189    /// (brute-force, exact) instead of HNSW. The transition is automatic once
190    /// the background task commits the updated manifest entry.
191    pub async fn write_batch_deferred(
192        &mut self,
193        batch: &RecordBatch,
194        embeddings: &[Vec<f32>],
195    ) -> AilakeResult<()> {
196        self.ensure_deferred_supported()?;
197        self.validate_embedding_dim(embeddings)?;
198        self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
199        let file_path = self.next_part_path();
200
201        // Fast path: persist Parquet without HNSW.
202        let file_writer = AilakeFileWriter::new(self.policy.clone());
203        let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
204        let file_size = parquet_bytes.len() as u64;
205        self.store.put(&file_path, parquet_bytes).await?;
206
207        // Centroid needed immediately for geometric pruning during the build window.
208        let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
209        let mut entry = make_data_file_entry_indexing(
210            &file_path,
211            embeddings.len() as u64,
212            file_size,
213            &centroid,
214            &self.policy.column_name,
215            self.policy.dim,
216        );
217        entry.embedding_model = self
218            .policy
219            .embedding_model
220            .as_ref()
221            .map(|m| m.to_property_value());
222        entry.partition_value =
223            apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
224        self.pending_files.push(entry);
225
226        // Spawn background HNSW build (fire-and-forget; errors are logged).
227        let store = self.store.clone();
228        let catalog = self.catalog.clone();
229        let policy = self.policy.clone();
230        let table = self.table.clone();
231        let fp = file_path.clone();
232        tokio::spawn(async move {
233            if let Err(e) = build_and_patch_index(
234                store.clone(),
235                catalog.clone(),
236                policy,
237                table.clone(),
238                fp.clone(),
239            )
240            .await
241            {
242                error!(
243                    "ailake: deferred HNSW build failed for {fp}: {e}; \
244                     marking IndexStatus::Failed — compaction will rebuild"
245                );
246                patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
247            }
248        });
249
250        // Update BM25 IDF stats + build Bloom filter (Phase F) for the new file.
251        if self.bm25_text_column.is_some() {
252            self.update_bm25_stats_from_batch(batch).await?;
253            self.build_bloom_for_file(batch, &file_path);
254        }
255
256        Ok(())
257    }
258
259    /// Write batch as Parquet-only immediately; train IVF-PQ index in background.
260    ///
261    /// The first shard trains the shared codebook (k-means). All subsequent shards
262    /// reuse it via `OnceCell` — build is O(n) assign+encode, not O(n×k) k-means.
263    /// Returns after Parquet is persisted. Index transitions Indexing → Ready async.
264    pub async fn write_batch_ivf_pq_deferred(
265        &mut self,
266        batch: &RecordBatch,
267        embeddings: &[Vec<f32>],
268        ivf_config: IvfPqConfig,
269    ) -> AilakeResult<()> {
270        self.ensure_deferred_supported()?;
271        self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
272        let file_path = self.next_part_path();
273
274        let file_writer = AilakeFileWriter::new(self.policy.clone());
275        let parquet_bytes = file_writer.write_parquet_only(batch, embeddings)?;
276        let file_size = parquet_bytes.len() as u64;
277        self.store.put(&file_path, parquet_bytes).await?;
278
279        let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
280        let mut entry = make_data_file_entry_indexing(
281            &file_path,
282            embeddings.len() as u64,
283            file_size,
284            &centroid,
285            &self.policy.column_name,
286            self.policy.dim,
287        );
288        entry.embedding_model = self
289            .policy
290            .embedding_model
291            .as_ref()
292            .map(|m| m.to_property_value());
293        entry.partition_value =
294            apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
295        self.pending_files.push(entry);
296
297        let store = self.store.clone();
298        let catalog = self.catalog.clone();
299        let policy = self.policy.clone();
300        let table = self.table.clone();
301        let fp = file_path.clone();
302        let codebook_cell = self.deferred_ivf_codebook.clone();
303        tokio::spawn(async move {
304            if let Err(e) = build_ivf_pq_and_patch_index(
305                store.clone(),
306                catalog.clone(),
307                policy,
308                table.clone(),
309                fp.clone(),
310                ivf_config,
311                codebook_cell,
312            )
313            .await
314            {
315                error!(
316                    "ailake: deferred IVF-PQ build failed for {fp}: {e}; \
317                     marking IndexStatus::Failed — compaction will rebuild"
318                );
319                patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
320            }
321        });
322
323        Ok(())
324    }
325
326    /// Idempotent variant of `write_batch`.
327    ///
328    /// Before any I/O, checks if `batch_id` already appears in the current
329    /// snapshot. If it does, this is a no-op — safe for Airflow/Kestra retries.
330    /// If not found, writes the batch and tags the `DataFileEntry` with `batch_id`
331    /// so future retries can detect it.
332    ///
333    /// `commit()` is likewise a no-op when `pending_files` is empty.
334    ///
335    /// **Known window**: the tag lives on the `DataFileEntry`, and compaction's
336    /// merged file carries `batch_id: None` — a retry that fires *after* the
337    /// batch's file was compacted away will not find the tag and will re-insert
338    /// the batch. Keep retry horizons shorter than the compaction cadence, or
339    /// dedupe downstream on a business key.
340    pub async fn write_batch_idempotent(
341        &mut self,
342        batch: &RecordBatch,
343        embeddings: &[Vec<f32>],
344        batch_id: &str,
345    ) -> AilakeResult<()> {
346        let existing = self.catalog.list_files(&self.table, None).await?;
347        if existing
348            .iter()
349            .any(|f| f.batch_id.as_deref() == Some(batch_id))
350        {
351            return Ok(());
352        }
353        self.write_batch_with_id(batch, embeddings, Some(batch_id.to_string()))
354            .await
355    }
356
357    /// Write a batch to a new AI-Lake file and stage it for commit.
358    /// Validates that provided embeddings match the table's configured dimension.
359    /// Returns `ModelMismatch` error when dim differs — prevents silently mixing
360    /// incompatible vectors (same error type used across write paths for consistency).
361    fn validate_embedding_dim(&self, embeddings: &[Vec<f32>]) -> AilakeResult<()> {
362        Self::validate_embedding_dim_for_policy(embeddings, &self.policy)
363    }
364
365    /// Next data-file path for this writer session:
366    /// `data/part-<session_ts>-NNNNN.parquet`. The session timestamp keeps
367    /// names unique across sessions (see the `session_ts` field doc for the
368    /// compaction-then-insert collision this prevents); the counter keeps them
369    /// unique within one.
370    fn next_part_path(&self) -> String {
371        let part_num = self.part_counter.fetch_add(1, Ordering::SeqCst);
372        format!("data/part-{}-{:05}.parquet", self.session_ts, part_num)
373    }
374
375    /// Deferred writes persist a Parquet-only file first and later patch the
376    /// full AILK file **in place at the same path**. Refuse up front on catalog
377    /// backends where a committed path's bytes must never change (DuckLake:
378    /// stats and footer size are trusted from registration time — an in-place
379    /// grow breaks every subsequent native read of the file, verified live, and
380    /// the physical put happens *before* any commit-time guard could stop it).
381    fn ensure_deferred_supported(&self) -> AilakeResult<()> {
382        if self.catalog.supports_in_place_rewrite() {
383            Ok(())
384        } else {
385            Err(AilakeError::Catalog(
386                "deferred writes are not supported with this catalog backend: the \
387                 background index build patches the data file in place at its committed \
388                 path, which this catalog cannot re-register — use a blocking write"
389                    .into(),
390            ))
391        }
392    }
393
394    fn validate_embedding_dim_for_policy(
395        embeddings: &[Vec<f32>],
396        policy: &VectorStoragePolicy,
397    ) -> AilakeResult<()> {
398        for emb in embeddings {
399            let actual = emb.len() as u32;
400            if actual != policy.dim {
401                let table_model = policy
402                    .embedding_model
403                    .as_ref()
404                    .map(|m| m.to_property_value())
405                    .unwrap_or_else(|| format!("dim={}", policy.dim));
406                return Err(AilakeError::ModelMismatch {
407                    table_model,
408                    table_dim: policy.dim,
409                    batch_model: format!("dim={}", actual),
410                    batch_dim: actual,
411                });
412            }
413        }
414        Ok(())
415    }
416
417    pub async fn write_batch(
418        &mut self,
419        batch: &RecordBatch,
420        embeddings: &[Vec<f32>],
421    ) -> AilakeResult<()> {
422        self.write_batch_with_id(batch, embeddings, None).await
423    }
424
425    async fn write_batch_with_id(
426        &mut self,
427        batch: &RecordBatch,
428        embeddings: &[Vec<f32>],
429        batch_id: Option<String>,
430    ) -> AilakeResult<()> {
431        self.validate_embedding_dim(embeddings)?;
432        self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
433        let file_path = self.next_part_path();
434
435        // Write AI-Lake file
436        let mut file_writer = AilakeFileWriter::new(self.policy.clone());
437        if let Some(ref fts_cfg) = self.fts_config {
438            file_writer = file_writer.with_fts(fts_cfg.clone());
439        }
440        let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
441        let file_size = file_bytes.len() as u64;
442
443        // Store the file
444        self.store.put(&file_path, file_bytes.clone()).await?;
445
446        // Compute centroid for catalog entry
447        let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
448
449        // Read back the HNSW offsets from the written file
450        let reader = ailake_file::AilakeFileReader::new(
451            file_bytes,
452            &self.policy.column_name,
453            self.policy.dim,
454        );
455        let header = reader.read_header()?;
456        let ailk_start = reader.ailk_offset()?;
457        let hnsw_abs_offset = ailk_start + header.hnsw_offset;
458        let hnsw_len = header.hnsw_len;
459
460        let mut entry = make_data_file_entry(
461            &file_path,
462            embeddings.len() as u64,
463            file_size,
464            &centroid,
465            VectorIndexInfo {
466                column: &self.policy.column_name,
467                dim: self.policy.dim,
468                hnsw_offset: hnsw_abs_offset,
469                hnsw_len,
470            },
471        );
472        entry.batch_id = batch_id;
473        entry.embedding_model = self
474            .policy
475            .embedding_model
476            .as_ref()
477            .map(|m| m.to_property_value());
478        entry.partition_value =
479            apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
480        self.pending_files.push(entry);
481
482        // Update BM25 IDF stats + build Bloom filter (Phase F).
483        if self.bm25_text_column.is_some() {
484            self.update_bm25_stats_from_batch(batch).await?;
485            self.build_bloom_for_file(batch, &file_path);
486        }
487        Ok(())
488    }
489
490    /// Write batch, auto-selecting the index based on detected hardware.
491    ///
492    /// Picks IVF-PQ when a CUDA GPU or ≥8 CPU cores are present AND the batch
493    /// has ≥5 000 vectors. Falls back to HNSW for weaker / local hardware.
494    /// Uses `IvfPqConfig::for_dataset` to scale nlist with dataset size.
495    pub async fn write_batch_auto(
496        &mut self,
497        batch: &RecordBatch,
498        embeddings: &[Vec<f32>],
499    ) -> AilakeResult<()> {
500        let profile = ailake_index::HardwareProfile::detect();
501        if profile.recommend_ivf_pq(embeddings.len()) {
502            let mut ivf_config =
503                ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
504            if self.policy.ivf_residual {
505                ivf_config = ivf_config.with_residual();
506            }
507            self.write_batch_ivf_pq(batch, embeddings, ivf_config).await
508        } else {
509            self.write_batch(batch, embeddings).await
510        }
511    }
512
513    /// Write batch, auto-selecting the index based on detected hardware — deferred variant.
514    ///
515    /// Same hardware detection as `write_batch_auto`: picks IVF-PQ when a CUDA GPU or
516    /// ≥8 CPU cores are present AND the batch has ≥5 000 vectors; falls back to HNSW.
517    ///
518    /// Unlike `write_batch_auto`, the index is built in a background tokio task:
519    /// - Parquet is persisted immediately (~200k vec/s, same as write_parquet_only).
520    /// - HNSW or IVF-PQ index built asynchronously; shard served via flat scan meanwhile.
521    ///
522    /// Use this when ingest throughput matters more than immediate searchability.
523    pub async fn write_batch_auto_deferred(
524        &mut self,
525        batch: &RecordBatch,
526        embeddings: &[Vec<f32>],
527    ) -> AilakeResult<()> {
528        let profile = ailake_index::HardwareProfile::detect();
529        if profile.recommend_ivf_pq(embeddings.len()) {
530            let mut ivf_config =
531                ailake_index::IvfPqConfig::for_dataset(self.policy.dim as usize, embeddings.len());
532            if self.policy.ivf_residual {
533                ivf_config = ivf_config.with_residual();
534            }
535            self.write_batch_ivf_pq_deferred(batch, embeddings, ivf_config)
536                .await
537        } else {
538            self.write_batch_deferred(batch, embeddings).await
539        }
540    }
541
542    /// Write batch with IVF-PQ index built synchronously (no background task).
543    ///
544    /// Smaller index than HNSW; better for S3 sequential-scan workloads.
545    pub async fn write_batch_ivf_pq(
546        &mut self,
547        batch: &RecordBatch,
548        embeddings: &[Vec<f32>],
549        ivf_config: IvfPqConfig,
550    ) -> AilakeResult<()> {
551        self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
552        let file_path = self.next_part_path();
553
554        // Train codebook once on the first shard; all subsequent shards reuse it.
555        // This makes cross-shard ADC distances comparable, eliminating the need
556        // for exact reranking during multi-shard search.
557        if self.cached_ivf_codebook.is_none() {
558            let codebook = tokio::task::spawn_blocking({
559                let embeddings = embeddings.to_vec();
560                let metric = self.policy.metric;
561                let config = ivf_config.clone();
562                move || ailake_index::IvfPqIndex::train_codebook(&embeddings, metric, &config)
563            })
564            .await
565            .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
566            self.cached_ivf_codebook = Some(Arc::new(codebook));
567        }
568        // SAFETY: set to Some in the block above (either pre-existing or just trained).
569        let codebook = self
570            .cached_ivf_codebook
571            .as_ref()
572            .expect("IVF-PQ codebook must be Some after training block")
573            .clone();
574
575        let file_writer = AilakeFileWriter::new(self.policy.clone())
576            .with_index_type(IndexType::IvfPq(ivf_config))
577            .with_shared_ivf_codebook(codebook);
578        let file_bytes: Bytes = file_writer.write(batch, embeddings)?;
579        let file_size = file_bytes.len() as u64;
580
581        self.store.put(&file_path, file_bytes.clone()).await?;
582
583        let centroid = compute_centroid_and_radius(embeddings, self.policy.metric);
584
585        let reader = ailake_file::AilakeFileReader::new(
586            file_bytes,
587            &self.policy.column_name,
588            self.policy.dim,
589        );
590        let header = reader.read_header()?;
591        let ailk_start = reader.ailk_offset()?;
592        let index_abs_offset = ailk_start + header.hnsw_offset;
593        let index_len = header.hnsw_len;
594
595        let mut entry = make_data_file_entry(
596            &file_path,
597            embeddings.len() as u64,
598            file_size,
599            &centroid,
600            VectorIndexInfo {
601                column: &self.policy.column_name,
602                dim: self.policy.dim,
603                hnsw_offset: index_abs_offset,
604                hnsw_len: index_len,
605            },
606        );
607        entry.embedding_model = self
608            .policy
609            .embedding_model
610            .as_ref()
611            .map(|m| m.to_property_value());
612        entry.partition_value =
613            apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
614        self.pending_files.push(entry);
615        Ok(())
616    }
617
618    /// Write a batch with multiple vector columns into a single AI-Lake file.
619    ///
620    /// The first entry in `columns` is treated as the primary column (used for
621    /// geometric pruning). Additional columns each get their own HNSW section.
622    pub async fn write_batch_multi(
623        &mut self,
624        batch: &RecordBatch,
625        columns: &[MultiVectorBatch<'_>],
626    ) -> AilakeResult<()> {
627        use ailake_core::AilakeError;
628        self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
629        if self.extra_vec_policies.is_empty() && columns.len() > 1 {
630            self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
631        }
632
633        if columns.is_empty() {
634            return Err(AilakeError::InvalidArgument(
635                "write_batch_multi requires at least one column".into(),
636            ));
637        }
638
639        for col in columns {
640            Self::validate_embedding_dim_for_policy(col.embeddings, &col.policy)?;
641        }
642
643        let file_path = self.next_part_path();
644
645        let col_batches: Vec<VectorColumnBatch<'_>> = columns
646            .iter()
647            .map(|c| VectorColumnBatch {
648                policy: &c.policy,
649                embeddings: c.embeddings,
650            })
651            .collect();
652
653        let primary_policy = &columns[0].policy;
654        let mut file_writer = AilakeFileWriter::new(primary_policy.clone());
655        if let Some(ref fts_cfg) = self.fts_config {
656            file_writer = file_writer.with_fts(fts_cfg.clone());
657        }
658        let file_bytes: Bytes = file_writer.write_multi(batch, &col_batches)?;
659        let file_size = file_bytes.len() as u64;
660
661        self.store.put(&file_path, file_bytes.clone()).await?;
662
663        // Primary centroid for pruning
664        let primary_centroid =
665            compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
666
667        // Read primary AILK header for offsets
668        let reader = ailake_file::AilakeFileReader::new(
669            file_bytes.clone(),
670            &primary_policy.column_name,
671            primary_policy.dim,
672        );
673        let primary_ailk_start = reader.ailk_offset()?;
674        let primary_header = {
675            use ailake_file::HEADER_SIZE;
676            let start = primary_ailk_start as usize;
677            let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
678                .try_into()
679                .map_err(|_| AilakeError::NotAnAilakeFile)?;
680            ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
681        };
682        let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
683
684        // Extra column index metadata
685        let mut extra: Vec<ExtraVectorIndex> = Vec::new();
686        for col in columns.iter().skip(1) {
687            let col_ailk_start = reader.ailk_offset_for_column(&col.policy.column_name)?;
688            let col_header = {
689                use ailake_file::HEADER_SIZE;
690                let start = col_ailk_start as usize;
691                let hdr_bytes: &[u8; HEADER_SIZE] = file_bytes[start..start + HEADER_SIZE]
692                    .try_into()
693                    .map_err(|_| AilakeError::NotAnAilakeFile)?;
694                ailake_file::AilakeHeader::from_bytes(hdr_bytes)?
695            };
696            let col_centroid = compute_centroid_and_radius(col.embeddings, col.policy.metric);
697            extra.push(ExtraVectorIndex {
698                column: col.policy.column_name.clone(),
699                dim: col.policy.dim,
700                hnsw_offset: col_ailk_start + col_header.hnsw_offset,
701                hnsw_len: col_header.hnsw_len,
702                centroid_b64: Some(encode_centroid_b64(&col_centroid)),
703                radius: Some(col_centroid.radius),
704            });
705        }
706
707        let mut entry = make_multi_column_data_file_entry(
708            &file_path,
709            columns[0].embeddings.len() as u64,
710            file_size,
711            &primary_centroid,
712            VectorIndexInfo {
713                column: &primary_policy.column_name,
714                dim: primary_policy.dim,
715                hnsw_offset: primary_hnsw_abs,
716                hnsw_len: primary_header.hnsw_len,
717            },
718            &extra,
719        );
720        entry.embedding_model = self
721            .policy
722            .embedding_model
723            .as_ref()
724            .map(|m| m.to_property_value());
725        entry.partition_value =
726            apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
727        self.pending_files.push(entry);
728        Ok(())
729    }
730
731    /// Write a multi-column batch as Parquet-only immediately; build all N column
732    /// HNSW indexes in a single background task.
733    ///
734    /// Same semantics as `write_batch_deferred` but for N vector columns:
735    /// - Parquet (primary column bytes) is persisted immediately (~200k vec/s).
736    /// - A background tokio task rebuilds the full AILK file via `write_multi` and
737    ///   patches the catalog entry with primary + extra column offsets once ready.
738    /// - During the build window, `SearchSession` serves this shard via GPU/CPU flat
739    ///   scan. Transition to HNSW-indexed search is automatic on `IndexStatus::Ready`.
740    ///
741    /// All N column embeddings are cloned into the background task; choose batch size
742    /// so that N×rows×dim×4 bytes fits comfortably in RAM while the task runs.
743    pub async fn write_batch_multi_deferred(
744        &mut self,
745        batch: &RecordBatch,
746        columns: &[MultiVectorBatch<'_>],
747    ) -> AilakeResult<()> {
748        use ailake_core::AilakeError;
749        self.ensure_deferred_supported()?;
750        if columns.is_empty() {
751            return Err(AilakeError::InvalidArgument(
752                "write_batch_multi_deferred requires at least one column".into(),
753            ));
754        }
755        self.captured_schema = Some(merge_schema(self.captured_schema.take(), &batch.schema()));
756        if self.extra_vec_policies.is_empty() && columns.len() > 1 {
757            self.extra_vec_policies = columns[1..].iter().map(|c| c.policy.clone()).collect();
758        }
759
760        let file_path = self.next_part_path();
761
762        // Immediate path: write Parquet with primary column only (no AILK sections yet).
763        let primary_policy = &columns[0].policy;
764        let file_writer = AilakeFileWriter::new(primary_policy.clone());
765        let parquet_bytes = file_writer.write_parquet_only(batch, columns[0].embeddings)?;
766        let file_size = parquet_bytes.len() as u64;
767        self.store.put(&file_path, parquet_bytes).await?;
768
769        // Primary centroid enables geometric pruning during the build window.
770        let primary_centroid =
771            compute_centroid_and_radius(columns[0].embeddings, primary_policy.metric);
772        let mut entry = make_data_file_entry_indexing(
773            &file_path,
774            columns[0].embeddings.len() as u64,
775            file_size,
776            &primary_centroid,
777            &primary_policy.column_name,
778            primary_policy.dim,
779        );
780        // Populate extra_vector_indexes with centroids/radii for pruning.
781        // hnsw_offset/len are 0 until the background task patches them to non-zero.
782        entry.extra_vector_indexes = columns[1..]
783            .iter()
784            .map(|c| {
785                let col_centroid = compute_centroid_and_radius(c.embeddings, c.policy.metric);
786                ExtraVectorIndex {
787                    column: c.policy.column_name.clone(),
788                    dim: c.policy.dim,
789                    hnsw_offset: 0,
790                    hnsw_len: 0,
791                    centroid_b64: Some(encode_centroid_b64(&col_centroid)),
792                    radius: Some(col_centroid.radius),
793                }
794            })
795            .collect();
796        entry.embedding_model = self
797            .policy
798            .embedding_model
799            .as_ref()
800            .map(|m| m.to_property_value());
801        entry.partition_value =
802            apply_partition_transforms(&self.policy, self.policy.partition_value.as_deref());
803        self.pending_files.push(entry);
804
805        // Clone all column data for the background task.
806        let all_policies: Vec<VectorStoragePolicy> =
807            columns.iter().map(|c| c.policy.clone()).collect();
808        let all_embeddings: Vec<Vec<Vec<f32>>> =
809            columns.iter().map(|c| c.embeddings.to_vec()).collect();
810        let store = self.store.clone();
811        let catalog = self.catalog.clone();
812        let table = self.table.clone();
813        let fp = file_path.clone();
814        tokio::spawn(async move {
815            if let Err(e) = build_and_patch_multi_index(
816                store,
817                catalog.clone(),
818                all_policies,
819                table.clone(),
820                fp.clone(),
821                all_embeddings,
822            )
823            .await
824            {
825                error!(
826                    "ailake: deferred multi-column HNSW build failed for {fp}: {e}; \
827                     marking IndexStatus::Failed — compaction will rebuild"
828                );
829                patch_index_failed(catalog, &table, &fp, &e.to_string()).await;
830            }
831        });
832
833        Ok(())
834    }
835
836    /// Commit all staged files as a new Iceberg snapshot.
837    ///
838    /// No-op when `pending_files` is empty (e.g., all `write_batch_idempotent`
839    /// calls were skipped because their `batch_id` was already committed).
840    /// Returns the current snapshot id in that case (or 0 if no snapshot exists yet).
841    /// Build a Bloom filter from the BM25 text column and store it for the given file.
842    /// Called alongside `update_bm25_stats_from_batch` for every write_batch. The filter
843    /// is flushed to the Puffin stats file at commit time (Phase F).
844    fn build_bloom_for_file(&mut self, batch: &RecordBatch, file_path: &str) {
845        use arrow_array::cast::AsArray;
846        let col_name = match &self.bm25_text_column {
847            Some(c) => c.clone(),
848            None => return,
849        };
850        let col = match batch.column_by_name(&col_name) {
851            Some(c) => c,
852            None => return,
853        };
854        let str_arr = match col.as_string_opt::<i32>() {
855            Some(a) => a,
856            None => return,
857        };
858        // Size the filter for ~10× unique terms per row at 1% FPR.
859        let cap = (batch.num_rows() * 10).max(128);
860        let mut bloom = crate::bloom::BloomFilter::with_capacity(cap, 0.01);
861        for i in 0..str_arr.len() {
862            if str_arr.is_valid(i) {
863                for term in crate::bm25::tokenize(str_arr.value(i)) {
864                    bloom.insert(&term);
865                }
866            }
867        }
868        self.pending_blooms
869            .push((file_path.to_string(), bloom.to_bytes()));
870    }
871
872    /// Update BM25 IDF stats from a batch's text column and persist to storage.
873    ///
874    /// Read-modify-write: loads existing stats (if any), merges new DF counts,
875    /// writes back. Concurrent writers may lose some DF deltas; acceptable for
876    /// approximate BM25 (same as Iceberg without OCC). Compaction rebuilds accurately.
877    async fn update_bm25_stats_from_batch(&self, batch: &RecordBatch) -> AilakeResult<()> {
878        use arrow_array::cast::AsArray;
879
880        let col_name = match &self.bm25_text_column {
881            Some(c) => c.as_str(),
882            None => return Ok(()),
883        };
884        let col = match batch.column_by_name(col_name) {
885            Some(c) => c,
886            None => {
887                tracing::warn!(
888                    "ailake: BM25 text column '{}' not found in batch — skipping IDF update",
889                    col_name
890                );
891                return Ok(());
892            }
893        };
894        let str_arr = match col.as_string_opt::<i32>() {
895            Some(a) => a,
896            None => {
897                tracing::warn!(
898                    "ailake: BM25 text column '{}' is not a Utf8 column — skipping",
899                    col_name
900                );
901                return Ok(());
902            }
903        };
904
905        let texts: Vec<&str> = (0..str_arr.len())
906            .filter(|&i| str_arr.is_valid(i))
907            .map(|i| str_arr.value(i))
908            .collect();
909
910        // Load existing stats
911        let stats_path = crate::bm25::BM25_STATS_FILE;
912        let mut stats: crate::bm25::IdfStats = match self.store.get(stats_path).await {
913            Ok(bytes) => crate::bm25::IdfStats::from_bytes(&bytes).unwrap_or_default(),
914            Err(_) => crate::bm25::IdfStats::default(),
915        };
916
917        stats.merge_batch(&texts);
918
919        let bytes = stats.to_bytes()?;
920        self.store
921            .put(stats_path, bytes::Bytes::from(bytes))
922            .await?;
923        Ok(())
924    }
925
926    pub async fn commit(mut self) -> AilakeResult<SnapshotId> {
927        if self.pending_files.is_empty() {
928            let current = self
929                .catalog
930                .load_table(&self.table)
931                .await
932                .ok()
933                .and_then(|m| m.current_snapshot_id)
934                .unwrap_or(0);
935            return Ok(current);
936        }
937        let iceberg_schema = self
938            .captured_schema
939            .as_deref()
940            .map(|s| arrow_schema_to_iceberg_update(s, &self.policy, &self.extra_vec_policies));
941        // Store secondary column dims/metrics as table-level properties so
942        // search_multimodal can discover them without reading Parquet files.
943        let mut extra_properties = std::collections::HashMap::new();
944        if let Some(ref fts_cfg) = self.fts_config {
945            extra_properties.insert("ailake.fts.enabled".to_string(), "true".to_string());
946            extra_properties.insert(
947                "ailake.fts.text-columns".to_string(),
948                fts_cfg.text_columns.join(","),
949            );
950            extra_properties.insert(
951                "ailake.fts.tokenizer".to_string(),
952                fts_cfg.tokenizer.clone(),
953            );
954        }
955        for ep in &self.extra_vec_policies {
956            extra_properties.insert(format!("ailake.dim-{}", ep.column_name), ep.dim.to_string());
957            extra_properties.insert(
958                format!("ailake.metric-{}", ep.column_name),
959                ailake_parquet::schema::metric_str(ep.metric).to_string(),
960            );
961            if let Some(modality) = ep.modality {
962                extra_properties.insert(
963                    format!("ailake.modality-{}", ep.column_name),
964                    modality.as_str().to_string(),
965                );
966            }
967        }
968        let snapshot = NewSnapshot {
969            snapshot_id: new_snapshot_id(),
970            parent_snapshot_id: self.parent_snapshot_id,
971            files: std::mem::take(&mut self.pending_files),
972            operation: SnapshotOperation::Append,
973            iceberg_schema,
974            extra_properties,
975            bloom_filters: std::mem::take(&mut self.pending_blooms),
976            equality_delete_files: vec![],
977        };
978        self.catalog.commit_snapshot(&self.table, snapshot).await
979    }
980
981    /// Create a table if it doesn't exist, then return a writer for it.
982    pub async fn create_or_open(
983        catalog: Arc<dyn CatalogProvider>,
984        store: Arc<dyn Store>,
985        policy: VectorStoragePolicy,
986        table: TableIdent,
987        format_version: u8,
988    ) -> AilakeResult<Self> {
989        // Part-path uniqueness across sessions comes from `session_ts` in the
990        // file name (see `next_part_path`), not from seeding the counter with
991        // the current file count — that seed was wrong anyway: compaction
992        // shrinks the count, making a later writer reuse a retired file's name.
993        match catalog.load_table(&table).await {
994            Ok(existing_meta) => {
995                // Hard error: dim stored in table metadata must match the policy dim.
996                // validate_embedding_dim() only checks vectors vs policy.dim; without this
997                // check a caller can open with dim=16 on a dim=8 table and silently corrupt it.
998                if let Some(stored_dim_str) = existing_meta.properties.get("ailake.vector-dim") {
999                    if let Ok(stored_dim) = stored_dim_str.parse::<u32>() {
1000                        if stored_dim != policy.dim {
1001                            let table_model = policy
1002                                .embedding_model
1003                                .as_ref()
1004                                .map(|m| m.to_property_value())
1005                                .unwrap_or_else(|| format!("dim={}", stored_dim));
1006                            return Err(AilakeError::ModelMismatch {
1007                                table_model,
1008                                table_dim: stored_dim,
1009                                batch_model: format!("dim={}", policy.dim),
1010                                batch_dim: policy.dim,
1011                            });
1012                        }
1013                    }
1014                }
1015                // Warn when writing with a different model name into an existing table.
1016                // Name divergence is softer — same dim, different model (e.g. fine-tune vs
1017                // base) — warn only.
1018                if let Some(incoming) = &policy.embedding_model {
1019                    if let Some(stored_val) = existing_meta
1020                        .properties
1021                        .get(EmbeddingModelInfo::property_key())
1022                    {
1023                        let stored = EmbeddingModelInfo::from_property_value(stored_val);
1024                        if stored.name != incoming.name {
1025                            warn!(
1026                                "ailake: embedding model name changed: table has '{}', writing with '{}' \
1027                                 (dim={}). Vectors may be incompatible for similarity search.",
1028                                stored.name, incoming.name, policy.dim
1029                            );
1030                        }
1031                    }
1032                }
1033            }
1034            Err(_) => {
1035                catalog
1036                    .create_table(
1037                        &table,
1038                        &TableProperties {
1039                            partition_column_type: policy.partition_column_type.clone(),
1040                            policy: policy.clone(),
1041                            extra: std::collections::HashMap::new(),
1042                            format_version,
1043                        },
1044                    )
1045                    .await?;
1046            }
1047        }
1048        let parent_snapshot_id = catalog
1049            .load_table(&table)
1050            .await
1051            .ok()
1052            .and_then(|m| m.current_snapshot_id);
1053        let mut writer = Self::new(catalog, store, policy, table);
1054        writer.parent_snapshot_id = parent_snapshot_id;
1055        Ok(writer)
1056    }
1057}
1058
1059/// Convert an Arrow schema to an Iceberg schema update for catalog commits.
1060///
1061/// Top-level field IDs are assigned sequentially (1-based) and match the
1062/// `PARQUET:field_id` stamps written by `ParquetVectorWriter`. Nested element
1063/// IDs (inside List/Struct/Map) are assigned after all top-level IDs are
1064/// pre-reserved, so they never collide with Parquet column field IDs.
1065fn arrow_schema_to_iceberg_update(
1066    schema: &arrow_schema::Schema,
1067    policy: &VectorStoragePolicy,
1068    extra_vec_policies: &[VectorStoragePolicy],
1069) -> IcebergSchemaUpdate {
1070    let bytes_per_dim = policy.precision.bytes_per_element() as u32;
1071    let vec_fixed_len = policy.dim * bytes_per_dim;
1072
1073    // Collect all vector column names that will appear in the final schema.
1074    let has_primary_in_batch = schema
1075        .fields()
1076        .iter()
1077        .any(|f| f.name() == &policy.column_name);
1078    let vec_cols: Vec<(String, u32)> = {
1079        let mut v = Vec::new();
1080        if !has_primary_in_batch {
1081            v.push((policy.column_name.clone(), vec_fixed_len));
1082        }
1083        for ep in extra_vec_policies {
1084            let ep_fixed_len = ep.dim * ep.precision.bytes_per_element() as u32;
1085            if !schema.fields().iter().any(|f| f.name() == &ep.column_name) {
1086                v.push((ep.column_name.clone(), ep_fixed_len));
1087            }
1088        }
1089        v
1090    };
1091
1092    // Total top-level columns = batch fields + appended vec columns.
1093    let top_level_count = schema.fields().len() + vec_cols.len();
1094    // Nested element IDs start after all top-level IDs are pre-reserved.
1095    let mut nested_id = top_level_count as i32;
1096
1097    let mut fields: Vec<serde_json::Value> = Vec::new();
1098    let mut name_mapping: Vec<serde_json::Value> = Vec::new();
1099
1100    for (idx, field) in schema.fields().iter().enumerate() {
1101        let field_id = (idx + 1) as i32;
1102        let iceberg_type = arrow_type_to_iceberg(field.data_type(), &mut nested_id);
1103        fields.push(serde_json::json!({
1104            "id": field_id,
1105            "name": field.name(),
1106            "required": false,
1107            "type": iceberg_type,
1108        }));
1109        name_mapping.push(serde_json::json!({
1110            "field-id": field_id,
1111            "names": [field.name()],
1112        }));
1113    }
1114
1115    // Append vector columns that live outside the RecordBatch schema.
1116    for (i, (col_name, fixed_len)) in vec_cols.iter().enumerate() {
1117        let field_id = (schema.fields().len() + 1 + i) as i32;
1118        fields.push(serde_json::json!({
1119            "id": field_id,
1120            "name": col_name,
1121            "required": false,
1122            "type": format!("fixed[{fixed_len}]"),
1123        }));
1124        name_mapping.push(serde_json::json!({
1125            "field-id": field_id,
1126            "names": [col_name],
1127        }));
1128    }
1129
1130    let last_column_id = nested_id;
1131    let name_mapping_json = serde_json::to_string(&name_mapping).unwrap_or_else(|_| "[]".into());
1132
1133    IcebergSchemaUpdate {
1134        fields,
1135        last_column_id,
1136        name_mapping_json,
1137    }
1138}
1139
1140/// Map an Arrow DataType to an Iceberg schema type value (string or JSON object).
1141///
1142/// `nested_id` is a shared counter for generating unique element/field IDs inside
1143/// List, Struct, and Map types. It must start beyond all pre-reserved top-level IDs.
1144fn arrow_type_to_iceberg(dt: &arrow_schema::DataType, nested_id: &mut i32) -> serde_json::Value {
1145    use arrow_schema::DataType;
1146    match dt {
1147        DataType::Boolean => serde_json::json!("boolean"),
1148        DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::UInt8 | DataType::UInt16 => {
1149            serde_json::json!("int")
1150        }
1151        DataType::Int64 | DataType::UInt32 | DataType::UInt64 => serde_json::json!("long"),
1152        DataType::Float16 | DataType::Float32 => serde_json::json!("float"),
1153        DataType::Float64 => serde_json::json!("double"),
1154        DataType::Utf8 | DataType::LargeUtf8 | DataType::Utf8View => serde_json::json!("string"),
1155        DataType::Binary | DataType::LargeBinary | DataType::BinaryView => {
1156            serde_json::json!("binary")
1157        }
1158        DataType::Date32 | DataType::Date64 => serde_json::json!("date"),
1159        // Timestamp with timezone → timestamptz; without → timestamp.
1160        DataType::Timestamp(_, Some(_)) => serde_json::json!("timestamptz"),
1161        DataType::Timestamp(_, None) => serde_json::json!("timestamp"),
1162        DataType::Time32(_) | DataType::Time64(_) => serde_json::json!("time"),
1163        DataType::FixedSizeBinary(n) => serde_json::json!(format!("fixed[{n}]")),
1164        DataType::Decimal128(p, s) | DataType::Decimal256(p, s) => {
1165            serde_json::json!(format!("decimal({p}, {s})"))
1166        }
1167        DataType::List(inner)
1168        | DataType::LargeList(inner)
1169        | DataType::ListView(inner)
1170        | DataType::FixedSizeList(inner, _) => {
1171            *nested_id += 1;
1172            let element_id = *nested_id;
1173            let element_type = arrow_type_to_iceberg(inner.data_type(), nested_id);
1174            serde_json::json!({
1175                "type": "list",
1176                "element-id": element_id,
1177                "element": element_type,
1178                "element-required": !inner.is_nullable(),
1179            })
1180        }
1181        DataType::Struct(arrow_fields) => {
1182            let struct_fields: Vec<serde_json::Value> = arrow_fields
1183                .iter()
1184                .map(|f| {
1185                    *nested_id += 1;
1186                    let fid = *nested_id;
1187                    let ftype = arrow_type_to_iceberg(f.data_type(), nested_id);
1188                    serde_json::json!({
1189                        "id": fid,
1190                        "name": f.name(),
1191                        "required": !f.is_nullable(),
1192                        "type": ftype,
1193                    })
1194                })
1195                .collect();
1196            serde_json::json!({ "type": "struct", "fields": struct_fields })
1197        }
1198        DataType::Map(entries, _) => {
1199            // Arrow Map is List<Struct<key: K, value: V>>.
1200            *nested_id += 1;
1201            let key_id = *nested_id;
1202            *nested_id += 1;
1203            let val_id = *nested_id;
1204            if let DataType::Struct(kv_fields) = entries.data_type() {
1205                let key_f = kv_fields
1206                    .iter()
1207                    .find(|f| f.name() == "key" || f.name() == "keys");
1208                let val_f = kv_fields
1209                    .iter()
1210                    .find(|f| f.name() == "value" || f.name() == "values");
1211                let key_type = key_f
1212                    .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1213                    .unwrap_or(serde_json::json!("binary"));
1214                let val_type = val_f
1215                    .map(|f| arrow_type_to_iceberg(f.data_type(), nested_id))
1216                    .unwrap_or(serde_json::json!("binary"));
1217                let val_required = val_f.map(|f| !f.is_nullable()).unwrap_or(false);
1218                serde_json::json!({
1219                    "type": "map",
1220                    "key-id": key_id,
1221                    "key": key_type,
1222                    "value-id": val_id,
1223                    "value": val_type,
1224                    "value-required": val_required,
1225                })
1226            } else {
1227                serde_json::json!("binary")
1228            }
1229        }
1230        _ => serde_json::json!("binary"),
1231    }
1232}
1233
1234/// Background task: reads a Parquet-only shard, builds full AILK file, patches catalog.
1235/// Mark a file's catalog entry as `IndexStatus::Failed` with a reason.
1236/// Best-effort: if the catalog commit itself fails, the error is logged and
1237/// the file stays in `Indexing` state (compaction will rebuild it).
1238async fn patch_index_failed(
1239    catalog: Arc<dyn CatalogProvider>,
1240    table: &TableIdent,
1241    file_path: &str,
1242    reason: &str,
1243) {
1244    let Ok(table_meta) = catalog.load_table(table).await else {
1245        return;
1246    };
1247    let parent_snapshot_id = table_meta.current_snapshot_id;
1248    let Ok(mut files) = catalog.list_files(table, None).await else {
1249        return;
1250    };
1251    for f in &mut files {
1252        if f.path == file_path {
1253            f.index_status = IndexStatus::Failed;
1254            f.index_error = Some(reason.to_string());
1255            break;
1256        }
1257    }
1258    let _ = catalog
1259        .commit_snapshot(
1260            table,
1261            NewSnapshot {
1262                snapshot_id: new_snapshot_id(),
1263                parent_snapshot_id,
1264                files,
1265                operation: SnapshotOperation::Replace,
1266                iceberg_schema: None,
1267                extra_properties: std::collections::HashMap::new(),
1268                bloom_filters: vec![],
1269                equality_delete_files: vec![],
1270            },
1271        )
1272        .await
1273        .map_err(|e| {
1274            error!(
1275                "ailake: failed to write IndexStatus::Failed for {file_path}: {e}; \
1276                 file will remain Indexing until compaction"
1277            )
1278        });
1279}
1280
1281pub(crate) async fn build_and_patch_index(
1282    store: Arc<dyn Store>,
1283    catalog: Arc<dyn CatalogProvider>,
1284    policy: VectorStoragePolicy,
1285    table: TableIdent,
1286    file_path: String,
1287) -> AilakeResult<()> {
1288    // Read the Parquet-only bytes already stored.
1289    let parquet_bytes = store.get(&file_path).await?;
1290    let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1291    let (batch, embeddings) = reader.read_parquet()?;
1292
1293    // Build the full AILK file (Parquet + HNSW) — CPU-intensive; run on blocking pool
1294    // so the tokio async threads aren't starved when many shards build concurrently.
1295    let full_bytes = tokio::task::spawn_blocking({
1296        let policy = policy.clone();
1297        move || {
1298            let file_writer = AilakeFileWriter::new(policy);
1299            file_writer.write(&batch, &embeddings)
1300        }
1301    })
1302    .await
1303    .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1304
1305    // Extract HNSW offsets from the newly written file.
1306    let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1307    let header = full_reader.read_header()?;
1308    let ailk_start = full_reader.ailk_offset()?;
1309    let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1310    let hnsw_len = header.hnsw_len;
1311
1312    // Positional invariant check — see `compaction.rs::compact()` for rationale. This is
1313    // the single build point shared by every deferred-index path (plain deferred insert
1314    // via write_batch_deferred, and compact_deferred's background job), so checking here
1315    // covers both instead of requiring each caller to remember it.
1316    full_reader.verify_integrity()?;
1317
1318    // Overwrite the Parquet-only file with the full AILK version.
1319    store.put(&file_path, full_bytes).await?;
1320
1321    // Wait for the initial writer commit to appear (max 60 s).
1322    // HNSW builds can finish before the main write loop calls commit_snapshot.
1323    let mut committed = false;
1324    for _ in 0..120u32 {
1325        match catalog.load_table(&table).await {
1326            Ok(meta) if meta.current_snapshot_id.is_some() => {
1327                committed = true;
1328                break;
1329            }
1330            _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1331        }
1332    }
1333    if !committed {
1334        return Err(ailake_core::AilakeError::Store(format!(
1335            "deferred HNSW build: no snapshot committed for {file_path} after 60 s — \
1336             did you call TableWriter::commit()?"
1337        )));
1338    }
1339
1340    // Update the catalog with CAS-like retry to handle concurrent background tasks.
1341    // Multiple tasks can race on commit_snapshot(Replace): the last writer wins and
1342    // may overwrite a sibling task's Ready status. Retry until we confirm our file
1343    // is marked Ready in the current snapshot.
1344    for attempt in 0..50u32 {
1345        let table_meta = catalog.load_table(&table).await?;
1346        let parent_snapshot_id = table_meta.current_snapshot_id;
1347        let mut files = catalog.list_files(&table, None).await?;
1348
1349        // Already marked Ready by a previous successful attempt.
1350        if files
1351            .iter()
1352            .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1353        {
1354            break;
1355        }
1356
1357        for f in &mut files {
1358            if f.path == file_path {
1359                f.hnsw_offset = Some(hnsw_abs_offset);
1360                f.hnsw_len = Some(hnsw_len);
1361                f.index_status = IndexStatus::Ready;
1362                break;
1363            }
1364        }
1365        catalog
1366            .commit_snapshot(
1367                &table,
1368                NewSnapshot {
1369                    snapshot_id: new_snapshot_id(),
1370                    parent_snapshot_id,
1371                    files,
1372                    operation: SnapshotOperation::Replace,
1373                    iceberg_schema: None,
1374                    extra_properties: std::collections::HashMap::new(),
1375                    bloom_filters: vec![],
1376                    equality_delete_files: vec![],
1377                },
1378            )
1379            .await?;
1380
1381        // Brief yield so sibling tasks can commit, then verify our change survived.
1382        tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1383
1384        let verify = catalog.list_files(&table, None).await?;
1385        if verify
1386            .iter()
1387            .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1388        {
1389            break;
1390        }
1391        // Another task overwrote us — retry.
1392    }
1393
1394    info!(
1395        "ailake: deferred HNSW index built for {} (offset={}, len={})",
1396        file_path, hnsw_abs_offset, hnsw_len
1397    );
1398    Ok(())
1399}
1400
1401/// Background task: train IVF-PQ (using shared codebook) and patch catalog entry.
1402///
1403/// The OnceCell guarantees that k-means training runs exactly once across all
1404/// concurrent background tasks — subsequent tasks skip directly to assign+encode.
1405async fn build_ivf_pq_and_patch_index(
1406    store: Arc<dyn Store>,
1407    catalog: Arc<dyn CatalogProvider>,
1408    policy: VectorStoragePolicy,
1409    table: TableIdent,
1410    file_path: String,
1411    ivf_config: IvfPqConfig,
1412    codebook_cell: Arc<tokio::sync::OnceCell<IvfPqCodebook>>,
1413) -> AilakeResult<()> {
1414    let parquet_bytes = store.get(&file_path).await?;
1415    let reader = AilakeFileReader::new(parquet_bytes, &policy.column_name, policy.dim);
1416    let (batch, embeddings) = reader.read_parquet()?;
1417
1418    // Get or train the shared codebook. First task trains; all others await the result.
1419    let codebook = codebook_cell
1420        .get_or_try_init(|| async {
1421            let vecs = embeddings.clone();
1422            let metric = policy.metric;
1423            let cfg = ivf_config.clone();
1424            tokio::task::spawn_blocking(move || {
1425                ailake_index::IvfPqIndex::train_codebook(&vecs, metric, &cfg)
1426            })
1427            .await
1428            .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))?
1429        })
1430        .await?;
1431
1432    let full_bytes = tokio::task::spawn_blocking({
1433        let policy = policy.clone();
1434        let codebook = codebook.clone();
1435        move || {
1436            let file_writer = AilakeFileWriter::new(policy)
1437                .with_index_type(IndexType::IvfPq(ivf_config))
1438                .with_shared_ivf_codebook(Arc::new(codebook));
1439            file_writer.write(&batch, &embeddings)
1440        }
1441    })
1442    .await
1443    .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1444
1445    let full_reader = AilakeFileReader::new(full_bytes.clone(), &policy.column_name, policy.dim);
1446    let header = full_reader.read_header()?;
1447    let ailk_start = full_reader.ailk_offset()?;
1448    let hnsw_abs_offset = ailk_start + header.hnsw_offset;
1449    let hnsw_len = header.hnsw_len;
1450
1451    store.put(&file_path, full_bytes).await?;
1452
1453    // Wait for initial commit to appear then patch IndexStatus::Ready (max 60 s).
1454    let mut committed = false;
1455    for _ in 0..120u32 {
1456        match catalog.load_table(&table).await {
1457            Ok(meta) if meta.current_snapshot_id.is_some() => {
1458                committed = true;
1459                break;
1460            }
1461            _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1462        }
1463    }
1464    if !committed {
1465        return Err(ailake_core::AilakeError::Store(format!(
1466            "deferred IVF-PQ build: no snapshot committed for {file_path} after 60 s — \
1467             did you call TableWriter::commit()?"
1468        )));
1469    }
1470
1471    for attempt in 0..50u32 {
1472        let table_meta = catalog.load_table(&table).await?;
1473        let parent_snapshot_id = table_meta.current_snapshot_id;
1474        let mut files = catalog.list_files(&table, None).await?;
1475
1476        if files
1477            .iter()
1478            .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1479        {
1480            break;
1481        }
1482
1483        for f in &mut files {
1484            if f.path == file_path {
1485                f.hnsw_offset = Some(hnsw_abs_offset);
1486                f.hnsw_len = Some(hnsw_len);
1487                f.index_status = IndexStatus::Ready;
1488                break;
1489            }
1490        }
1491        catalog
1492            .commit_snapshot(
1493                &table,
1494                NewSnapshot {
1495                    snapshot_id: new_snapshot_id(),
1496                    parent_snapshot_id,
1497                    files,
1498                    operation: SnapshotOperation::Replace,
1499                    iceberg_schema: None,
1500                    extra_properties: std::collections::HashMap::new(),
1501                    bloom_filters: vec![],
1502                    equality_delete_files: vec![],
1503                },
1504            )
1505            .await?;
1506
1507        tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1508
1509        let verify = catalog.list_files(&table, None).await?;
1510        if verify
1511            .iter()
1512            .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1513        {
1514            break;
1515        }
1516    }
1517
1518    info!(
1519        "ailake: deferred IVF-PQ index built for {} (offset={}, len={})",
1520        file_path, hnsw_abs_offset, hnsw_len
1521    );
1522    Ok(())
1523}
1524
1525/// Background task: rebuild full multi-column AILK file and patch all column offsets.
1526///
1527/// Reads the Parquet-only shard, calls `write_multi` with all N column embeddings
1528/// (cloned from the caller), extracts per-column HNSW offsets, overwrites the file,
1529/// then applies the same CAS retry loop used by single-column deferred tasks.
1530async fn build_and_patch_multi_index(
1531    store: Arc<dyn Store>,
1532    catalog: Arc<dyn CatalogProvider>,
1533    policies: Vec<VectorStoragePolicy>,
1534    table: TableIdent,
1535    file_path: String,
1536    all_embeddings: Vec<Vec<Vec<f32>>>,
1537) -> AilakeResult<()> {
1538    // Read the Parquet-only shard (primary column only).
1539    let parquet_bytes = store.get(&file_path).await?;
1540    let primary_reader =
1541        AilakeFileReader::new(parquet_bytes, &policies[0].column_name, policies[0].dim);
1542    let (batch, _) = primary_reader.read_parquet()?;
1543
1544    // Build full AILK file with all N column HNSW sections on the blocking pool.
1545    let full_bytes = tokio::task::spawn_blocking({
1546        let policies = policies.clone();
1547        let all_embeddings = all_embeddings.clone();
1548        move || {
1549            let col_batches: Vec<VectorColumnBatch<'_>> = policies
1550                .iter()
1551                .zip(all_embeddings.iter())
1552                .map(|(p, embs)| VectorColumnBatch {
1553                    policy: p,
1554                    embeddings: embs.as_slice(),
1555                })
1556                .collect();
1557            let file_writer = AilakeFileWriter::new(policies[0].clone());
1558            file_writer.write_multi(&batch, &col_batches)
1559        }
1560    })
1561    .await
1562    .map_err(|e| ailake_core::AilakeError::Store(format!("spawn_blocking panic: {e}")))??;
1563
1564    // Extract primary HNSW offsets.
1565    let primary_reader = AilakeFileReader::new(
1566        full_bytes.clone(),
1567        &policies[0].column_name,
1568        policies[0].dim,
1569    );
1570    let primary_header = primary_reader.read_header()?;
1571    let primary_ailk_start = primary_reader.ailk_offset()?;
1572    let primary_hnsw_abs = primary_ailk_start + primary_header.hnsw_offset;
1573    let primary_hnsw_len = primary_header.hnsw_len;
1574
1575    // Extract extra column HNSW offsets (one reader per column).
1576    // Must use ailk_offset_for_column / read_header_for_column so each column's
1577    // own `ailake.{col}.footer_offset` is used — ailk_offset() always returns the
1578    // primary column offset, which is wrong for extra columns.
1579    let mut extra_offsets: Vec<(u64, u64)> = Vec::with_capacity(policies.len().saturating_sub(1));
1580    for col_policy in policies.iter().skip(1) {
1581        let col_reader =
1582            AilakeFileReader::new(full_bytes.clone(), &col_policy.column_name, col_policy.dim);
1583        let col_ailk_start = col_reader.ailk_offset_for_column(&col_policy.column_name)?;
1584        let col_header = col_reader.read_header_for_column(&col_policy.column_name)?;
1585        extra_offsets.push((col_ailk_start + col_header.hnsw_offset, col_header.hnsw_len));
1586    }
1587
1588    // Overwrite the Parquet-only shard with the full AILK file.
1589    store.put(&file_path, full_bytes).await?;
1590
1591    // Wait for the initial writer commit to appear (max 60 s).
1592    let mut committed = false;
1593    for _ in 0..120u32 {
1594        match catalog.load_table(&table).await {
1595            Ok(meta) if meta.current_snapshot_id.is_some() => {
1596                committed = true;
1597                break;
1598            }
1599            _ => tokio::time::sleep(std::time::Duration::from_millis(500)).await,
1600        }
1601    }
1602    if !committed {
1603        return Err(ailake_core::AilakeError::Store(format!(
1604            "deferred index build: no snapshot committed for {file_path} after 60 s — \
1605             did you call TableWriter::commit()?"
1606        )));
1607    }
1608
1609    // CAS retry loop: patch primary offsets + extra_vector_indexes + IndexStatus::Ready.
1610    for attempt in 0..50u32 {
1611        let table_meta = catalog.load_table(&table).await?;
1612        let parent_snapshot_id = table_meta.current_snapshot_id;
1613        let mut files = catalog.list_files(&table, None).await?;
1614
1615        if files
1616            .iter()
1617            .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1618        {
1619            break;
1620        }
1621
1622        for f in &mut files {
1623            if f.path == file_path {
1624                f.hnsw_offset = Some(primary_hnsw_abs);
1625                f.hnsw_len = Some(primary_hnsw_len);
1626                f.index_status = IndexStatus::Ready;
1627                for (i, &(off, len)) in extra_offsets.iter().enumerate() {
1628                    if let Some(xi) = f.extra_vector_indexes.get_mut(i) {
1629                        xi.hnsw_offset = off;
1630                        xi.hnsw_len = len;
1631                    }
1632                }
1633                break;
1634            }
1635        }
1636        catalog
1637            .commit_snapshot(
1638                &table,
1639                NewSnapshot {
1640                    snapshot_id: new_snapshot_id(),
1641                    parent_snapshot_id,
1642                    files,
1643                    operation: SnapshotOperation::Replace,
1644                    iceberg_schema: None,
1645                    extra_properties: std::collections::HashMap::new(),
1646                    bloom_filters: vec![],
1647                    equality_delete_files: vec![],
1648                },
1649            )
1650            .await?;
1651
1652        tokio::time::sleep(std::time::Duration::from_millis(10 + attempt as u64 * 5)).await;
1653
1654        let verify = catalog.list_files(&table, None).await?;
1655        if verify
1656            .iter()
1657            .any(|f| f.path == file_path && f.index_status == IndexStatus::Ready)
1658        {
1659            break;
1660        }
1661    }
1662
1663    info!(
1664        "ailake: deferred multi-column HNSW built for {} ({} cols, primary offset={})",
1665        file_path,
1666        policies.len(),
1667        primary_hnsw_abs
1668    );
1669    Ok(())
1670}
1671
1672#[cfg(test)]
1673mod tests {
1674    use super::*;
1675    use ailake_core::{VectorMetric, VectorPrecision};
1676    use arrow_schema::{DataType, Field, Schema, TimeUnit};
1677
1678    fn policy(col: &str, dim: u32) -> VectorStoragePolicy {
1679        VectorStoragePolicy {
1680            column_name: col.to_string(),
1681            dim,
1682            metric: VectorMetric::Cosine,
1683            precision: VectorPrecision::F16,
1684            pq: None,
1685            keep_raw_for_reranking: true,
1686            pre_normalize: false,
1687            hnsw_m: None,
1688            hnsw_ef_construction: None,
1689            ivf_residual: false,
1690            embedding_model: None,
1691            modality: None,
1692            partition_by: None,
1693            partition_value: None,
1694            partition_column_type: None,
1695            partition_fields: vec![],
1696        }
1697    }
1698
1699    fn update_for(schema: &Schema, pol: &VectorStoragePolicy) -> IcebergSchemaUpdate {
1700        arrow_schema_to_iceberg_update(schema, pol, &[])
1701    }
1702
1703    #[test]
1704    fn simple_schema_produces_correct_fields() {
1705        let schema = Schema::new(vec![
1706            Field::new("id", DataType::Int32, false),
1707            Field::new("text", DataType::Utf8, false),
1708        ]);
1709        let pol = policy("embedding", 8);
1710        let upd = update_for(&schema, &pol);
1711
1712        assert_eq!(upd.fields.len(), 3);
1713        assert_eq!(upd.fields[0]["id"], 1);
1714        assert_eq!(upd.fields[0]["type"], "int");
1715        assert_eq!(upd.fields[1]["id"], 2);
1716        assert_eq!(upd.fields[1]["type"], "string");
1717        assert_eq!(upd.fields[2]["id"], 3);
1718        assert_eq!(upd.fields[2]["type"], "fixed[16]"); // dim=8, F16=2 bytes
1719
1720        let nm: Vec<serde_json::Value> = serde_json::from_str(&upd.name_mapping_json).unwrap();
1721        assert_eq!(nm.len(), 3);
1722        assert_eq!(nm[2]["field-id"], 3);
1723        assert_eq!(nm[2]["names"][0], "embedding");
1724        assert_eq!(upd.last_column_id, 3);
1725    }
1726
1727    #[test]
1728    fn timestamp_without_tz_maps_to_timestamp_not_timestamptz() {
1729        let schema = Schema::new(vec![
1730            Field::new(
1731                "created_at",
1732                DataType::Timestamp(TimeUnit::Microsecond, None),
1733                true,
1734            ),
1735            Field::new(
1736                "updated_at",
1737                DataType::Timestamp(TimeUnit::Microsecond, Some("UTC".into())),
1738                true,
1739            ),
1740        ]);
1741        let pol = policy("vec", 4);
1742        let upd = update_for(&schema, &pol);
1743
1744        assert_eq!(upd.fields[0]["type"], "timestamp");
1745        assert_eq!(upd.fields[1]["type"], "timestamptz");
1746    }
1747
1748    #[test]
1749    fn list_type_produces_iceberg_list_object() {
1750        let schema = Schema::new(vec![Field::new(
1751            "tags",
1752            DataType::List(std::sync::Arc::new(Field::new(
1753                "item",
1754                DataType::Utf8,
1755                true,
1756            ))),
1757            true,
1758        )]);
1759        let pol = policy("vec", 4);
1760        let upd = update_for(&schema, &pol);
1761
1762        let t = &upd.fields[0]["type"];
1763        assert_eq!(t["type"], "list");
1764        assert_eq!(t["element"], "string");
1765        // element-id must be > top-level field count (2: tags + vec)
1766        assert!(t["element-id"].as_i64().unwrap() > 2);
1767    }
1768
1769    #[test]
1770    fn struct_type_produces_nested_fields() {
1771        let schema = Schema::new(vec![Field::new(
1772            "meta",
1773            DataType::Struct(
1774                vec![
1775                    Field::new("key", DataType::Utf8, false),
1776                    Field::new("val", DataType::Int64, false),
1777                ]
1778                .into(),
1779            ),
1780            true,
1781        )]);
1782        let pol = policy("vec", 4);
1783        let upd = update_for(&schema, &pol);
1784
1785        let t = &upd.fields[0]["type"];
1786        assert_eq!(t["type"], "struct");
1787        let nested = t["fields"].as_array().unwrap();
1788        assert_eq!(nested.len(), 2);
1789        assert_eq!(nested[0]["name"], "key");
1790        assert_eq!(nested[0]["type"], "string");
1791        assert_eq!(nested[1]["name"], "val");
1792        assert_eq!(nested[1]["type"], "long");
1793        // Nested IDs must be > top-level count (2: meta + vec)
1794        assert!(nested[0]["id"].as_i64().unwrap() > 2);
1795    }
1796
1797    #[test]
1798    fn no_duplicate_vec_column_when_already_in_batch() {
1799        // If for some reason the vec column is in the batch schema, don't add it twice.
1800        let schema = Schema::new(vec![
1801            Field::new("id", DataType::Int32, false),
1802            Field::new("embedding", DataType::FixedSizeBinary(16), false),
1803        ]);
1804        let pol = policy("embedding", 8);
1805        let upd = update_for(&schema, &pol);
1806
1807        assert_eq!(upd.fields.len(), 2, "should not add embedding twice");
1808        let names: Vec<&str> = upd
1809            .fields
1810            .iter()
1811            .map(|f| f["name"].as_str().unwrap())
1812            .collect();
1813        assert_eq!(names.iter().filter(|&&n| n == "embedding").count(), 1);
1814    }
1815
1816    #[test]
1817    fn multi_vec_policies_all_appended() {
1818        let schema = Schema::new(vec![Field::new("id", DataType::Int32, false)]);
1819        let primary = policy("embedding", 4);
1820        let extra = vec![policy("context_embedding", 4)];
1821        let upd = arrow_schema_to_iceberg_update(&schema, &primary, &extra);
1822
1823        assert_eq!(upd.fields.len(), 3); // id + embedding + context_embedding
1824        let names: Vec<&str> = upd
1825            .fields
1826            .iter()
1827            .map(|f| f["name"].as_str().unwrap())
1828            .collect();
1829        assert!(names.contains(&"embedding"));
1830        assert!(names.contains(&"context_embedding"));
1831    }
1832
1833    #[test]
1834    fn top_level_field_ids_match_parquet_stamp_sequence() {
1835        // Top-level IDs must be 1, 2, ..., N regardless of nested element IDs.
1836        let schema = Schema::new(vec![
1837            Field::new("id", DataType::Int64, false),
1838            Field::new(
1839                "tags",
1840                DataType::List(std::sync::Arc::new(Field::new(
1841                    "item",
1842                    DataType::Utf8,
1843                    true,
1844                ))),
1845                true,
1846            ),
1847        ]);
1848        let pol = policy("vec", 4);
1849        let upd = update_for(&schema, &pol);
1850
1851        // Top-level: id=1, tags=2, vec=3
1852        assert_eq!(upd.fields[0]["id"], 1);
1853        assert_eq!(upd.fields[1]["id"], 2);
1854        assert_eq!(upd.fields[2]["id"], 3);
1855
1856        // Nested element ID must be > 3
1857        assert!(upd.fields[1]["type"]["element-id"].as_i64().unwrap() > 3);
1858    }
1859
1860    /// Regression: `captured_schema` used to be "first batch wins" — any column absent
1861    /// from the first `write_batch*` call but present in a later one within the same
1862    /// commit window was written into that later file's Parquet bytes but never declared
1863    /// in the committed Iceberg schema, making it invisible to standard readers.
1864    #[test]
1865    fn merge_schema_accumulates_columns_across_batches() {
1866        let first = Arc::new(Schema::new(vec![
1867            Field::new("id", DataType::Int32, false),
1868            Field::new("text", DataType::Utf8, false),
1869        ]));
1870        let second = Arc::new(Schema::new(vec![
1871            Field::new("id", DataType::Int32, false),
1872            Field::new("text", DataType::Utf8, false),
1873            Field::new("author", DataType::Utf8, true),
1874        ]));
1875
1876        let merged = merge_schema(None, &first);
1877        assert_eq!(merged.fields().len(), 2);
1878        let merged = merge_schema(Some(merged), &second);
1879
1880        assert_eq!(
1881            merged.fields().len(),
1882            3,
1883            "author from the second batch must survive"
1884        );
1885        assert!(merged.field_with_name("id").is_ok());
1886        assert!(merged.field_with_name("text").is_ok());
1887        assert!(merged.field_with_name("author").is_ok());
1888
1889        // A third batch with a subset of columns must not drop what was already accumulated.
1890        let third = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
1891        let merged = merge_schema(Some(merged), &third);
1892        assert_eq!(
1893            merged.fields().len(),
1894            3,
1895            "shrinking batch must not drop prior columns"
1896        );
1897    }
1898
1899    /// Smoke-test write_batch_auto_deferred: verifies that it completes without error
1900    /// and stages a pending file entry (index built asynchronously in background).
1901    #[tokio::test]
1902    async fn write_batch_auto_deferred_stages_file() {
1903        use ailake_catalog::{HadoopCatalog, TableIdent};
1904        use ailake_store::LocalStore;
1905        use arrow_schema::{DataType, Field, Schema};
1906
1907        let dir = tempfile::tempdir().unwrap();
1908        let store: std::sync::Arc<dyn ailake_store::Store> =
1909            std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1910        let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1911        let pol = policy("embedding", 4);
1912        let ident = TableIdent::new("default", "t");
1913
1914        let mut writer = TableWriter::create_or_open(catalog, store, pol, ident, 2)
1915            .await
1916            .unwrap();
1917
1918        let schema =
1919            std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1920        let batch = arrow_array::RecordBatch::try_new(
1921            schema,
1922            vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1923                "hello",
1924            ]))],
1925        )
1926        .unwrap();
1927        let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
1928
1929        writer
1930            .write_batch_auto_deferred(&batch, &embeddings)
1931            .await
1932            .unwrap();
1933
1934        // One pending file should be staged even before commit.
1935        assert_eq!(writer.pending_files.len(), 1);
1936    }
1937
1938    /// Smoke-test write_batch_multi_deferred: verifies Parquet staged immediately,
1939    /// placeholder extra_vector_indexes populated, and background task spawned.
1940    #[tokio::test]
1941    async fn write_batch_multi_deferred_stages_file_with_extra_indexes() {
1942        use ailake_catalog::{HadoopCatalog, IndexStatus, TableIdent};
1943        use ailake_store::LocalStore;
1944        use arrow_schema::{DataType, Field, Schema};
1945
1946        let dir = tempfile::tempdir().unwrap();
1947        let store: std::sync::Arc<dyn ailake_store::Store> =
1948            std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
1949        let catalog = std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
1950        let primary_pol = policy("embedding", 4);
1951        let ident = TableIdent::new("default", "t");
1952
1953        let mut writer = TableWriter::create_or_open(catalog, store, primary_pol, ident, 2)
1954            .await
1955            .unwrap();
1956
1957        let schema =
1958            std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
1959        let batch = arrow_array::RecordBatch::try_new(
1960            schema,
1961            vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
1962                "hello", "world",
1963            ]))],
1964        )
1965        .unwrap();
1966
1967        let text_embs = vec![vec![1.0f32, 0.0, 0.0, 0.0], vec![0.0, 1.0, 0.0, 0.0]];
1968        let img_embs = vec![vec![1.0f32, 0.0], vec![0.0, 1.0]];
1969
1970        let columns = vec![
1971            MultiVectorBatch {
1972                policy: policy("embedding", 4),
1973                embeddings: &text_embs,
1974            },
1975            MultiVectorBatch {
1976                policy: policy("img_embedding", 2),
1977                embeddings: &img_embs,
1978            },
1979        ];
1980
1981        writer
1982            .write_batch_multi_deferred(&batch, &columns)
1983            .await
1984            .unwrap();
1985
1986        assert_eq!(writer.pending_files.len(), 1);
1987        let entry = &writer.pending_files[0];
1988        // IndexStatus::Indexing — index build is async
1989        assert_eq!(entry.index_status, IndexStatus::Indexing);
1990        // Primary centroid populated for pruning during build window
1991        assert!(entry.centroid_b64.is_some());
1992        // Placeholder extra column entry (centroid present, offsets zero)
1993        assert_eq!(entry.extra_vector_indexes.len(), 1);
1994        let xi = &entry.extra_vector_indexes[0];
1995        assert_eq!(xi.column, "img_embedding");
1996        assert_eq!(xi.dim, 2);
1997        assert_eq!(xi.hnsw_offset, 0); // not yet built
1998        assert_eq!(xi.hnsw_len, 0); // not yet built
1999        assert!(xi.centroid_b64.is_some());
2000    }
2001
2002    /// Regression: part paths must be unique across writer sessions even after
2003    /// the table's file count shrinks (compaction). The old counter seed
2004    /// (`list_files().len()`) made a post-compaction writer reuse a retired
2005    /// part's name — under DuckLake that file still exists physically and is
2006    /// still registered, so the colliding put rewrote a registered file in
2007    /// place. `session_ts` in the path prevents the reuse structurally.
2008    #[tokio::test]
2009    async fn part_paths_unique_across_sessions_after_compaction() {
2010        use ailake_catalog::{
2011            new_snapshot_id, HadoopCatalog, NewSnapshot, SnapshotOperation, TableIdent,
2012        };
2013        use ailake_store::LocalStore;
2014        use arrow_schema::{DataType, Field, Schema};
2015
2016        let dir = tempfile::tempdir().unwrap();
2017        let store: std::sync::Arc<dyn ailake_store::Store> =
2018            std::sync::Arc::new(LocalStore::new(dir.path().to_str().unwrap()));
2019        let catalog: std::sync::Arc<dyn CatalogProvider> =
2020            std::sync::Arc::new(HadoopCatalog::new(std::sync::Arc::clone(&store), ""));
2021        let ident = TableIdent::new("default", "t");
2022
2023        let schema =
2024            std::sync::Arc::new(Schema::new(vec![Field::new("text", DataType::Utf8, false)]));
2025        let batch = arrow_array::RecordBatch::try_new(
2026            schema,
2027            vec![std::sync::Arc::new(arrow_array::StringArray::from(vec![
2028                "hello",
2029            ]))],
2030        )
2031        .unwrap();
2032        let embeddings = vec![vec![1.0f32, 0.0, 0.0, 0.0]];
2033
2034        // Session 1: two parts committed.
2035        let mut w1 = TableWriter::create_or_open(
2036            catalog.clone(),
2037            store.clone(),
2038            policy("embedding", 4),
2039            ident.clone(),
2040            2,
2041        )
2042        .await
2043        .unwrap();
2044        w1.write_batch(&batch, &embeddings).await.unwrap();
2045        w1.write_batch(&batch, &embeddings).await.unwrap();
2046        w1.commit().await.unwrap();
2047        let session1_paths: Vec<String> = catalog
2048            .list_files(&ident, None)
2049            .await
2050            .unwrap()
2051            .iter()
2052            .map(|f| f.path.clone())
2053            .collect();
2054        assert_eq!(session1_paths.len(), 2);
2055
2056        // Simulate compaction: Replace the two parts with one merged file,
2057        // shrinking the table's file count from 2 to 1.
2058        let mut merged = catalog.list_files(&ident, None).await.unwrap()[0].clone();
2059        merged.path = "data/compacted-test.parquet".to_string();
2060        store
2061            .put(&merged.path, store.get(&session1_paths[0]).await.unwrap())
2062            .await
2063            .unwrap();
2064        let parent = catalog
2065            .load_table(&ident)
2066            .await
2067            .unwrap()
2068            .current_snapshot_id;
2069        catalog
2070            .commit_snapshot(
2071                &ident,
2072                NewSnapshot {
2073                    snapshot_id: new_snapshot_id(),
2074                    parent_snapshot_id: parent,
2075                    files: vec![merged],
2076                    operation: SnapshotOperation::Replace,
2077                    iceberg_schema: None,
2078                    extra_properties: std::collections::HashMap::new(),
2079                    bloom_filters: vec![],
2080                    equality_delete_files: vec![],
2081                },
2082            )
2083            .await
2084            .unwrap();
2085
2086        // Session 2: a fresh writer must not reuse any session-1 part name.
2087        let mut w2 = TableWriter::create_or_open(
2088            catalog.clone(),
2089            store.clone(),
2090            policy("embedding", 4),
2091            ident.clone(),
2092            2,
2093        )
2094        .await
2095        .unwrap();
2096        w2.write_batch(&batch, &embeddings).await.unwrap();
2097        w2.commit().await.unwrap();
2098
2099        let final_paths: Vec<String> = catalog
2100            .list_files(&ident, None)
2101            .await
2102            .unwrap()
2103            .iter()
2104            .map(|f| f.path.clone())
2105            .collect();
2106        let new_part = final_paths
2107            .iter()
2108            .find(|p| p.starts_with("data/part-"))
2109            .expect("session 2 must have committed a part file");
2110        assert!(
2111            !session1_paths.contains(new_part),
2112            "session 2 reused a retired part name: {new_part} (session 1 wrote {session1_paths:?})"
2113        );
2114    }
2115}