Skip to main content

loonfs_core/
error.rs

1//! Core error types and their wire-code classification.
2//!
3//! Stringification rule: core errors are `Clone`/serde-constrained, so foreign
4//! causes (object-store, codec, io) are captured as prefixed message strings
5//! next to the object key they are about, not as `#[source]` chains. Crates
6//! without those constraints (server, CLI) prefer `#[source]` chains instead.
7
8use crate::checkpoint::ManifestLoadError;
9use crate::commit::{CommitHeadPublishError, CommitValidationError};
10use crate::commit_engine::ContentPreparationError;
11use crate::metadata::VisiblePathError;
12use crate::namespace::catalog::NamespaceCatalogLoadError;
13use crate::namespace::control::ControlObjectLoadError;
14use crate::namespace::writer_epoch::WriterEpochAcquireError;
15use crate::storage::content::DurableContentValidationError;
16use crate::wal::{WalBuildError, WalChainLoadError, WalReplayError};
17use loonfs_api::wire::control::HeadState;
18use loonfs_api::{
19    ChangeSeq, CommitId, CommitIdValidationError, ErrorDetails, GeneratedIdValidationError,
20    InodeId, InodeKind, NamespaceId, NamespaceIdValidationError, RevisionNo, UploadId, WriterEpoch,
21};
22use loonfs_objectstore::{ImmutableWriteError, ObjectStoreError};
23use thiserror::Error;
24
25/// Public error type returned by `loonfs-core`.
26///
27/// Use [`Error::kind`] for broad caller action and [`Error::code`] for a stable
28/// machine-readable reason.
29pub use self::CoreError as Error;
30
31/// Result type used by `loonfs-core` entrypoints. Crate-internal: the alias
32/// is transparent, so public signatures using it still read as
33/// `std::result::Result<T, Error>` from outside.
34pub(crate) type Result<T> = std::result::Result<T, Error>;
35
36pub use loonfs_api::{ErrorCode, ErrorKind};
37
38/// Detailed core error.
39///
40/// Most callers should branch on [`CoreError::kind`] or [`CoreError::code`]
41/// instead of matching every internal variant.
42#[derive(Debug, Clone, Error)]
43#[non_exhaustive]
44pub enum CoreError {
45    #[error(transparent)]
46    MetadataProjection(#[from] MetadataProjectionLoadError),
47    #[error(transparent)]
48    MetadataView(#[from] MetadataViewError),
49    #[error(transparent)]
50    VisiblePath(#[from] VisiblePathError),
51    #[error(transparent)]
52    DurableContent(#[from] DurableContentValidationError),
53    #[error(transparent)]
54    WriterEpoch(#[from] WriterEpochAcquireError),
55    #[error("commit validation failed: {0}")]
56    CommitValidation(#[from] CommitValidationError),
57    #[error("wal build failed: {0}")]
58    WalBuild(#[from] WalBuildError),
59    #[error("head publish failed: {0}")]
60    HeadPublish(#[from] CommitHeadPublishError),
61    #[error("failed to write wal object `{object_key}`: {message}")]
62    WalWrite {
63        object_key: String,
64        message: String,
65        class: StoreFailureClass,
66    },
67    #[error("invalid absolute path `{0}`")]
68    InvalidPath(String),
69    #[error(transparent)]
70    InvalidNamespaceId(#[from] NamespaceIdValidationError),
71    #[error(transparent)]
72    InvalidCommitId(#[from] CommitIdValidationError),
73    #[error("invalid commit request: {0}")]
74    InvalidCommitRequest(String),
75    #[error(transparent)]
76    InvalidUploadId(#[from] GeneratedIdValidationError),
77    #[error("path not found `{0}`")]
78    PathNotFound(String),
79    #[error("revision `{revision_no}` not found for inode `{inode_id}`")]
80    RevisionNotFound {
81        inode_id: InodeId,
82        revision_no: RevisionNo,
83    },
84    /// A buffered content read was refused before any fetch: the resolved
85    /// content is larger than the caller's read budget allows in memory.
86    #[error(
87        "file content is {size_bytes} bytes, over the {max_bytes}-byte limit \
88         this deployment buffers for one read"
89    )]
90    ContentTooLarge { size_bytes: u64, max_bytes: u64 },
91    /// A batch read asked for more items than one call answers. The caller
92    /// splits the batch; nothing was read.
93    #[error("asked for {requested} items, over the {max} one batch answers")]
94    BatchTooLarge { requested: usize, max: usize },
95    /// A streamed read was asked to start past the end of the content it
96    /// reads. Nothing was read.
97    #[error("cannot start a read at offset {start_offset} of {size_bytes}-byte content")]
98    ResumeOffsetOutOfRange { start_offset: u64, size_bytes: u64 },
99    /// A streamed read that starts past zero was driven before the bytes it
100    /// skipped were folded into its verification. Verification covers the
101    /// whole object, so it cannot begin until the caller has handed over
102    /// what it already holds. Nothing was read.
103    #[error(
104        "a read resumed at offset {start_offset} was given {folded} bytes of what it skipped; \
105         verification covers the whole object, so all of them are needed first"
106    )]
107    ResumePrefixIncomplete { start_offset: u64, folded: u64 },
108    #[error("expected file at `{path}` but found `{kind}`")]
109    ExpectedFile { path: String, kind: InodeKind },
110    #[error("expected directory at `{path}` but found `{kind}`")]
111    ExpectedDirectory { path: String, kind: InodeKind },
112    #[error("directory not empty `{0}`")]
113    DirectoryNotEmpty(String),
114    #[error("cannot mutate root path")]
115    RootMutationForbidden,
116    #[error("{}", destination_exists_message(.path, .existing_display_name.as_deref()))]
117    DestinationExists {
118        path: String,
119        /// Stored spelling of the occupying entry, when the planner had it.
120        /// Rendered when it differs from what the caller typed, because
121        /// sibling names collide after NFC normalization and case folding
122        /// and the two spellings can look identical.
123        existing_display_name: Option<String>,
124    },
125    #[error("commit id conflict for `{commit_id}`")]
126    CommitIdReuseConflict {
127        commit_id: String,
128        /// Sequence the commit id already landed at, when the conflict was
129        /// decided against a durable receipt — the receipt is what holds
130        /// it. Absent when nothing has committed under the id yet and two
131        /// live requests are claiming it at once, which is the one case a
132        /// caller cannot reconcile by reading the feed.
133        committed_seq: Option<ChangeSeq>,
134        /// Semantic identity of the mutation that landed under the id, taken
135        /// from the same receipt as `committed_seq` and present exactly when
136        /// it is. Reporting it is what lets a retry prove it is the same
137        /// request by recomputing one value, rather than comparing whichever
138        /// fields it thought to compare.
139        committed_fingerprint: Option<String>,
140    },
141    #[error(transparent)]
142    ContentPreparation(#[from] ContentPreparationError),
143    #[error("commit queue is full; slow down and retry")]
144    CommitQueueFull,
145    /// The serving front-end closed admission for shutdown. New work is
146    /// refused; work admitted earlier still settles.
147    #[error("shutting down; new work is not admitted")]
148    ShuttingDown,
149    #[error("checkpoint unavailable: {0}")]
150    CheckpointUnavailable(String),
151    #[error("invalid checkpoint request: {0}")]
152    InvalidCheckpointRequest(String),
153    #[error(
154        "metadata publication budget exceeded after {elapsed_ms}ms (budget {budget_ms}ms); \
155         the root was not published"
156    )]
157    MetadataPublicationBudgetExceeded { elapsed_ms: u64, budget_ms: u64 },
158    #[error("invalid gc configuration: {0}")]
159    InvalidGcConfig(String),
160    #[error("invalid search query: {0}")]
161    InvalidQuery(String),
162    #[error("the pattern requires no literal bytes and cannot use the index: {0}")]
163    QueryUnindexable(String),
164    #[error(
165        "the grep index trails the head by {behind_commits} commits, past the \
166         exhaustive-scan budget; run maintenance or set allow_stale"
167    )]
168    IndexLagging { behind_commits: u64 },
169    #[error("upload session `{upload_id}` was not found")]
170    UploadNotFound { upload_id: UploadId },
171    #[error("upload session `{upload_id}` is already completed")]
172    UploadAlreadyCompleted { upload_id: UploadId },
173    #[error("upload session `{upload_id}` content conflicts with prior content")]
174    UploadContentConflict { upload_id: UploadId },
175    #[error("invalid upload content: {0}")]
176    InvalidUploadContent(String),
177    #[error("invalid cursor: {0}")]
178    InvalidCursor(String),
179    #[error(
180        "change feed cursor `{after_seq}` is older than retention floor `{retention_floor_seq}`"
181    )]
182    RebootstrapRequired {
183        after_seq: ChangeSeq,
184        retention_floor_seq: ChangeSeq,
185    },
186    #[error("path component `{0}` is not a directory")]
187    NonDirectoryPathComponent(String),
188    #[error("namespace corrupt: {0}")]
189    NamespaceCorrupt(String),
190    /// This writer session's epoch was superseded. Terminal for the session:
191    /// callers surface it without reacquiring.
192    #[error("writer session fenced: {0}")]
193    WriterFenced(WriterFence),
194    #[error("object store error for `{object_key}`: {message}")]
195    Store {
196        object_key: String,
197        message: String,
198        class: StoreFailureClass,
199    },
200    /// Non-store internal failure (codec, overflow, invariant breach). Same
201    /// wire code as [`ErrorCode::ServerError`]; the message is the detail.
202    #[error("internal error: {0}")]
203    Internal(String),
204    #[error("namespace `{namespace_id}` already exists")]
205    NamespaceExists { namespace_id: NamespaceId },
206    #[error("namespace `{namespace_id}` is deleted")]
207    NamespaceDeleted { namespace_id: NamespaceId },
208    /// A caller-supplied `expected_head_seq` did not match the head.
209    ///
210    /// Distinct from the publish path's [`CommitHeadPublishError::StaleHead`],
211    /// which reports a race the caller never asked about: this is a
212    /// precondition the caller wrote, so the rejection owes it both numbers —
213    /// what it asked for and what the namespace was actually at — and a
214    /// caller that meant to delete the current head can retry against the
215    /// sequence it found. Both carry the `stale_head` code.
216    #[error("expected head sequence {expected}, found {actual}")]
217    StaleHeadPrecondition {
218        expected: ChangeSeq,
219        actual: ChangeSeq,
220    },
221    /// A batch stopped at one of its operations. A mutation commits all of
222    /// its operations or none of them, so this names the operation that
223    /// stopped the request and carries the failure it produced. The wire code
224    /// stays the inner failure's; the position joins the message and the
225    /// structured details.
226    ///
227    /// Only a request with more than one operation is wrapped: a
228    /// single-operation request has one place to fail, so its error stays
229    /// exactly what the operation produced.
230    #[error("operation {operation_index}: {source}")]
231    FailedOperation {
232        operation_index: u32,
233        source: Box<CoreError>,
234    },
235}
236
237/// Failures specific to manifest-plus-tail metadata views.
238///
239/// These are not generic store failures: each variant names the recovery or
240/// caller action we expect. Normal reads and publishes must return these
241/// errors instead of falling back to a whole-namespace rebuild.
242#[derive(Debug, Clone, Error)]
243pub enum MetadataViewError {
244    #[error("namespace `{namespace_id}` head has no current manifest")]
245    MissingManifest { namespace_id: NamespaceId },
246    #[error("metadata view for namespace `{namespace_id}` requires maintenance: {reason}")]
247    MaintenanceRequired {
248        namespace_id: NamespaceId,
249        reason: String,
250    },
251    #[error(
252        "the cursor was minted at seq `{requested_seq}`, ahead of the loaded head `{head_seq}`; restart the listing"
253    )]
254    SnapshotUnavailable {
255        requested_seq: ChangeSeq,
256        head_seq: ChangeSeq,
257    },
258}
259
260/// Failures while loading a bounded manifest-plus-tail metadata projection.
261///
262/// These variants name durable/control failure cases without implying that a
263/// full namespace state was reconstructed.
264#[derive(Debug, Clone, PartialEq, Eq, Error)]
265pub enum MetadataProjectionLoadError {
266    #[error(transparent)]
267    LoadHead(#[from] ControlObjectLoadError),
268    #[error("missing head etag for `{object_key}`")]
269    MissingHeadEtag { object_key: String },
270    #[error("namespace `{namespace_id}` is deleted")]
271    NamespaceDeleted { namespace_id: NamespaceId },
272    #[error(
273        "namespace head changed during metadata projection load for `{object_key}`: loaded `{loaded_head_etag}`, current `{current_head_etag}`"
274    )]
275    HeadChangedDuringLoad {
276        object_key: String,
277        loaded_head_etag: String,
278        current_head_etag: String,
279    },
280    #[error(transparent)]
281    WalChainLoad(#[from] WalChainLoadError),
282    #[error(transparent)]
283    ManifestLoad(#[from] ManifestLoadError),
284    #[error("wal replay failed: {0}")]
285    WalReplay(#[from] WalReplayError),
286    #[error(
287        "metadata projection head mismatch: expected current head `{expected:?}`, replayed `{actual:?}`"
288    )]
289    ReplayedHeadMismatch {
290        expected: Box<HeadState>,
291        actual: Box<HeadState>,
292    },
293}
294
295impl From<NamespaceCatalogLoadError> for MetadataProjectionLoadError {
296    fn from(value: NamespaceCatalogLoadError) -> Self {
297        match value {
298            NamespaceCatalogLoadError::LoadHead(error) => Self::LoadHead(error),
299        }
300    }
301}
302
303impl From<NamespaceCatalogLoadError> for CoreError {
304    fn from(value: NamespaceCatalogLoadError) -> Self {
305        Self::MetadataProjection(value.into())
306    }
307}
308
309impl From<ImmutableWriteError> for CoreError {
310    fn from(value: ImmutableWriteError) -> Self {
311        let fallback_object_key = value.object_key().to_owned();
312        match value {
313            ImmutableWriteError::DifferentObject { object_key } => Self::Store {
314                object_key,
315                message: "immutable object already exists with different bytes".to_owned(),
316                class: StoreFailureClass::Other,
317            },
318            ImmutableWriteError::Transport { object_key, source } => Self::Store {
319                object_key,
320                message: source.message(),
321                class: StoreFailureClass::of(&source),
322            },
323            error => Self::Store {
324                object_key: fallback_object_key,
325                message: error.to_string(),
326                class: StoreFailureClass::Other,
327            },
328        }
329    }
330}
331
332/// What a failed provider operation says about who can fix it, preserved
333/// across the message-flattening seams so the wire code can distinguish
334/// "fix the storage credentials" from "unclassified internal failure".
335#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
336pub enum StoreFailureClass {
337    /// The provider rejected the deployment's credentials: operator work,
338    /// never transient. Served as `permission_denied`.
339    PermissionDenied,
340    /// Everything else is internal from the caller's point of view.
341    Other,
342}
343
344impl StoreFailureClass {
345    /// Classifies a provider error at the seam where its message is
346    /// flattened into a carrier's `message` field.
347    pub fn of(error: &ObjectStoreError) -> Self {
348        match error {
349            ObjectStoreError::PermissionDenied { .. } => Self::PermissionDenied,
350            _ => Self::Other,
351        }
352    }
353}
354
355fn classify_store_failure(class: StoreFailureClass) -> ErrorCode {
356    match class {
357        StoreFailureClass::PermissionDenied => ErrorCode::PermissionDenied,
358        StoreFailureClass::Other => ErrorCode::ServerError,
359    }
360}
361
362impl CoreError {
363    pub(crate) fn load_head(error: ControlObjectLoadError) -> Self {
364        Self::MetadataProjection(MetadataProjectionLoadError::LoadHead(error))
365    }
366
367    /// Builds [`CoreError::Store`] for a failed object-store operation on
368    /// `object_key`.
369    pub(crate) fn store(object_key: impl Into<String>, error: &ObjectStoreError) -> Self {
370        Self::Store {
371            object_key: object_key.into(),
372            message: error.message(),
373            class: StoreFailureClass::of(error),
374        }
375    }
376
377    pub fn kind(&self) -> ErrorKind {
378        self.code().kind()
379    }
380
381    pub fn code(&self) -> ErrorCode {
382        match self {
383            CoreError::MetadataProjection(error) => classify_metadata_projection_load_error(error),
384            CoreError::MetadataView(error) => classify_metadata_view_error(error),
385            CoreError::VisiblePath(error) => classify_visible_path_error(error),
386            CoreError::DurableContent(error) => classify_durable_content_error(error),
387            CoreError::WriterEpoch(error) => classify_writer_epoch_acquire_error(error),
388            CoreError::CommitValidation(error) => classify_commit_validation_error(error),
389            CoreError::WalBuild(_) | CoreError::Internal(_) => ErrorCode::ServerError,
390            CoreError::WalWrite { class, .. } | CoreError::Store { class, .. } => {
391                classify_store_failure(*class)
392            }
393            CoreError::HeadPublish(error) => classify_head_publish_error(error),
394            CoreError::InvalidPath(_)
395            | CoreError::RootMutationForbidden
396            | CoreError::InvalidNamespaceId(_)
397            | CoreError::InvalidCommitId(_)
398            | CoreError::InvalidCommitRequest(_)
399            | CoreError::InvalidUploadId(_)
400            | CoreError::InvalidCheckpointRequest(_)
401            | CoreError::InvalidGcConfig(_)
402            | CoreError::InvalidQuery(_)
403            | CoreError::InvalidUploadContent(_)
404            | CoreError::InvalidCursor(_)
405            | CoreError::BatchTooLarge { .. }
406            | CoreError::ResumeOffsetOutOfRange { .. }
407            | CoreError::ResumePrefixIncomplete { .. }
408            | CoreError::NonDirectoryPathComponent(_) => ErrorCode::InvalidRequest,
409            CoreError::PathNotFound(_) => ErrorCode::PathNotFound,
410            CoreError::RevisionNotFound { .. } => ErrorCode::RevisionNotFound,
411            CoreError::ContentTooLarge { .. } => ErrorCode::ContentTooLarge,
412            CoreError::NamespaceExists { .. } => ErrorCode::NamespaceExists,
413            CoreError::NamespaceDeleted { .. } => ErrorCode::NamespaceDeleted,
414            CoreError::StaleHeadPrecondition { .. } => ErrorCode::StaleHead,
415            CoreError::CommitIdReuseConflict { .. } => ErrorCode::CommitIdReuseConflict,
416            CoreError::ContentPreparation(_) => ErrorCode::ContentNotPrepared,
417            CoreError::CommitQueueFull => ErrorCode::CommitQueueFull,
418            CoreError::ShuttingDown => ErrorCode::ShuttingDown,
419            // An over-budget publication aborts pre-CAS and is retryable
420            // after maintenance, exactly the checkpoint_unavailable contract.
421            CoreError::CheckpointUnavailable(_)
422            | CoreError::MetadataPublicationBudgetExceeded { .. } => {
423                ErrorCode::CheckpointUnavailable
424            }
425            CoreError::QueryUnindexable(_) => ErrorCode::QueryUnindexable,
426            CoreError::IndexLagging { .. } => ErrorCode::IndexLagging,
427            CoreError::UploadNotFound { .. } => ErrorCode::UploadNotFound,
428            CoreError::UploadAlreadyCompleted { .. } => ErrorCode::UploadAlreadyCompleted,
429            CoreError::UploadContentConflict { .. } => ErrorCode::UploadContentConflict,
430            CoreError::RebootstrapRequired { .. } => ErrorCode::RebootstrapRequired,
431            CoreError::ExpectedFile { .. }
432            | CoreError::ExpectedDirectory { .. }
433            | CoreError::DestinationExists { .. } => ErrorCode::PathConflict,
434            CoreError::DirectoryNotEmpty(_) => ErrorCode::DirectoryNotEmpty,
435            CoreError::WriterFenced(_) => ErrorCode::WriterFenced,
436            CoreError::NamespaceCorrupt(_) => ErrorCode::NamespaceCorrupt,
437            // Naming which operation stopped a batch says nothing new about
438            // what went wrong, so the code stays the failure's own.
439            CoreError::FailedOperation { source, .. } => source.code(),
440        }
441    }
442
443    /// Attributes this failure to the operation at `operation_index` of a
444    /// multi-operation request.
445    pub(crate) fn at_operation(self, operation_index: usize) -> Self {
446        let Ok(operation_index) = u32::try_from(operation_index) else {
447            return self;
448        };
449        Self::FailedOperation {
450            operation_index,
451            source: Box::new(self),
452        }
453    }
454
455    pub fn message(&self) -> String {
456        self.to_string()
457    }
458
459    /// Structured wire details for this error, when the variant carries
460    /// machine-usable identity (API spec, "Standard error contract"). The
461    /// server serializes this beside [`CoreError::code`]; embedded callers
462    /// can match the typed variants directly instead.
463    pub fn details(&self) -> Option<ErrorDetails> {
464        match self {
465            CoreError::WriterFenced(fence) => Some(ErrorDetails {
466                fenced_epoch: Some(fence.fenced_epoch),
467                active_writer_epoch: Some(fence.active_epoch),
468                active_writer: fence.active_writer.clone(),
469                active_acquired_at_ms: fence.active_acquired_at_ms,
470                ..ErrorDetails::default()
471            }),
472            CoreError::CommitIdReuseConflict {
473                commit_id,
474                committed_seq,
475                committed_fingerprint,
476            } => Some(ErrorDetails {
477                commit_id: CommitId::parse(commit_id).ok(),
478                committed_seq: *committed_seq,
479                committed_fingerprint: committed_fingerprint.clone(),
480                ..ErrorDetails::default()
481            }),
482            CoreError::RebootstrapRequired {
483                after_seq,
484                retention_floor_seq,
485            } => Some(ErrorDetails {
486                after_seq: Some(*after_seq),
487                retention_floor_seq: Some(*retention_floor_seq),
488                ..ErrorDetails::default()
489            }),
490            CoreError::StaleHeadPrecondition { expected, actual } => Some(ErrorDetails {
491                expected_head_seq: Some(*expected),
492                actual_head_seq: Some(*actual),
493                ..ErrorDetails::default()
494            }),
495            CoreError::CommitValidation(error) => commit_validation_details(error),
496            CoreError::FailedOperation {
497                operation_index,
498                source,
499            } => Some(ErrorDetails {
500                operation_index: Some(*operation_index),
501                ..source.details().unwrap_or_default()
502            }),
503            _ => None,
504        }
505    }
506}
507
508/// The fencing event a writer session observed: the epoch the session held,
509/// the epoch that displaced it, and — when the head recorded a writer block —
510/// the winner's label and when it acquired.
511///
512/// The epochs are what identifies the two parties. Writer labels are process
513/// names, so two local processes can share one (the CLI defaults `writer_id`
514/// to the hostname); the acquisition stamp is what tells those runs apart in
515/// a diagnostic.
516#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct WriterFence {
518    /// Epoch the fenced session held.
519    pub fenced_epoch: WriterEpoch,
520    /// Epoch that owns the namespace now.
521    pub active_epoch: WriterEpoch,
522    /// Writer label recorded by the winning acquirer, when known.
523    pub active_writer: Option<String>,
524    /// When the winning acquirer took the epoch, in Unix milliseconds, when
525    /// known.
526    pub active_acquired_at_ms: Option<u64>,
527}
528
529fn destination_exists_message(path: &str, existing_display_name: Option<&str>) -> String {
530    let typed_leaf = path.rsplit('/').next().unwrap_or(path);
531    match existing_display_name {
532        Some(existing) if existing != typed_leaf => format!(
533            "destination already exists at `{path}` (stored as `{existing}`; sibling names \
534             collide after Unicode NFC normalization and case folding)"
535        ),
536        _ => format!("destination already exists at `{path}`"),
537    }
538}
539
540impl std::fmt::Display for WriterFence {
541    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
542        write!(
543            f,
544            "epoch {} was fenced by epoch {}",
545            self.fenced_epoch, self.active_epoch
546        )?;
547        // Both fields come from the head's writer block, so in practice they
548        // are present or absent together; the arms keep the message honest
549        // either way.
550        match (self.active_writer.as_deref(), self.active_acquired_at_ms) {
551            (Some(writer), Some(acquired_at_ms)) => {
552                write!(f, " (writer `{writer}`, acquired at {acquired_at_ms} ms)")
553            }
554            (Some(writer), None) => write!(f, " (writer `{writer}`)"),
555            (None, Some(acquired_at_ms)) => write!(f, " (acquired at {acquired_at_ms} ms)"),
556            (None, None) => Ok(()),
557        }
558    }
559}
560
561fn commit_validation_details(error: &CommitValidationError) -> Option<ErrorDetails> {
562    match error {
563        CommitValidationError::ReplaceFileBaseRevisionMismatch {
564            inode_id,
565            expected,
566            actual,
567        }
568        | CommitValidationError::RestoreRevisionBaseRevisionMismatch {
569            inode_id,
570            expected,
571            actual,
572        } => Some(ErrorDetails {
573            inode_id: Some(*inode_id),
574            expected_revision: Some(*expected),
575            actual_revision: *actual,
576            ..ErrorDetails::default()
577        }),
578        CommitValidationError::StaleWriterEpoch { active, requested } => Some(ErrorDetails {
579            fenced_epoch: Some(*requested),
580            active_writer_epoch: Some(*active),
581            ..ErrorDetails::default()
582        }),
583        CommitValidationError::UndeleteInodeMissing { inode_id }
584        | CommitValidationError::UndeleteTargetNotDeleted { inode_id } => Some(ErrorDetails {
585            inode_id: Some(*inode_id),
586            ..ErrorDetails::default()
587        }),
588        CommitValidationError::UndeleteTargetsCurrentCommit {
589            inode_id,
590            requested_seq,
591        } => Some(ErrorDetails {
592            inode_id: Some(*inode_id),
593            requested_deletion_seq: Some(*requested_seq),
594            ..ErrorDetails::default()
595        }),
596        CommitValidationError::UndeleteGenerationMismatch {
597            inode_id,
598            requested_seq,
599            active_seq,
600        } => Some(ErrorDetails {
601            inode_id: Some(*inode_id),
602            requested_deletion_seq: Some(*requested_seq),
603            active_deletion_seq: Some(*active_seq),
604            ..ErrorDetails::default()
605        }),
606        _ => None,
607    }
608}
609
610fn classify_metadata_view_error(error: &MetadataViewError) -> ErrorCode {
611    match error {
612        MetadataViewError::MissingManifest { .. } => ErrorCode::NamespaceCorrupt,
613        MetadataViewError::MaintenanceRequired { .. } => ErrorCode::MaintenanceRequired,
614        // A well-formed cursor from ahead of the loaded head is a state
615        // condition, not a malformed request: the client's recovery is to
616        // restart the listing, same as a sub-floor change cursor.
617        MetadataViewError::SnapshotUnavailable { .. } => ErrorCode::RebootstrapRequired,
618    }
619}
620
621fn classify_metadata_projection_load_error(error: &MetadataProjectionLoadError) -> ErrorCode {
622    match error {
623        MetadataProjectionLoadError::NamespaceDeleted { .. } => ErrorCode::NamespaceDeleted,
624        // The head is the namespace: an absent head is the one and only
625        // "this namespace does not exist" signal.
626        MetadataProjectionLoadError::LoadHead(error) => classify_control_object_load_error(error),
627        MetadataProjectionLoadError::WalChainLoad(error) => classify_wal_chain_load_error(error),
628        MetadataProjectionLoadError::WalReplay(_)
629        | MetadataProjectionLoadError::ReplayedHeadMismatch { .. } => ErrorCode::NamespaceCorrupt,
630        MetadataProjectionLoadError::ManifestLoad(error) => match error.failure_class() {
631            crate::checkpoint::ManifestLoadFailureClass::Corrupt => ErrorCode::NamespaceCorrupt,
632            crate::checkpoint::ManifestLoadFailureClass::Store => ErrorCode::ServerError,
633        },
634        MetadataProjectionLoadError::MissingHeadEtag { .. } => ErrorCode::ServerError,
635        MetadataProjectionLoadError::HeadChangedDuringLoad { .. } => ErrorCode::StaleHead,
636    }
637}
638
639fn classify_control_object_load_error(error: &ControlObjectLoadError) -> ErrorCode {
640    match error {
641        ControlObjectLoadError::MissingObject { .. } => ErrorCode::NamespaceNotFound,
642        ControlObjectLoadError::RootAheadOfHead { .. } => ErrorCode::StaleHead,
643        ControlObjectLoadError::NamespaceMismatch { .. }
644        | ControlObjectLoadError::ChecksumMismatch { .. }
645        | ControlObjectLoadError::Codec { .. } => ErrorCode::NamespaceCorrupt,
646        ControlObjectLoadError::Store { .. } => ErrorCode::ServerError,
647    }
648}
649
650fn classify_wal_chain_load_error(error: &WalChainLoadError) -> ErrorCode {
651    match error {
652        WalChainLoadError::ReadWal { .. } => ErrorCode::ServerError,
653        WalChainLoadError::InvalidSeqRange { .. }
654        | WalChainLoadError::MissingVisibleTip { .. }
655        | WalChainLoadError::TipEndSeqMismatch { .. }
656        | WalChainLoadError::MissingWalObject { .. }
657        | WalChainLoadError::PointerMismatch { .. }
658        | WalChainLoadError::HeadSeqMismatch { .. }
659        | WalChainLoadError::CursorNotCovered { .. }
660        | WalChainLoadError::Replay(_) => ErrorCode::NamespaceCorrupt,
661    }
662}
663
664fn classify_visible_path_error(error: &VisiblePathError) -> ErrorCode {
665    match error {
666        VisiblePathError::RootMissing => ErrorCode::NamespaceCorrupt,
667        VisiblePathError::PathNotFound { .. } => ErrorCode::PathNotFound,
668        VisiblePathError::PathComponentNotDirectory { .. } => ErrorCode::PathConflict,
669    }
670}
671
672fn classify_durable_content_error(error: &DurableContentValidationError) -> ErrorCode {
673    match error {
674        DurableContentValidationError::InvalidContentRef(_)
675        | DurableContentValidationError::MissingContentObject { .. }
676        | DurableContentValidationError::ContentLengthMismatch { .. }
677        | DurableContentValidationError::ContentChecksumMismatch { .. }
678        | DurableContentValidationError::ContentChecksumUnverifiable { .. }
679        | DurableContentValidationError::ContentStoreMismatch { .. } => ErrorCode::NamespaceCorrupt,
680        DurableContentValidationError::Store { .. } => ErrorCode::ServerError,
681    }
682}
683
684impl From<crate::control_update::ControlUpdateError> for CoreError {
685    fn from(value: crate::control_update::ControlUpdateError) -> Self {
686        use crate::control_update::ControlUpdateError;
687        match value {
688            ControlUpdateError::LoadHead(error) => {
689                CoreError::MetadataProjection(MetadataProjectionLoadError::LoadHead(error))
690            }
691            // The remaining variants (missing etag, codec, control store error,
692            // retries exhausted) are control-plane plumbing failures with no
693            // single object key in scope at this blanket conversion. They share
694            // the ServerError wire code with `Store`; keep the detail as a
695            // prefixed message.
696            other => CoreError::Internal(other.to_string()),
697        }
698    }
699}
700
701fn classify_writer_epoch_acquire_error(error: &WriterEpochAcquireError) -> ErrorCode {
702    match error {
703        WriterEpochAcquireError::LoadHead(error) => classify_control_object_load_error(error),
704        WriterEpochAcquireError::NamespaceDeleted { .. } => ErrorCode::NamespaceDeleted,
705        WriterEpochAcquireError::EmptyWriterId
706        | WriterEpochAcquireError::MissingHeadEtag { .. }
707        | WriterEpochAcquireError::WriterEpochOverflow { .. }
708        | WriterEpochAcquireError::HeadWrite(_)
709        | WriterEpochAcquireError::RetryExhausted { .. } => ErrorCode::ServerError,
710    }
711}
712
713fn classify_commit_validation_error(error: &CommitValidationError) -> ErrorCode {
714    match error {
715        CommitValidationError::ReplaceFileBaseRevisionMismatch { .. }
716        | CommitValidationError::RestoreRevisionBaseRevisionMismatch { .. } => {
717            ErrorCode::StaleRevision
718        }
719        CommitValidationError::RestoreRevisionSourceRevisionMissing { .. } => {
720            ErrorCode::RevisionNotFound
721        }
722        // Corruption guards, not caller-actionable conflicts. Every inode an
723        // operation names is either freshly allocated by the same commit or
724        // resolved through visible bindings, and a visible binding already
725        // implies no covering tombstone (`metadata::visibility`: a visible
726        // inode is one no tombstone covers, and a delete unbinds and
727        // tombstones in the same commit). So a covered target here means the
728        // stored rows contradict themselves — a live binding under a
729        // tombstone — which is repair work, not something a caller can fix by
730        // re-reading and retrying.
731        CommitValidationError::CreateUnderSubtreeTombstone { .. }
732        | CommitValidationError::ReplaceFileUnderSubtreeTombstone { .. }
733        | CommitValidationError::RestoreRevisionUnderSubtreeTombstone { .. }
734        | CommitValidationError::DeleteFileCoveredByTombstone { .. }
735        | CommitValidationError::RenameInodeUnderSubtreeTombstone { .. }
736        | CommitValidationError::RenameTargetParentUnderSubtreeTombstone { .. }
737        | CommitValidationError::DeleteSubtreeRootCoveredByTombstone { .. } => {
738            ErrorCode::NamespaceCorrupt
739        }
740        CommitValidationError::CreateChildNameCollision { .. }
741        | CommitValidationError::NamePreconditionParentNotDirectory { .. }
742        | CommitValidationError::BindingPreconditionMissing { .. }
743        | CommitValidationError::BindingPreconditionMismatch { .. }
744        | CommitValidationError::CreateParentNotDirectory { .. }
745        | CommitValidationError::ReplaceFileInodeNotFile { .. }
746        | CommitValidationError::RestoreRevisionInodeNotFile { .. }
747        | CommitValidationError::DeleteFileInodeNotFile { .. }
748        | CommitValidationError::RenameTargetParentNotDirectory { .. }
749        | CommitValidationError::RenameTargetNameCollision { .. }
750        | CommitValidationError::DeleteSubtreeRootNotDirectory { .. }
751        | CommitValidationError::DirectoryEmptyPreconditionInodeNotDirectory { .. } => {
752            ErrorCode::PathConflict
753        }
754        CommitValidationError::DirectoryEmptyPreconditionNotEmpty { .. } => {
755            ErrorCode::DirectoryNotEmpty
756        }
757        CommitValidationError::CreateParentMissing { .. }
758        | CommitValidationError::NamePreconditionParentMissing { .. }
759        | CommitValidationError::ReplaceFileInodeMissing { .. }
760        | CommitValidationError::RestoreRevisionInodeMissing { .. }
761        | CommitValidationError::DeleteFileInodeMissing { .. }
762        | CommitValidationError::RenameInodeMissing { .. }
763        | CommitValidationError::RenameSourceBindingMissing { .. }
764        | CommitValidationError::SourceBindingMissing { .. }
765        | CommitValidationError::RenameTargetParentMissing { .. }
766        | CommitValidationError::DeleteSubtreeRootMissing { .. }
767        | CommitValidationError::DirectoryEmptyPreconditionInodeMissing { .. } => {
768            ErrorCode::PathNotFound
769        }
770        CommitValidationError::UndeleteInodeMissing { .. } => ErrorCode::PathNotFound,
771        // Both are "the deletion you named is not the live one": absent
772        // entirely, or superseded by a newer generation. One code, with the
773        // generations in the structured details.
774        CommitValidationError::UndeleteTargetNotDeleted { .. }
775        | CommitValidationError::UndeleteTargetsCurrentCommit { .. }
776        | CommitValidationError::UndeleteGenerationMismatch { .. } => ErrorCode::NotDeleted,
777        CommitValidationError::RenameWouldCycleDirectory { .. } => ErrorCode::WouldCycle,
778        CommitValidationError::InvalidDisplayName { .. } => ErrorCode::InvalidRequest,
779        CommitValidationError::StaleWriterEpoch { .. } => ErrorCode::WriterFenced,
780        CommitValidationError::EmptyCommit
781        | CommitValidationError::NamespaceMismatch
782        | CommitValidationError::ValidatedPreviewApplyFailed(_)
783        | CommitValidationError::RestoreRevisionOverflow { .. }
784        | CommitValidationError::ReplaceFileRevisionOverflow { .. }
785        | CommitValidationError::SeqOverflow
786        | CommitValidationError::NextInodeOverflow
787        | CommitValidationError::OpIndexOverflow
788        | CommitValidationError::DeltaIndexOverflow => ErrorCode::ServerError,
789    }
790}
791
792fn classify_head_publish_error(error: &CommitHeadPublishError) -> ErrorCode {
793    match error {
794        CommitHeadPublishError::StaleHead
795        | CommitHeadPublishError::PublishBudgetExceeded { .. } => ErrorCode::StaleHead,
796        CommitHeadPublishError::OutcomeUnknown(_) => ErrorCode::CommitOutcomeUnknown,
797        CommitHeadPublishError::EmptyExpectedHeadEtag
798        | CommitHeadPublishError::NamespaceMismatch { .. }
799        | CommitHeadPublishError::WalSegmentNamespaceMismatch { .. }
800        | CommitHeadPublishError::WalSegmentWriterEpochMismatch { .. }
801        | CommitHeadPublishError::WalSegmentBaseHeadSeqMismatch { .. }
802        | CommitHeadPublishError::WalSegmentStartSeqMismatch { .. }
803        | CommitHeadPublishError::WalSegmentEndSeqMismatch { .. }
804        | CommitHeadPublishError::EmptyWalSegment
805        | CommitHeadPublishError::HeadIdentityDrift(_)
806        | CommitHeadPublishError::SeqOverflow
807        | CommitHeadPublishError::Codec { .. }
808        | CommitHeadPublishError::Store { .. } => ErrorCode::ServerError,
809    }
810}
811
812#[cfg(test)]
813mod tests {
814    use super::{
815        CommitValidationError, CoreError, ErrorCode, ErrorKind, MetadataViewError, WriterFence,
816    };
817    use crate::commit_engine::ContentPreparationError;
818    use crate::storage::content_admission::ContentTokenError;
819    use loonfs_api::{
820        ChangeSeq, CommitId, InodeId, ManifestId, NamespaceId, RevisionNo, WriterEpoch,
821    };
822    use loonfs_objectstore::ObjectStoreError;
823
824    #[test]
825    fn public_error_kind_groups_detailed_codes() {
826        assert_eq!(ErrorCode::InvalidRequest.kind(), ErrorKind::InvalidRequest);
827        assert_eq!(ErrorCode::PathNotFound.kind(), ErrorKind::NotFound);
828        assert_eq!(ErrorCode::NamespaceDeleted.kind(), ErrorKind::Gone);
829        assert_eq!(ErrorCode::NamespaceExists.kind(), ErrorKind::AlreadyExists);
830        // Precondition failures are 409 resource-state conflicts in v0
831        // (api.md, "Standard error contract"), so the kind is Conflict.
832        assert_eq!(ErrorCode::StaleRevision.kind(), ErrorKind::Conflict);
833        assert_eq!(ErrorCode::ContentNotPrepared.kind(), ErrorKind::Conflict);
834        assert_eq!(ErrorCode::CommitQueueFull.kind(), ErrorKind::Unavailable);
835        assert_eq!(
836            ErrorCode::MaintenanceRequired.kind(),
837            ErrorKind::Unavailable
838        );
839        assert_eq!(
840            ErrorCode::CommitOutcomeUnknown.kind(),
841            ErrorKind::OutcomeUnknown
842        );
843        assert_eq!(
844            ErrorCode::NamespaceCorrupt.kind(),
845            ErrorKind::DataCorruption
846        );
847        assert_eq!(ErrorCode::ServerError.kind(), ErrorKind::Internal);
848    }
849
850    #[test]
851    fn core_error_exposes_public_kind_and_detailed_code() {
852        let error = CoreError::NamespaceExists {
853            namespace_id: NamespaceId::parse("demo").expect("valid namespace id"),
854        };
855        assert_eq!(error.kind(), ErrorKind::AlreadyExists);
856        assert_eq!(error.code(), ErrorCode::NamespaceExists);
857        assert_eq!(error.code().as_str(), "namespace_exists");
858        assert!(error.message().contains("already exists"));
859
860        let content_id = loonfs_api::ContentId::generate();
861        let error = CoreError::ContentPreparation(ContentPreparationError::ContentNotPrepared {
862            content_id: content_id.clone(),
863        });
864        assert_eq!(error.kind(), ErrorKind::Conflict);
865        assert_eq!(error.code(), ErrorCode::ContentNotPrepared);
866        assert!(error.message().contains(content_id.as_str()));
867    }
868
869    #[test]
870    fn rejected_content_token_maps_to_content_not_prepared() {
871        let error = CoreError::from(ContentPreparationError::ContentToken(
872            ContentTokenError::Expired,
873        ));
874
875        assert_eq!(error.code(), ErrorCode::ContentNotPrepared);
876    }
877
878    #[test]
879    fn metadata_view_errors_map_to_actionable_public_codes() {
880        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
881        let _manifest_id = ManifestId(1);
882        let head_seq = ChangeSeq(3);
883
884        let cases = [
885            (
886                MetadataViewError::MissingManifest {
887                    namespace_id: namespace_id.clone(),
888                },
889                ErrorCode::NamespaceCorrupt,
890            ),
891            (
892                MetadataViewError::MaintenanceRequired {
893                    namespace_id: namespace_id.clone(),
894                    reason: "retention progress is missing".to_owned(),
895                },
896                ErrorCode::MaintenanceRequired,
897            ),
898            (
899                MetadataViewError::SnapshotUnavailable {
900                    requested_seq: ChangeSeq(1),
901                    head_seq,
902                },
903                ErrorCode::RebootstrapRequired,
904            ),
905        ];
906
907        for (metadata_error, code) in cases {
908            let error = CoreError::from(metadata_error);
909            assert_eq!(error.code(), code);
910        }
911    }
912
913    #[test]
914    fn identity_bearing_errors_expose_structured_wire_details() {
915        let fenced = CoreError::WriterFenced(WriterFence {
916            fenced_epoch: WriterEpoch(3),
917            active_epoch: WriterEpoch(4),
918            active_writer: Some("writer-b".to_owned()),
919            active_acquired_at_ms: Some(2_000),
920        });
921        let details = fenced.details().expect("fence details");
922        assert_eq!(details.fenced_epoch, Some(WriterEpoch(3)));
923        assert_eq!(details.active_writer_epoch, Some(WriterEpoch(4)));
924        assert_eq!(details.active_writer.as_deref(), Some("writer-b"));
925        assert_eq!(details.active_acquired_at_ms, Some(2_000));
926        assert!(fenced
927            .to_string()
928            .contains("epoch 3 was fenced by epoch 4 (writer `writer-b`, acquired at 2000 ms)"));
929
930        // A head with no writer block still names both epochs.
931        let anonymous = CoreError::WriterFenced(WriterFence {
932            fenced_epoch: WriterEpoch(3),
933            active_epoch: WriterEpoch(4),
934            active_writer: None,
935            active_acquired_at_ms: None,
936        });
937        assert!(anonymous
938            .to_string()
939            .ends_with("epoch 3 was fenced by epoch 4"));
940
941        // A conflict decided against a durable receipt names where the
942        // commit id landed and what landed there, which is what a retry
943        // reads back and proves itself against.
944        let reuse = CoreError::CommitIdReuseConflict {
945            commit_id: "retry-key-1".to_owned(),
946            committed_seq: Some(ChangeSeq(9)),
947            committed_fingerprint: Some("v0:sha256:abc".to_owned()),
948        };
949        let details = reuse.details().expect("reuse details");
950        assert_eq!(
951            details.commit_id,
952            Some(CommitId::parse("retry-key-1").expect("valid commit id"))
953        );
954        assert_eq!(details.committed_seq, Some(ChangeSeq(9)));
955        assert_eq!(
956            details.committed_fingerprint.as_deref(),
957            Some("v0:sha256:abc")
958        );
959
960        // A conflict between two live claims has no landed commit to name,
961        // so it carries neither half of the receipt.
962        let contended = CoreError::CommitIdReuseConflict {
963            commit_id: "retry-key-1".to_owned(),
964            committed_seq: None,
965            committed_fingerprint: None,
966        };
967        let details = contended.details().expect("reuse details");
968        assert_eq!(details.committed_seq, None);
969        assert_eq!(details.committed_fingerprint, None);
970
971        let stale =
972            CoreError::CommitValidation(CommitValidationError::ReplaceFileBaseRevisionMismatch {
973                inode_id: InodeId(7),
974                expected: RevisionNo(2),
975                actual: Some(RevisionNo(5)),
976            });
977        let details = stale.details().expect("stale-revision details");
978        assert_eq!(details.inode_id, Some(InodeId(7)));
979        assert_eq!(details.expected_revision, Some(RevisionNo(2)));
980        assert_eq!(details.actual_revision, Some(RevisionNo(5)));
981        // The prose says the same thing in words, so neither revision
982        // reaches a reader as Rust formatting.
983        assert!(
984            stale
985                .to_string()
986                .ends_with("expected revision 2, found revision 5"),
987            "{stale}"
988        );
989
990        // A file with no revision at all reads as a sentence rather than
991        // printing the absent value.
992        let unversioned =
993            CoreError::CommitValidation(CommitValidationError::ReplaceFileBaseRevisionMismatch {
994                inode_id: InodeId(7),
995                expected: RevisionNo(2),
996                actual: None,
997            });
998        assert!(
999            unversioned
1000                .to_string()
1001                .ends_with("expected revision 2, found no revision"),
1002            "{unversioned}"
1003        );
1004        assert_eq!(
1005            unversioned
1006                .details()
1007                .expect("stale-revision details")
1008                .actual_revision,
1009            None
1010        );
1011
1012        // A refused `expected_head_seq` carries both sequences, so a caller
1013        // that still means to delete knows what to retry against.
1014        let precondition = CoreError::StaleHeadPrecondition {
1015            expected: ChangeSeq(41),
1016            actual: ChangeSeq(45),
1017        };
1018        assert_eq!(precondition.code(), ErrorCode::StaleHead);
1019        assert_eq!(
1020            precondition.to_string(),
1021            "expected head sequence 41, found 45"
1022        );
1023        let details = precondition.details().expect("head-sequence details");
1024        assert_eq!(details.expected_head_seq, Some(ChangeSeq(41)));
1025        assert_eq!(details.actual_head_seq, Some(ChangeSeq(45)));
1026
1027        // Errors without machine-usable identity stay detail-free.
1028        assert!(CoreError::Internal("boom".to_owned()).details().is_none());
1029    }
1030
1031    /// Provider auth failures keep their class across the message-flattening
1032    /// seams and reach the wire as `permission_denied`; every other store
1033    /// failure stays `server_error`.
1034    #[test]
1035    fn store_permission_denied_classifies_to_its_wire_code() {
1036        let denied = ObjectStoreError::PermissionDenied {
1037            object_key: "namespaces/demo/wal/head.json".to_owned(),
1038            message: "AccessDenied: bucket policy".to_owned(),
1039        };
1040        let error = CoreError::store("namespaces/demo/wal/head.json", &denied);
1041        assert_eq!(error.code(), ErrorCode::PermissionDenied);
1042        assert_eq!(error.kind(), ErrorKind::PermissionDenied);
1043
1044        let transport = ObjectStoreError::transport("namespaces/demo/wal/head.json", "timed out");
1045        let error = CoreError::store("namespaces/demo/wal/head.json", &transport);
1046        assert_eq!(error.code(), ErrorCode::ServerError);
1047    }
1048}