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