Skip to main content

ailake_query/
writer.rs

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