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