Skip to main content

array_format/
file.rs

1//! The runtime: [`ArrayFile`], the top-level read/write/compact handle.
2//!
3//! [`ArrayFile`] ties the lower layers together — it defines and reads/writes
4//! arrays, manages the stack of delta layers (flushing pending writes into
5//! sidecars and compacting them back down), and is configured through
6//! [`FileConfig`].
7
8use std::sync::Arc;
9
10use bytes::Bytes;
11use indexmap::IndexMap;
12use object_store::{ObjectStore, ObjectStoreExt};
13
14use crate::{
15    DType, Error, Result,
16    address::ChunkAddress,
17    array::ArrayElement,
18    codec::CompressionCodec,
19    delta::{
20        Delta, DeltaAllocator, DeltaCache, DeltaImmutable, DeltaMutable, write_file_then_bytes,
21    },
22    footer::{FOOTER_VERSION, Footer},
23    layout::{
24        ArrayLayout, ArrayMeta, AttrIndexKind, AttributeValue, Attributes, ChunkEntry, FillValue,
25        StorageLayout,
26    },
27    stats::{ArrayStats, StatsFile, compute_chunk_partial, merge_partial, read_stats_file},
28    storage::{ObjectStoreBackend, Storage},
29};
30
31// ── Constants ───────────────────────────────────────────────────────
32
33/// Default target size for a data block before a new one is started (8 MiB).
34pub const DEFAULT_BLOCK_TARGET_SIZE: usize = 8 * 1024 * 1024; // 8 MiB
35/// Default byte budget for the decompressed-block cache (256 MiB).
36pub const DEFAULT_CACHE_CAPACITY: usize = 256 * 1024 * 1024; // 256 MiB
37/// Default byte budget for the raw I/O slab cache (64 MiB); useful for object-store workloads.
38pub const DEFAULT_IO_CACHE_CAPACITY: usize = 64 * 1024 * 1024; // 64 MiB; enable for object-store workloads
39
40// ── FileConfig ──────────────────────────────────────────────────────
41
42/// Configuration for opening or creating an [`ArrayFile`].
43///
44/// Construct with [`FileConfig::new`] for the defaults, then override fields as
45/// needed:
46///
47/// ```
48/// use array_format::{FileConfig, ZstdCodec};
49///
50/// let config = FileConfig {
51///     block_target_size: 4 * 1024 * 1024,
52///     ..FileConfig::new(ZstdCodec { level: 9 })
53/// };
54/// ```
55pub struct FileConfig<C: CompressionCodec> {
56    /// Compression codec applied to data blocks on write.
57    pub codec: C,
58    /// Target size of a data block before a new block is started, in bytes.
59    pub block_target_size: usize,
60    /// Byte budget for this file's decompressed-block cache.
61    ///
62    /// Ignored when [`cache`](Self::cache) is `Some`.
63    pub cache_capacity: usize,
64    /// Byte budget for this file's raw I/O slab cache (0 disables it).
65    ///
66    /// Ignored when [`cache`](Self::cache) is `Some`.
67    pub io_cache_capacity: usize,
68    /// Optional pre-built cache to share across multiple [`ArrayFile`]s.
69    ///
70    /// When `Some`, [`cache_capacity`](Self::cache_capacity) and
71    /// [`io_cache_capacity`](Self::io_cache_capacity) are ignored and every file
72    /// sharing this cache is bounded by one combined byte budget. Entries are
73    /// keyed by `(file_path, block_id)`, so files do not interfere.
74    pub cache: Option<Arc<DeltaCache>>,
75}
76
77impl<C: CompressionCodec> FileConfig<C> {
78    /// Creates a config using `codec` and the `DEFAULT_*` capacities, with no
79    /// shared cache.
80    pub fn new(codec: C) -> Self {
81        Self {
82            codec,
83            block_target_size: DEFAULT_BLOCK_TARGET_SIZE,
84            cache_capacity: DEFAULT_CACHE_CAPACITY,
85            io_cache_capacity: DEFAULT_IO_CACHE_CAPACITY,
86            cache: None,
87        }
88    }
89}
90
91// ── MergedArrayMeta ─────────────────────────────────────────────────
92
93/// Array metadata visible to the caller after merging all delta layers.
94///
95/// Returned by [`ArrayFile::list_arrays`].
96#[derive(Debug, Clone)]
97pub struct MergedArrayMeta {
98    /// Array name (unique within the file).
99    pub name: String,
100    /// Element type.
101    pub dtype: DType,
102    /// Full array shape, one entry per dimension.
103    pub shape: Vec<u32>,
104    /// Chunk shape; equals [`shape`](Self::shape) for single-chunk arrays.
105    pub chunk_shape: Vec<u32>,
106    /// Name of each dimension.
107    pub dimension_names: Vec<String>,
108    /// Fill value used for unwritten elements, if one was set at definition.
109    pub fill_value: Option<FillValue>,
110}
111
112// ── File ────────────────────────────────────────────────────────────
113
114/// Object store and base-file path backing a file.
115struct StoreDir {
116    store: Arc<dyn ObjectStore>,
117    base_path: object_store::path::Path,
118}
119
120/// Schema information returned by [`File::get_chunked_schema`].
121pub(crate) struct ChunkedSchema {
122    pub full_shape: Vec<u32>,
123    pub chunk_shape: Vec<u32>,
124    pub dtype: DType,
125    pub all_coords: Vec<Vec<u32>>,
126}
127
128/// The top-level file handle.
129///
130/// Layers are stacked oldest → newest in `deltas`. Uncommitted writes
131/// accumulate in a disk-backed mutable delta (`pending`) and are flushed
132/// by [`flush`](Self::flush). The mutable delta is created lazily on the
133/// first mutation after open/flush.
134pub struct ArrayFile {
135    deltas: Vec<Delta<DeltaImmutable>>,
136    pending: Option<Delta<DeltaMutable>>,
137    codec: Arc<dyn CompressionCodec>,
138    block_target_size: usize,
139    cache: Option<Arc<DeltaCache>>,
140    /// Object store and stem backing this file (an in-memory file uses
141    /// `object_store`'s in-memory backend).
142    store_dir: StoreDir,
143    /// Per-array aggregate statistics; `None` until first flush or open.
144    stats: Option<StatsFile>,
145}
146
147// ── Constructors ────────────────────────────────────────────────────
148
149impl ArrayFile {
150    /// Creates a new empty file at `path` within `store`.
151    ///
152    /// `path` is the base file object and should end in `.af`; sidecars
153    /// (`{stem}.N.af`) and the stats file (`{stem}.stats`) are written alongside
154    /// it in the same prefix. Fails if an object already exists at `path` only
155    /// insofar as the backend allows overwriting — the base is (re)written empty.
156    pub async fn create<C: CompressionCodec + 'static>(
157        store: Arc<dyn ObjectStore>,
158        path: object_store::path::Path,
159        config: FileConfig<C>,
160    ) -> Result<Self> {
161        let cache = resolve_cache(&config);
162        let delta_path = Arc::<str>::from(path.as_ref());
163        let storage =
164            Arc::new(ObjectStoreBackend::new(Arc::clone(&store), path.clone())) as Arc<dyn Storage>;
165        write_empty_base(&*storage).await?;
166        let base_delta = Delta::<DeltaImmutable>::open(storage, delta_path, cache.clone()).await?;
167        Ok(ArrayFile {
168            deltas: vec![base_delta],
169            pending: None,
170            codec: Arc::new(config.codec),
171            block_target_size: config.block_target_size,
172            cache,
173            store_dir: StoreDir {
174                store,
175                base_path: path,
176            },
177            stats: None,
178        })
179    }
180
181    /// Opens an existing file from `store`, discovering the base and all
182    /// sidecar layers under the same stem.
183    ///
184    /// `path` must end in `.af`. Aggregate statistics are loaded from
185    /// `{stem}.stats` if present; a missing or unreadable stats file is not an
186    /// error (see [`array_stats`](Self::array_stats)).
187    pub async fn open<C: CompressionCodec + 'static>(
188        store: Arc<dyn ObjectStore>,
189        path: object_store::path::Path,
190        config: FileConfig<C>,
191    ) -> Result<Self> {
192        let cache = resolve_cache(&config);
193        let delta_path = Arc::<str>::from(path.as_ref());
194        let storage =
195            Arc::new(ObjectStoreBackend::new(Arc::clone(&store), path.clone())) as Arc<dyn Storage>;
196        let base_delta = Delta::<DeltaImmutable>::open(storage, delta_path, cache.clone()).await?;
197        let mut deltas = vec![base_delta];
198
199        let sidecars = discover_sidecars_store(&*store, &path).await?;
200        for (_, scar_path) in sidecars {
201            let scar_delta_path = Arc::<str>::from(scar_path.as_ref());
202            let scar_storage = Arc::new(ObjectStoreBackend::new(Arc::clone(&store), scar_path))
203                as Arc<dyn Storage>;
204            deltas.push(
205                Delta::<DeltaImmutable>::open(scar_storage, scar_delta_path, cache.clone()).await?,
206            );
207        }
208
209        let stats = {
210            let s_storage = ObjectStoreBackend::new(Arc::clone(&store), stats_path(&path));
211            read_stats_file(&s_storage).await.ok()
212        };
213
214        Ok(ArrayFile {
215            deltas,
216            pending: None,
217            codec: Arc::new(config.codec),
218            block_target_size: config.block_target_size,
219            cache,
220            store_dir: StoreDir {
221                store,
222                base_path: path,
223            },
224            stats,
225        })
226    }
227
228    /// Creates a new empty in-memory file.
229    ///
230    /// Backed by `object_store`'s in-memory backend, so it behaves exactly like
231    /// an on-disk file (commit pending writes with [`flush`](Self::flush)) but
232    /// keeps everything in process. Useful for tests and ephemeral pipelines.
233    pub async fn create_memory<C: CompressionCodec + 'static>(
234        config: FileConfig<C>,
235    ) -> Result<Self> {
236        let store = Arc::new(object_store::memory::InMemory::new());
237        Self::create(store, object_store::path::Path::from("memory.af"), config).await
238    }
239}
240
241// ── Schema & attribute access ────────────────────────────────────────
242
243impl ArrayFile {
244    /// Returns a reference to the merged array metadata for `name`,
245    /// searching from the newest layer towards the oldest.
246    pub fn get_array(&self, name: &str) -> Result<&ArrayMeta> {
247        self.resolve_array_meta(name)
248            .ok_or_else(|| Error::ArrayNotFound {
249                name: name.to_string(),
250            })
251    }
252
253    fn resolve_array_meta(&self, name: &str) -> Option<&ArrayMeta> {
254        if let Some(p) = self.pending.as_ref()
255            && let Some(m) = p.inner.array_meta.get(name)
256        {
257            return if m.deleted { None } else { Some(m) };
258        }
259        for delta in self.deltas.iter().rev() {
260            if let Some(&i) = delta.inner.array_index.get(name) {
261                let m = &delta.inner.footer.arrays[i];
262                return if m.deleted { None } else { Some(m) };
263            }
264        }
265        None
266    }
267
268    /// Lazily creates a mutable pending delta and returns a mutable reference.
269    fn pending_mut(&mut self) -> &mut Delta<DeltaMutable> {
270        if self.pending.is_none() {
271            let overlay_index = self.deltas.len() as u32;
272            self.pending = Some(Delta::<DeltaMutable>::new(
273                Arc::clone(&self.codec),
274                self.block_target_size,
275                overlay_index,
276            ));
277        }
278        self.pending.as_mut().unwrap()
279    }
280
281    /// Returns the array schema in the form expected by the ndarray write path.
282    pub(crate) fn get_chunked_schema(&self, name: &str) -> Result<ChunkedSchema> {
283        let meta = self.get_array(name)?;
284        let full_shape = meta.layout.shape.clone();
285        let chunk_shape = meta.layout.storage.chunk_shape.clone();
286        let dtype = meta.dtype.clone();
287        // Collect existing chunk coords from all layers (newest wins, so just union).
288        let mut existing: IndexMap<Vec<u32>, ()> = IndexMap::new();
289        for delta in &self.deltas {
290            if let Some(&i) = delta.inner.array_index.get(name) {
291                for e in &delta.inner.footer.arrays[i].layout.storage.chunks {
292                    existing.entry(e.coord.clone()).or_default();
293                }
294            }
295        }
296        if let Some(p) = self.pending.as_ref()
297            && let Some(m) = p.inner.array_meta.get(name)
298        {
299            for e in &m.layout.storage.chunks {
300                existing.entry(e.coord.clone()).or_default();
301            }
302        }
303        Ok(ChunkedSchema {
304            full_shape,
305            chunk_shape,
306            dtype,
307            all_coords: existing.into_keys().collect(),
308        })
309    }
310
311    /// Returns all non-deleted visible arrays (newest-wins merge).
312    pub fn list_arrays(&self) -> Vec<MergedArrayMeta> {
313        let mut seen: IndexMap<String, MergedArrayMeta> = IndexMap::new();
314
315        // Walk from oldest to newest so later entries overwrite earlier ones.
316        for delta in &self.deltas {
317            for a in &delta.inner.footer.arrays {
318                if a.deleted {
319                    seen.shift_remove(&a.name);
320                } else {
321                    seen.insert(
322                        a.name.clone(),
323                        MergedArrayMeta {
324                            name: a.name.clone(),
325                            dtype: a.dtype.clone(),
326                            shape: a.layout.shape.clone(),
327                            chunk_shape: a.layout.storage.chunk_shape.clone(),
328                            dimension_names: a.layout.dimension_names.clone(),
329                            fill_value: a.fill_value.clone(),
330                        },
331                    );
332                }
333            }
334        }
335        if let Some(p) = self.pending.as_ref() {
336            for (name, a) in &p.inner.array_meta {
337                if a.deleted {
338                    seen.shift_remove(name);
339                } else {
340                    seen.insert(
341                        name.clone(),
342                        MergedArrayMeta {
343                            name: a.name.clone(),
344                            dtype: a.dtype.clone(),
345                            shape: a.layout.shape.clone(),
346                            chunk_shape: a.layout.storage.chunk_shape.clone(),
347                            dimension_names: a.layout.dimension_names.clone(),
348                            fill_value: a.fill_value.clone(),
349                        },
350                    );
351                }
352            }
353        }
354        seen.into_values().collect()
355    }
356
357    /// Returns aggregate statistics for `name`, or `None` if no stats exist yet.
358    pub fn array_stats(&self, name: &str) -> Option<&ArrayStats> {
359        self.stats.as_ref()?.get_array(name)
360    }
361
362    /// Number of committed (immutable) delta layers.
363    pub fn num_layers(&self) -> usize {
364        self.deltas.len()
365    }
366
367    /// Returns the value of attribute `key` on array `name`, or `None` if the
368    /// array has no such attribute. Errors if the array does not exist.
369    pub fn get_attribute(&self, name: &str, key: &str) -> Result<Option<&AttributeValue>> {
370        let meta = self.get_array(name)?;
371        let key_idx = match self
372            .pending
373            .as_ref()
374            .and_then(|p| p.inner.attr_keys.iter().position(|k| k == key))
375            .or_else(|| {
376                // Check global dicts in most-recent delta
377                self.deltas
378                    .iter()
379                    .rev()
380                    .find_map(|d| d.inner.footer.attr_keys.iter().position(|k| k == key))
381            }) {
382            Some(i) => i,
383            None => return Ok(None),
384        };
385        let val_idx = match meta.attributes.get(key_idx) {
386            Some(i) => i,
387            None => return Ok(None),
388        };
389        // Look up in pending first, then deltas
390        if let Some(p) = self.pending.as_ref()
391            && val_idx < p.inner.attr_values.len()
392        {
393            return Ok(Some(&p.inner.attr_values[val_idx]));
394        }
395        for delta in self.deltas.iter().rev() {
396            if val_idx < delta.inner.footer.attr_values.len() {
397                return Ok(Some(&delta.inner.footer.attr_values[val_idx]));
398            }
399        }
400        Ok(None)
401    }
402
403    /// Returns attribute `key` for every visible array as `(array_name, value)`.
404    ///
405    /// The result is a full column over all non-deleted arrays: `Some(value)`
406    /// for arrays that carry the attribute, `None` for those that don't.
407    /// Intended for coarse pruning — scan the returned values to select arrays
408    /// without walking each one via [`get_attribute`](Self::get_attribute).
409    /// Logically deleted arrays are omitted entirely.
410    pub fn attribute_index(&self, key: &str) -> Vec<(String, Option<&AttributeValue>)> {
411        self.list_arrays()
412            .into_iter()
413            .map(|m| {
414                let value = self.get_attribute(&m.name, key).ok().flatten();
415                (m.name, value)
416            })
417            .collect()
418    }
419
420    /// Sets attribute `key` on array `name` to `value`, inserting or replacing
421    /// any existing entry. The change lands in the pending layer and is
422    /// persisted on the next [`flush`](Self::flush). Errors if the array does
423    /// not exist.
424    pub fn set_attribute(&mut self, name: &str, key: &str, value: AttributeValue) -> Result<()> {
425        // Ensure the array exists (in deltas or pending), and snapshot its meta
426        // in case we need to copy it down into the pending mutable delta.
427        // Clear the cloned chunks list so we don't carry stale block addresses
428        // from a lower layer into this delta's footer.
429        let mut existing_meta = self.get_array(name)?.clone();
430        existing_meta.layout.storage.chunks.clear();
431
432        let pending = self.pending_mut();
433        let key_idx = pending.intern_attr_key(key);
434        let val_idx = pending.intern_attr_value(value);
435
436        // Update the array meta in pending (copy from lower layer if absent).
437        if pending.array_meta_mut(name).is_none() {
438            pending.upsert_array_meta(existing_meta);
439        }
440        let meta = pending.array_meta_mut(name).unwrap();
441        meta.attributes.upsert(key_idx, val_idx);
442        Ok(())
443    }
444}
445
446// ── Array definition / deletion ──────────────────────────────────────
447
448impl ArrayFile {
449    /// Defines a new array in the pending layer.
450    ///
451    /// `shape` is the full array shape; `chunk_shape` tiles it into a grid of
452    /// independently stored chunks, or `None` to store the whole array as a
453    /// single chunk. If `dimension_names` does not have one entry per dimension
454    /// it is replaced with `dim0`, `dim1`, … . `fill_value` is returned for
455    /// elements that are never written.
456    ///
457    /// Errors with [`Error::ArrayAlreadyExists`] if an array of this name is
458    /// already visible. The definition is persisted on the next
459    /// [`flush`](Self::flush).
460    pub fn define_array<T: ArrayElement>(
461        &mut self,
462        name: impl Into<String>,
463        dimension_names: Vec<String>,
464        shape: Vec<usize>,
465        chunk_shape: Option<Vec<usize>>,
466        fill_value: Option<FillValue>,
467    ) -> Result<()> {
468        let name = name.into();
469        if self.resolve_array_meta(&name).is_some() {
470            return Err(Error::ArrayAlreadyExists { name });
471        }
472        let shape_u32: Vec<u32> = shape.iter().map(|&s| s as u32).collect();
473        let ndim = shape_u32.len();
474        let chunk_shape_u32: Vec<u32> = chunk_shape
475            .map(|cs| cs.iter().map(|&s| s as u32).collect())
476            .unwrap_or_else(|| shape_u32.clone());
477        let dim_names = if dimension_names.len() == ndim {
478            dimension_names
479        } else {
480            (0..ndim).map(|i| format!("dim{i}")).collect()
481        };
482        let layout = ArrayLayout {
483            shape: shape_u32,
484            dimension_names: dim_names,
485            storage: StorageLayout {
486                chunk_shape: chunk_shape_u32,
487                chunks: vec![],
488            },
489        };
490        self.pending_mut().upsert_array_meta(ArrayMeta {
491            name,
492            dtype: T::DTYPE,
493            layout,
494            fill_value,
495            deleted: false,
496            attributes: Attributes::empty(AttrIndexKind::U16),
497        });
498        Ok(())
499    }
500
501    /// Logically deletes an array by writing a tombstone to the pending layer.
502    ///
503    /// The array is excluded from [`list_arrays`](Self::list_arrays) and all
504    /// reads immediately, but its bytes remain on disk until
505    /// [`compact`](Self::compact) reclaims them.
506    pub fn delete(&mut self, name: &str) -> Result<()> {
507        let meta = self.get_array(name)?.clone();
508        self.pending_mut().mark_deleted(meta);
509        Ok(())
510    }
511}
512
513// ── Chunk-level read/write (pub(crate) for ndarray_ext) ──────────────
514
515impl ArrayFile {
516    pub(crate) async fn read_chunk<T: ArrayElement>(
517        &self,
518        name: &str,
519        coord: &[u32],
520    ) -> Result<Vec<T>> {
521        if let Some(bytes) = self.resolve_raw_chunk(name, coord).await? {
522            return Ok(T::decode_chunk(&bytes));
523        }
524        let meta = self.get_array(name)?;
525        let chunk_elems: usize = meta
526            .layout
527            .storage
528            .chunk_shape
529            .iter()
530            .enumerate()
531            .map(|(i, &cs)| {
532                let axis_len = meta.layout.shape[i] as usize;
533                let start = coord[i] as usize * cs as usize;
534                (cs as usize).min(axis_len.saturating_sub(start))
535            })
536            .product();
537        Ok(vec![T::fill_element(meta.fill_value.as_ref()); chunk_elems])
538    }
539
540    pub(crate) fn write_chunk_raw(
541        &mut self,
542        name: &str,
543        coord: Vec<u32>,
544        bytes: Vec<u8>,
545    ) -> Result<()> {
546        // If the array isn't yet present in pending, copy its meta down so the
547        // mutable delta has an entry to attach the chunk to. Clear the cloned
548        // chunks list — the lower-layer addresses don't apply to this delta's
549        // data file, and only chunks written into this session belong here.
550        let snapshot = if self
551            .pending
552            .as_ref()
553            .and_then(|p| p.inner.array_meta.get(name))
554            .is_none()
555        {
556            let mut m = self.get_array(name)?.clone();
557            m.layout.storage.chunks.clear();
558            Some(m)
559        } else {
560            None
561        };
562        let pending = self.pending_mut();
563        if let Some(meta) = snapshot {
564            pending.upsert_array_meta(meta);
565        }
566        pending.write_raw_chunk(name, coord, &bytes)
567    }
568
569    async fn resolve_raw_chunk(&self, name: &str, coord: &[u32]) -> Result<Option<Bytes>> {
570        if let Some(p) = self.pending.as_ref()
571            && let Some(bytes) = p.read_raw_chunk(name, coord)
572        {
573            return Ok(Some(bytes));
574        }
575        for delta in self.deltas.iter().rev() {
576            if let Some(bytes) = delta.read_raw_chunk(name, coord).await? {
577                return Ok(Some(bytes));
578            }
579        }
580        Ok(None)
581    }
582}
583
584// ── ndarray read/write ───────────────────────────────────────────────
585
586impl ArrayFile {
587    /// Writes `data` into array `name` with its origin at coordinate `start`.
588    ///
589    /// The region may span multiple chunks and need not be chunk-aligned;
590    /// partially covered chunks are read-modify-written automatically. `T` must
591    /// match the array's declared dtype, otherwise [`Error::DTypeMismatch`] is
592    /// returned. Writes accumulate in the pending layer until
593    /// [`flush`](Self::flush).
594    pub async fn write_array<T: ArrayElement>(
595        &mut self,
596        name: &str,
597        start: Vec<usize>,
598        data: ndarray::ArrayView<'_, T, ndarray::IxDyn>,
599    ) -> Result<()> {
600        crate::ndarray_ext::write_nd(self, name, data, &start).await
601    }
602
603    /// Reads the sub-region of array `name` starting at `start` with the given
604    /// `shape`.
605    ///
606    /// Pass `vec![], vec![]` to read the whole array. Chunks that were never
607    /// written are materialized from the array's fill value. `T` must match the
608    /// array's declared dtype, otherwise [`Error::DTypeMismatch`] is returned.
609    pub async fn read_array<T: ArrayElement>(
610        &self,
611        name: &str,
612        start: Vec<usize>,
613        shape: Vec<usize>,
614    ) -> Result<ndarray::ArcArray<T, ndarray::IxDyn>> {
615        use std::ops::Range;
616        let slice: Option<Vec<Range<usize>>> = if start.is_empty() && shape.is_empty() {
617            None
618        } else {
619            let meta = self.get_array(name)?;
620            let ndim = meta.layout.shape.len();
621            let effective_start = if start.len() == ndim {
622                start.clone()
623            } else {
624                vec![0; ndim]
625            };
626            let effective_shape: Vec<usize> = if shape.len() == ndim {
627                shape.clone()
628            } else {
629                meta.layout.shape.iter().map(|&s| s as usize).collect()
630            };
631            Some(
632                effective_start
633                    .iter()
634                    .zip(&effective_shape)
635                    .map(|(&s, &sz)| s..s + sz)
636                    .collect(),
637            )
638        };
639        crate::ndarray_ext::assemble_nd(self, name, slice.as_deref()).await
640    }
641}
642
643// ── Flush ────────────────────────────────────────────────────────────
644
645impl ArrayFile {
646    /// Commits pending writes to a new sidecar layer and refreshes the
647    /// `{stem}.stats` file.
648    ///
649    /// A no-op if there are no pending changes.
650    pub async fn flush(&mut self) -> Result<()> {
651        if self.pending.is_none() {
652            return Ok(());
653        }
654        let store = Arc::clone(&self.store_dir.store);
655        let base_path = self.store_dir.base_path.clone();
656        let overlay_index = self.deltas.len() as u32;
657        let scar_path = sidecar_path(&base_path, overlay_index);
658        let delta_path = Arc::<str>::from(scar_path.as_ref());
659        let storage =
660            Arc::new(ObjectStoreBackend::new(Arc::clone(&store), scar_path)) as Arc<dyn Storage>;
661        let hint = base_path.as_ref().to_string();
662        let dirty_names = self.commit_pending(storage, delta_path, hint).await?;
663
664        let merged = self.compute_stats_for(&dirty_names).await?;
665        let s_storage = ObjectStoreBackend::new(Arc::clone(&store), stats_path(&base_path));
666        s_storage
667            .write(bytes::Bytes::from(merged.serialize()?))
668            .await?;
669        self.stats = Some(merged);
670        Ok(())
671    }
672
673    async fn compute_stats_for(&self, dirty_names: &[String]) -> Result<StatsFile> {
674        let mut merged = self.stats.clone().unwrap_or_default();
675        for name in dirty_names {
676            let schema = match self.get_chunked_schema(name) {
677                Ok(s) => s,
678                Err(_) => continue,
679            };
680            let fill_value = self
681                .resolve_array_meta(name)
682                .and_then(|m| m.fill_value.clone());
683            let shape_product: u64 = schema.full_shape.iter().map(|&s| s as u64).product();
684            let mut stats = ArrayStats::new(name.clone());
685            let mut written_non_null: u64 = 0;
686            for coord in &schema.all_coords {
687                if let Some(bytes) = self.resolve_raw_chunk(name, coord).await? {
688                    let (min, max, nc, rc) =
689                        compute_chunk_partial(&bytes, &schema.dtype, fill_value.as_ref());
690                    written_non_null += rc - nc;
691                    merge_partial(&mut stats, min, max, nc, rc);
692                }
693            }
694            stats.row_count = shape_product;
695            stats.null_count = shape_product - written_non_null;
696            merged.upsert(stats);
697        }
698        Ok(merged)
699    }
700
701    /// Commits the pending mutable delta to `storage`, appends the resulting
702    /// immutable delta to `self.deltas`, and returns the names of arrays that
703    /// had dirty chunks (used to recompute stats).
704    async fn commit_pending(
705        &mut self,
706        storage: Arc<dyn Storage>,
707        delta_path: Arc<str>,
708        base_file_hint: String,
709    ) -> Result<Vec<String>> {
710        let mutable = self
711            .pending
712            .take()
713            .expect("commit_pending: no pending delta");
714        let dirty_names: Vec<String> = mutable
715            .inner
716            .array_meta
717            .iter()
718            .filter(|(_, m)| !m.layout.storage.chunks.is_empty())
719            .map(|(name, _)| name.clone())
720            .collect();
721        let immutable = mutable
722            .commit(storage, delta_path, self.cache.clone(), base_file_hint)
723            .await?;
724        self.deltas.push(immutable);
725        Ok(dirty_names)
726    }
727}
728
729// ── Compact ──────────────────────────────────────────────────────────
730
731impl ArrayFile {
732    /// Merges all committed layers into a single new base file, deleting the
733    /// sidecars and reclaiming space held by overwritten and tombstoned chunks.
734    ///
735    /// After a successful compaction [`num_layers`](Self::num_layers) returns
736    /// `1`. Recomputes and rewrites the `{stem}.stats` file.
737    pub async fn compact(&mut self) -> Result<()> {
738        // Build the merged view.
739        let merged_names: Vec<String> = self.list_arrays().into_iter().map(|m| m.name).collect();
740
741        // Allocate all chunks for merged arrays.
742        let mut allocator = DeltaAllocator::new(Arc::clone(&self.codec), self.block_target_size);
743        let mut arrays: Vec<ArrayMeta> = Vec::new();
744        let mut per_array_stats: Vec<ArrayStats> = Vec::new();
745
746        for name in &merged_names {
747            let meta = self
748                .resolve_array_meta(name)
749                .ok_or_else(|| Error::ArrayNotFound { name: name.clone() })?
750                .clone();
751
752            // Collect all chunk coords across all layers for this array.
753            let mut all_coords: indexmap::IndexSet<Vec<u32>> = indexmap::IndexSet::new();
754            for delta in &self.deltas {
755                if let Some(&i) = delta.inner.array_index.get(name.as_str()) {
756                    for e in &delta.inner.footer.arrays[i].layout.storage.chunks {
757                        all_coords.insert(e.coord.clone());
758                    }
759                }
760            }
761
762            let shape_product: u64 = meta.layout.shape.iter().map(|&s| s as u64).product();
763            let mut new_chunks: Vec<ChunkEntry> = Vec::new();
764            let mut array_stats = ArrayStats::new(name.clone());
765            let mut written_non_null: u64 = 0;
766            for coord in &all_coords {
767                // Read from newest layer that has this chunk.
768                if let Some(raw) = self.resolve_raw_chunk(name, coord).await? {
769                    let (min, max, nc, rc) =
770                        compute_chunk_partial(&raw, &meta.dtype, meta.fill_value.as_ref());
771                    written_non_null += rc - nc;
772                    merge_partial(&mut array_stats, min, max, nc, rc);
773                    let alloc = allocator.allocate(&raw);
774                    new_chunks.push(ChunkEntry {
775                        coord: coord.clone(),
776                        address: ChunkAddress::from(alloc),
777                    });
778                }
779            }
780            array_stats.row_count = shape_product;
781            array_stats.null_count = shape_product - written_non_null;
782            per_array_stats.push(array_stats);
783
784            let mut new_meta = meta;
785            new_meta.layout.storage.chunks = new_chunks;
786            arrays.push(new_meta);
787        }
788
789        let crate::delta::AllocatorOutput {
790            mut file,
791            output_size,
792            blocks,
793        } = allocator.commit().await;
794
795        // Build attr dictionaries from all layers (simple union).
796        let mut attr_keys: Vec<String> = Vec::new();
797        let mut attr_values: Vec<crate::layout::AttributeValue> = Vec::new();
798        for delta in &self.deltas {
799            for k in &delta.inner.footer.attr_keys {
800                if !attr_keys.contains(k) {
801                    attr_keys.push(k.clone());
802                }
803            }
804            for v in &delta.inner.footer.attr_values {
805                if !attr_values.contains(v) {
806                    attr_values.push(v.clone());
807                }
808            }
809        }
810
811        let footer = Footer {
812            version: FOOTER_VERSION,
813            blocks,
814            arrays,
815            attr_keys,
816            attr_values,
817            overlay_index: 0,
818            base_file_hint: String::new(),
819        };
820        let footer_bytes = footer.serialize()?;
821
822        // Write the new base file.
823        let sd = &self.store_dir;
824        // Delete old sidecars first.
825        for i in 1..self.deltas.len() {
826            let _ = sd
827                .store
828                .delete(&sidecar_path(&sd.base_path, i as u32))
829                .await;
830        }
831        let base_storage: Arc<dyn Storage> = Arc::new(ObjectStoreBackend::new(
832            Arc::clone(&sd.store),
833            sd.base_path.clone(),
834        ));
835
836        write_file_then_bytes(&mut file, output_size, &footer_bytes, &*base_storage).await?;
837        let base_delta_path: Arc<str> = Arc::from(sd.base_path.as_ref());
838        let new_base =
839            Delta::<DeltaImmutable>::open(base_storage, base_delta_path, self.cache.clone())
840                .await?;
841        self.deltas = vec![new_base];
842
843        let mut new_stats = StatsFile::default();
844        for s in per_array_stats {
845            new_stats.upsert(s);
846        }
847        let s_storage = ObjectStoreBackend::new(
848            Arc::clone(&self.store_dir.store),
849            stats_path(&self.store_dir.base_path),
850        );
851        s_storage
852            .write(bytes::Bytes::from(new_stats.serialize()?))
853            .await?;
854        self.stats = Some(new_stats);
855        Ok(())
856    }
857}
858
859// ── Helpers ──────────────────────────────────────────────────────────
860
861fn resolve_cache<C: CompressionCodec>(config: &FileConfig<C>) -> Option<Arc<DeltaCache>> {
862    if let Some(c) = &config.cache {
863        Some(Arc::clone(c))
864    } else if config.cache_capacity == 0 && config.io_cache_capacity == 0 {
865        None
866    } else {
867        Some(Arc::new(DeltaCache::new(
868            config.cache_capacity as u64,
869            config.io_cache_capacity as u64,
870        )))
871    }
872}
873
874fn sidecar_path(base: &object_store::path::Path, n: u32) -> object_store::path::Path {
875    let s = base.as_ref();
876    let without_af = s.strip_suffix(".af").unwrap_or(s);
877    object_store::path::Path::from(format!("{without_af}.{n}.af").as_str())
878}
879
880fn stats_path(base: &object_store::path::Path) -> object_store::path::Path {
881    let s = base.as_ref();
882    let without_af = s.strip_suffix(".af").unwrap_or(s);
883    object_store::path::Path::from(format!("{without_af}.stats").as_str())
884}
885
886async fn discover_sidecars_store(
887    store: &dyn ObjectStore,
888    base_path: &object_store::path::Path,
889) -> Result<Vec<(u32, object_store::path::Path)>> {
890    use futures::TryStreamExt;
891    let base_str = base_path.as_ref();
892    let stem_prefix = base_str
893        .strip_suffix(".af")
894        .ok_or_else(|| Error::Storage("path must end with .af".into()))?;
895    let list_prefix = base_str
896        .rfind('/')
897        .map(|pos| object_store::path::Path::from(&base_str[..pos]));
898    let objects: Vec<_> = store
899        .list(list_prefix.as_ref())
900        .try_collect()
901        .await
902        .map_err(|e| Error::Storage(e.to_string()))?;
903    let mut sidecars: Vec<(u32, object_store::path::Path)> = objects
904        .into_iter()
905        .filter_map(|meta| {
906            let s = meta.location.as_ref();
907            let rest = s.strip_prefix(stem_prefix)?.strip_prefix('.')?;
908            let (num_str, ext) = rest.rsplit_once('.')?;
909            if ext != "af" {
910                return None;
911            }
912            let n: u32 = num_str.parse().ok()?;
913            if n == 0 {
914                return None;
915            }
916            Some((n, meta.location))
917        })
918        .collect();
919    sidecars.sort_by_key(|(n, _)| *n);
920    Ok(sidecars)
921}
922
923async fn write_empty_base(storage: &dyn Storage) -> Result<()> {
924    let footer = Footer::new();
925    let bytes = footer.serialize()?;
926    storage.write(Bytes::from(bytes)).await
927}
928
929#[cfg(test)]
930mod tests {
931    use super::*;
932    use crate::codec::NoCompression;
933
934    #[tokio::test]
935    async fn shared_cache_is_reused_across_files() {
936        let shared = Arc::new(DeltaCache::new(1024 * 1024, 0));
937
938        let mut cfg_a = FileConfig::new(NoCompression);
939        cfg_a.cache = Some(Arc::clone(&shared));
940        let file_a = ArrayFile::create_memory(cfg_a).await.unwrap();
941
942        let mut cfg_b = FileConfig::new(NoCompression);
943        cfg_b.cache = Some(Arc::clone(&shared));
944        let file_b = ArrayFile::create_memory(cfg_b).await.unwrap();
945
946        let a = file_a.cache.as_ref().expect("file_a has cache");
947        let b = file_b.cache.as_ref().expect("file_b has cache");
948        assert!(Arc::ptr_eq(a, &shared));
949        assert!(Arc::ptr_eq(b, &shared));
950    }
951
952    /// Looks up `name` in an `attribute_index` result.
953    fn find<'a>(
954        index: &'a [(String, Option<&AttributeValue>)],
955        name: &str,
956    ) -> Option<&'a Option<&'a AttributeValue>> {
957        index.iter().find(|(n, _)| n == name).map(|(_, v)| v)
958    }
959
960    #[tokio::test]
961    async fn attribute_index_returns_full_column() {
962        let mut file = ArrayFile::create_memory(FileConfig::new(NoCompression))
963            .await
964            .unwrap();
965
966        file.define_array::<f32>("a", vec!["x".into()], vec![4], None, None)
967            .unwrap();
968        file.define_array::<f32>("b", vec!["x".into()], vec![4], None, None)
969            .unwrap();
970        file.define_array::<f32>("c", vec!["x".into()], vec![4], None, None)
971            .unwrap();
972
973        file.set_attribute("a", "units", AttributeValue::String("hPa".into()))
974            .unwrap();
975        file.set_attribute("b", "units", AttributeValue::String("Pa".into()))
976            .unwrap();
977        // "c" deliberately has no "units" attribute.
978
979        let index = file.attribute_index("units");
980        assert_eq!(index.len(), 3, "every visible array appears once");
981        assert_eq!(
982            find(&index, "a"),
983            Some(&Some(&AttributeValue::String("hPa".into())))
984        );
985        assert_eq!(
986            find(&index, "b"),
987            Some(&Some(&AttributeValue::String("Pa".into())))
988        );
989        assert_eq!(find(&index, "c"), Some(&None), "absent attribute -> None");
990    }
991
992    #[tokio::test]
993    async fn attribute_index_unknown_key_is_all_none() {
994        let mut file = ArrayFile::create_memory(FileConfig::new(NoCompression))
995            .await
996            .unwrap();
997        file.define_array::<f32>("a", vec!["x".into()], vec![4], None, None)
998            .unwrap();
999        file.set_attribute("a", "units", AttributeValue::String("hPa".into()))
1000            .unwrap();
1001
1002        let index = file.attribute_index("nonexistent");
1003        assert_eq!(index.len(), 1);
1004        assert_eq!(find(&index, "a"), Some(&None));
1005    }
1006
1007    #[tokio::test]
1008    async fn attribute_index_omits_deleted_arrays() {
1009        let mut file = ArrayFile::create_memory(FileConfig::new(NoCompression))
1010            .await
1011            .unwrap();
1012        file.define_array::<f32>("a", vec!["x".into()], vec![4], None, None)
1013            .unwrap();
1014        file.define_array::<f32>("b", vec!["x".into()], vec![4], None, None)
1015            .unwrap();
1016        file.set_attribute("a", "units", AttributeValue::String("hPa".into()))
1017            .unwrap();
1018        file.set_attribute("b", "units", AttributeValue::String("Pa".into()))
1019            .unwrap();
1020
1021        file.delete("b").unwrap();
1022
1023        let index = file.attribute_index("units");
1024        assert_eq!(index.len(), 1, "deleted array dropped from column");
1025        assert_eq!(find(&index, "b"), None);
1026        assert_eq!(
1027            find(&index, "a"),
1028            Some(&Some(&AttributeValue::String("hPa".into())))
1029        );
1030    }
1031
1032    #[tokio::test]
1033    async fn attribute_index_survives_flush() {
1034        let mut file = ArrayFile::create_memory(FileConfig::new(NoCompression))
1035            .await
1036            .unwrap();
1037        file.define_array::<f32>("a", vec!["x".into()], vec![4], None, None)
1038            .unwrap();
1039        file.set_attribute("a", "units", AttributeValue::String("hPa".into()))
1040            .unwrap();
1041
1042        // After flush the attribute lives in a committed sidecar footer, not the
1043        // pending layer — the query must still read it from the delta stack.
1044        file.flush().await.unwrap();
1045
1046        let index = file.attribute_index("units");
1047        assert_eq!(
1048            find(&index, "a"),
1049            Some(&Some(&AttributeValue::String("hPa".into())))
1050        );
1051    }
1052}