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