Skip to main content

infino/supertable/
error.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright The Infino Authors
3
4//! Typed errors for the supertable layer.
5//!
6//! Mirrors `superfile::error::BuildError` in shape — the
7//! supertable's options-validation rules are a strict superset of
8//! the superfile's, so most variants either parallel a superfile
9//! variant or convert from one. The only genuinely supertable-
10//! specific shapes are the `VectorColumnNotFixedSizeList` /
11//! `VectorColumnDimMismatch` / `VectorColumnHasNulls` variants
12//! that arise because supertable's schema includes vector columns
13//! as `FixedSizeList<Float32>` (vs superfile, where vectors are
14//! out-of-band entirely).
15
16use std::path::PathBuf;
17
18use thiserror::Error;
19
20use crate::{
21    storage::StorageError,
22    superfile::error::BuildError as SuperfileBuildError,
23    supertable::{ManifestLoadError, manifest::part},
24};
25
26/// Errors raised when constructing or operating against a
27/// `SupertableOptions` / `SupertableWriter`.
28#[derive(Debug, Error)]
29pub enum BuildError {
30    #[error("no documents to build")]
31    NoDocsToBuild,
32
33    #[error("schema is missing the declared id_column {0:?}")]
34    MissingIdColumn(String),
35
36    #[error("id_column {0:?} must be Decimal128(38, 0); found {1}")]
37    IdColumnWrongType(String, String),
38
39    #[error(
40        "user schema must not contain a column named {0:?} — \
41         that name is reserved for the supertable-managed id column"
42    )]
43    IdColumnReserved(String),
44
45    #[error("FTS column {column:?} not found in schema")]
46    FtsColumnMissing { column: String },
47
48    #[error("FTS column {column:?} must be LargeUtf8; found {actual}")]
49    FtsColumnMustBeLargeUtf8 { column: String, actual: String },
50
51    #[error("vector column {column:?} not found in schema")]
52    VectorColumnMissing { column: String },
53
54    #[error("vector column {column:?} must be FixedSizeList<Float32, {dim}>; found {actual}")]
55    VectorColumnNotFixedSizeList {
56        column: String,
57        dim: usize,
58        actual: String,
59    },
60
61    #[error(
62        "vector column {column:?} declares dim={expected}; \
63         schema FixedSizeList list_size is {actual}"
64    )]
65    VectorColumnDimMismatch {
66        column: String,
67        expected: usize,
68        actual: usize,
69    },
70
71    #[error(
72        "vector column {column:?} contains null entries at row offsets {first_nulls:?}; \
73         null vectors are not permitted in v1"
74    )]
75    VectorColumnHasNulls {
76        column: String,
77        first_nulls: Vec<usize>,
78    },
79
80    #[error("vector column {column:?} declares dim={dim}; must be in [16, 4096]")]
81    VectorDimOutOfRange { column: String, dim: usize },
82
83    #[error("logical name {0:?} duplicated across fts_columns and vector_columns")]
84    DuplicateLogicalName(String),
85
86    #[error("user column name {0:?} contains reserved \\x1F separator")]
87    ReservedSeparatorInColumnName(String),
88
89    #[error("user column name {0:?} starts with reserved prefix 'inf.'")]
90    ReservedPrefixInColumnName(String),
91
92    #[error(
93        "FTS columns declared but no tokenizer supplied; tokenizer is required iff fts_columns is non-empty"
94    )]
95    MissingTokenizer,
96
97    #[error("input RecordBatch schema does not match the supertable's declared schema")]
98    BatchSchemaMismatch,
99
100    #[error("error from underlying superfile layer: {0}")]
101    Superfile(#[from] SuperfileBuildError),
102
103    /// Ingest build refused: would cross the connection memory budget. The
104    /// string is already labelled ("during ingest, ..."); routes to
105    /// `InfinoError::OverBudget` via [`BuildError::over_budget`].
106    #[error("{0}")]
107    OverBudget(String),
108
109    #[error(
110        "another SupertableWriter is already outstanding for this Supertable; \
111         drop it before acquiring a new one"
112    )]
113    SupertableInUse,
114
115    #[error("superfile store: {0}")]
116    Store(String),
117
118    /// The table was dropped and purged while this handle was open, so the
119    /// commit had no pointer to fence against. Carried as its own variant
120    /// rather than folded into [`Self::Store`] so the public mapping can
121    /// report a missing table instead of a backend fault — see
122    /// [`CommitError::PointerVanished`] and `From<BuildError> for InfinoError`.
123    #[error("table was dropped and purged while this handle was open")]
124    TableGone,
125
126    /// A concurrent writer won the manifest CAS and the commit's retry
127    /// budget ran out. Carried as its own variant rather than folded into
128    /// [`Self::Store`] — a stringified error can't be matched on, and the
129    /// public mapping needs to report a retryable conflict rather than a
130    /// backend fault. See [`CommitError::WriteContentionExhausted`] and
131    /// `From<BuildError> for InfinoError`.
132    #[error("write contention: a concurrent writer won the commit race")]
133    WriteContention,
134
135    #[error("merge needs more memory than the connection budget allows: {0}")]
136    MemoryBudgetExceeded(String),
137
138    #[error("rayon thread pool creation failed: {0}")]
139    ThreadPoolCreation(String),
140
141    #[error("error reading the just-built superfile during commit: {0}")]
142    ReadAfterCommit(String),
143
144    /// Storage backend construction failed (auth handshake on
145    /// S3, invalid endpoint, region mismatch, LocalFS root not
146    /// writable). Source chain preserved so callers can match
147    /// on `StorageError::Permanent` vs `::TransientExhausted`
148    /// for retry semantics.
149    #[error("storage construction failed: {0}")]
150    StorageConstruction(#[from] StorageError),
151
152    /// Disk-cache root directory exists but isn't writable, or
153    /// can't be created. Distinct from `StorageConstruction`
154    /// because the disk cache is a local-only concern that
155    /// doesn't go through the storage provider.
156    #[error("disk cache root unwritable: {0}")]
157    DiskCacheRootUnwritable(PathBuf),
158
159    /// `partition_strategy` names a column the schema doesn't
160    /// have. Construction-time check — never silently falls
161    /// back. Caller fixes config or schema.
162    #[error("partition column missing in schema: {0}")]
163    PartitionColumnMissing(String),
164}
165
166impl BuildError {
167    /// The over-budget message if this is a budget refusal, else `None`.
168    pub(crate) fn over_budget(&self) -> Option<&str> {
169        match self {
170            BuildError::OverBudget(m) => Some(m),
171            _ => None,
172        }
173    }
174
175    /// True when the build failed because a concurrent writer won a
176    /// compare-and-set race, so reissuing against fresh state can succeed.
177    pub(crate) fn is_conflict(&self) -> bool {
178        match self {
179            BuildError::WriteContention => true,
180            BuildError::StorageConstruction(e) => e.is_conflict(),
181            _ => false,
182        }
183    }
184}
185
186impl From<CommitError> for BuildError {
187    /// Commit failures reach the build path as `Store` carrying the message —
188    /// except a vanished pointer and a lost commit race, which keep their own
189    /// variants so the public mapping can report a missing table or a
190    /// retryable conflict. A stringified error cannot be matched on,
191    /// and the append path converts here before any caller sees it.
192    fn from(e: CommitError) -> Self {
193        match e {
194            CommitError::PointerVanished => BuildError::TableGone,
195            other if other.is_conflict() => BuildError::WriteContention,
196            other => BuildError::Store(other.to_string()),
197        }
198    }
199}
200
201/// Errors raised by the supertable's commit path — building +
202/// publishing a new manifest version. Stable public surface;
203/// downstream callers may match on specific variants for
204/// recovery (e.g., `WriteContentionExhausted` from the OCC
205/// retry loop, `SuperfileSpansPartition` from the
206/// partition-assignment validation).
207#[derive(Debug, Error)]
208pub enum CommitError {
209    /// Storage backend returned an error during commit.
210    #[error("storage error during commit: {0}")]
211    Storage(#[from] crate::storage::StorageError),
212
213    /// Below-storage validation (options + schema) failed.
214    #[error("build error during commit")]
215    Build(#[from] BuildError),
216
217    /// ManifestSnapshot error
218    #[error("manifest error: {0}")]
219    ManifestError(#[from] ManifestError),
220
221    /// Failed to encode a manifest part or list to its wire
222    /// format. Indicates a programmer error (e.g., a
223    /// non-serializable scalar value in a manifest list), not
224    /// a transient failure.
225    #[error("manifest encode failed: {0}")]
226    Encode(String),
227
228    /// Pointer file on storage is malformed (truncated,
229    /// missing required fields, unexpected key).
230    #[error("pointer file parse failed: {0}")]
231    PointerParse(String),
232
233    /// OCC retry budget exhausted on a contended commit.
234    /// Reserved variant — the current writer doesn't retry,
235    /// but the public surface carries this so adding the retry
236    /// loop later is non-breaking.
237    #[error("write contention exhausted retries")]
238    WriteContentionExhausted,
239
240    /// The pointer this commit would have fenced against is gone: the table
241    /// was dropped and purged while this handle stayed open. Not retryable.
242    #[error("manifest pointer was deleted while this handle was open")]
243    PointerVanished,
244}
245
246impl CommitError {
247    /// True when the commit failed because a concurrent writer won the
248    /// pointer / part CAS, so reissuing against fresh state can succeed.
249    ///
250    /// A raw [`StorageError::PreconditionFailed`] can still reach here from a
251    /// sub-write that skipped the commit module's `translate_contention`, so
252    /// both shapes are classified together.
253    pub(crate) fn is_conflict(&self) -> bool {
254        match self {
255            CommitError::WriteContentionExhausted => true,
256            CommitError::Storage(e) => e.is_conflict(),
257            CommitError::Build(b) => b.is_conflict(),
258            _ => false,
259        }
260    }
261}
262
263#[derive(Debug, Error)]
264pub enum ManifestError {
265    /// A superfile's column range spans multiple
266    /// partitions under the configured `PartitionStrategy`.
267    /// For `TimeRange` / `ColumnRange`, the superfile's
268    /// `(min, max)` straddles a bucket boundary. For `Hash`,
269    /// the superfile's `partition_hint` is unset — the writer
270    /// didn't pre-shard.
271    ///
272    /// Single-bucket Hash strategies (`n_buckets == 1`) are
273    /// special-cased to bypass this check, since every
274    /// possible value hashes to bucket 0.
275    #[error("superfile spans partition boundary: {detail}")]
276    SuperfileSpansPartition { detail: String },
277    /// A superfile entry reached `update()` already carrying a
278    /// `partition_key`. Entries must arrive unstamped: the key is
279    /// derived from the strategy at commit time. A non-empty key means
280    /// an earlier stage already stamped it, and committing would
281    /// silently overwrite that assignment.
282    #[error("superfile entry already partitioned: {detail}")]
283    EntryAlreadyPartitioned { detail: String },
284    /// Manifest load error
285    #[error("manifest load error: {0}")]
286    ManifestLoadError(#[from] ManifestLoadError),
287    /// Unknown part id
288    #[error("unknown part id: {0}")]
289    UnknownPartId(part::PartId),
290}
291
292/// Errors raised by [`crate::supertable::Supertable::open`] and
293/// [`crate::supertable::Supertable::refresh`].
294///
295/// Stable public surface; downstream callers may match on
296/// specific variants for recovery (e.g., `PointerUnreadable`
297/// for the open-or-create pattern: caller falls back to
298/// `Supertable::create`).
299#[derive(Debug, Error)]
300pub enum OpenError {
301    /// Pointer file at `_supertable/current` doesn't exist or
302    /// can't be read. Matches the "open-or-create" trigger:
303    /// callers wanting that semantic catch this variant and
304    /// fall back to [`crate::supertable::Supertable::create`].
305    #[error("pointer file missing or unreadable")]
306    PointerUnreadable(#[source] crate::storage::StorageError),
307
308    /// ManifestSnapshot list parse failed.
309    #[error("manifest list parse failed")]
310    ManifestListParse(String),
311
312    /// ManifestSnapshot load error.
313    #[error("manifest load error: {0}")]
314    ManifestLoadError(#[from] ManifestLoadError),
315
316    /// ManifestSnapshot part load or parse failed during open or
317    /// refresh.
318    #[error("manifest part load failed: {part_id}")]
319    ManifestPartLoad {
320        part_id: String,
321        #[source]
322        source: Box<dyn std::error::Error + Send + Sync>,
323    },
324
325    /// Content-hash mismatch on a loaded manifest part — the
326    /// bytes returned by storage don't match the hash recorded
327    /// in the manifest list. Either storage corruption or a
328    /// serious bug; never auto-refetched (treated as a
329    /// caller-visible failure so the inconsistency can't be
330    /// papered over silently).
331    #[error("content-hash mismatch: expected {expected}, got {actual}")]
332    ContentHashMismatch { expected: String, actual: String },
333
334    /// Storage backend returned an unexpected error during
335    /// open or refresh.
336    #[error("storage error during open")]
337    Storage(#[from] crate::storage::StorageError),
338
339    /// Configuration error — e.g., calling
340    /// `Supertable::open` on options with no storage backend
341    /// attached.
342    #[error("build error during open")]
343    Build(#[from] BuildError),
344
345    /// Pointer-file or commit-error surfaced through the open
346    /// path.
347    #[error("commit error during open")]
348    Commit(#[from] CommitError),
349}
350
351impl OpenError {
352    /// True when the open lost a race against a concurrent writer — the
353    /// bootstrap commit an open-or-create performs is CAS-fenced like any
354    /// other, so a peer creating the same table first lands here.
355    pub(crate) fn is_conflict(&self) -> bool {
356        match self {
357            OpenError::PointerUnreadable(e) | OpenError::Storage(e) => e.is_conflict(),
358            OpenError::Build(b) => b.is_conflict(),
359            OpenError::Commit(c) => c.is_conflict(),
360            _ => false,
361        }
362    }
363}
364
365/// Errors raised by [`crate::Supertable::optimize`].
366#[derive(Debug, thiserror::Error)]
367pub enum OptimizeError {
368    /// No durable storage backend is configured (e.g. `memory://`); optimize
369    /// needs one.
370    #[error("optimize requires a storage backend")]
371    NoStorage,
372    /// A superfile selected for compaction was absent from the manifest
373    /// snapshot.
374    #[error("superfile {0} not found in manifest snapshot")]
375    SuperfileNotFound(uuid::Uuid),
376    /// Compaction produced an empty merged superfile.
377    #[error("empty merged superfile")]
378    EmptyMergedSuperfile,
379    /// The tombstone sidecar for a superfile was already sealed by another
380    /// compaction.
381    #[error(
382        "tombstone sidecar for {superfile_id} already sealed by compaction {existing_compaction_id}"
383    )]
384    SidecarConflict {
385        /// The superfile whose sidecar conflicted.
386        superfile_id: uuid::Uuid,
387        /// The compaction that had already sealed the sidecar.
388        existing_compaction_id: uuid::Uuid,
389    },
390    /// Sealing the compaction output failed.
391    #[error("seal failed: {0}")]
392    Seal(String),
393    /// Building a merged superfile failed.
394    #[error("failed to build superfile: {0}")]
395    Build(String),
396    /// Committing the compaction to the manifest failed.
397    #[error("failed to commit: {0}")]
398    Commit(String),
399    /// Refreshing the in-memory manifest after the commit failed.
400    #[error("post-commit manifest refresh failed: {0}")]
401    Refresh(String),
402    /// Another optimize is already running on this handle.
403    #[error("optimize already in progress on this handle")]
404    AlreadyRunning,
405    /// The post-compaction garbage-collection step failed.
406    #[error("gc failed during optimize: {0}")]
407    Gc(#[from] GcError),
408    /// The post-compaction WAL sweep failed.
409    #[error("wal sweep failed during optimize: {0}")]
410    WalGc(#[from] crate::supertable::wal::gc::GcError),
411}
412
413impl From<CompactionError> for OptimizeError {
414    fn from(e: CompactionError) -> Self {
415        match e {
416            CompactionError::NoStorage => OptimizeError::NoStorage,
417            CompactionError::SuperfileNotFound(id) => OptimizeError::SuperfileNotFound(id),
418            CompactionError::EmptyMergedSuperfile => OptimizeError::EmptyMergedSuperfile,
419            CompactionError::SidecarConflict {
420                superfile_id,
421                existing_compaction_id,
422            } => OptimizeError::SidecarConflict {
423                superfile_id,
424                existing_compaction_id,
425            },
426            CompactionError::Seal(s) => OptimizeError::Seal(s),
427            CompactionError::Build(s) => OptimizeError::Build(s),
428            CompactionError::Commit(s) => OptimizeError::Commit(s),
429            CompactionError::Refresh(s) => OptimizeError::Refresh(s),
430            CompactionError::AlreadyCompacting => OptimizeError::AlreadyRunning,
431        }
432    }
433}
434
435#[derive(Debug, thiserror::Error)]
436pub(crate) enum CompactionError {
437    /// Compaction requires durable storage
438    /// (needs to seal sidecars and publish the merged superfile).
439    #[error("compaction requires a storage backend")]
440    NoStorage,
441
442    /// A superfile listed in a `CompactionJob` is not present in the
443    /// current manifest snapshot.
444    #[error("superfile {0} not found in manifest snapshot")]
445    SuperfileNotFound(uuid::Uuid),
446
447    #[error("empty merged superfile")]
448    EmptyMergedSuperfile,
449
450    /// The tombstone sidecar for `superfile_id` is already sealed by
451    /// a different compaction run. Caller must drive the abandoned
452    /// compaction to completion (or unwind it) before retrying.
453    #[error(
454        "tombstone sidecar for {superfile_id} already sealed by compaction {existing_compaction_id}"
455    )]
456    SidecarConflict {
457        superfile_id: uuid::Uuid,
458        existing_compaction_id: uuid::Uuid,
459    },
460
461    /// A WAL-store I/O error occurred while sealing a sidecar.
462    #[error("seal failed: {0}")]
463    Seal(String),
464
465    /// Error when building the compacted superfile. Carries the
466    /// rendered cause as a string so the public error does not leak the
467    /// crate-internal `BuildError` type.
468    #[error("failed to build superfile: {0}")]
469    Build(String),
470
471    /// Error when committing the compacted superfile. Carries the
472    /// rendered cause as a string (see `Build`).
473    #[error("failed to commit compaction: {0}")]
474    Commit(String),
475
476    /// Refreshing the in-memory manifest after a successful commit failed.
477    #[error("post-commit manifest refresh failed: {0}")]
478    Refresh(String),
479
480    /// Another compaction is already running on this supertable handle.
481    #[error("compaction already in progress on this supertable handle")]
482    AlreadyCompacting,
483}
484
485/// Errors raised by [`crate::Supertable::gc`].
486#[derive(Debug, thiserror::Error)]
487pub enum GcError {
488    /// No durable storage backend is configured (e.g. `memory://`); gc needs
489    /// one.
490    #[error("gc requires a storage backend")]
491    NoStorage,
492
493    /// A storage operation failed while listing or deleting objects.
494    #[error("storage error during gc: {0}")]
495    Storage(#[from] crate::storage::StorageError),
496}
497
498/// Errors raised by query-time methods on [`crate::supertable::Supertable`]
499/// (`query_sql`; future: `bm25_search`, `vector_search`).
500///
501/// Each variant carries a stringified source — DataFusion's error
502/// types are not in the supertable's public dependency surface, so
503/// we don't propagate them as `#[from]`. Callers get the formatted
504/// message; structured introspection isn't a v1 concern. When the
505/// SQL surface gains a manifest-level skip planner, it'll get its
506/// own variant to distinguish "DataFusion failed" from "store
507/// failed mid-scan".
508#[derive(Debug, Error)]
509pub enum QueryError {
510    #[error("superfile store error during query: {0}")]
511    Store(String),
512
513    #[error("error reading parquet bytes during scan: {0}")]
514    Parquet(String),
515
516    #[error("invalid query: {0}")]
517    InvalidQuery(String),
518
519    #[error("DataFusion failed to plan the query: {0}")]
520    Plan(String),
521
522    #[error("DataFusion failed to execute the query: {0}")]
523    Execute(String),
524
525    /// A query crossed the connection memory budget. The string is already
526    /// labelled with the operation; routes to `InfinoError::OverBudget` via
527    /// [`QueryError::over_budget`].
528    #[error("{0}")]
529    OverBudget(String),
530
531    #[error("manifest load error: {0}")]
532    ManifestLoad(ManifestLoadError),
533}
534
535impl QueryError {
536    /// The over-budget message if this is a budget refusal, else `None`.
537    pub(crate) fn over_budget(&self) -> Option<&str> {
538        match self {
539            QueryError::OverBudget(m) => Some(m),
540            _ => None,
541        }
542    }
543}