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