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}