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