1use 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
25pub use self::CoreError as Error;
30
31pub(crate) type Result<T> = std::result::Result<T, Error>;
35
36pub use loonfs_api::{ErrorCode, ErrorKind};
37
38#[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 #[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 #[error("asked for {requested} items, over the {max} one batch answers")]
94 BatchTooLarge { requested: usize, max: usize },
95 #[error("cannot start a read at offset {start_offset} of {size_bytes}-byte content")]
98 ResumeOffsetOutOfRange { start_offset: u64, size_bytes: u64 },
99 #[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 existing_display_name: Option<String>,
124 },
125 #[error("commit id conflict for `{commit_id}`")]
126 CommitIdReuseConflict {
127 commit_id: String,
128 committed_seq: Option<ChangeSeq>,
134 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 #[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 #[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 #[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 #[error("expected head sequence {expected}, found {actual}")]
217 StaleHeadPrecondition {
218 expected: ChangeSeq,
219 actual: ChangeSeq,
220 },
221 #[error("operation {operation_index}: {source}")]
231 FailedOperation {
232 operation_index: u32,
233 source: Box<CoreError>,
234 },
235}
236
237#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
336pub enum StoreFailureClass {
337 PermissionDenied,
340 Other,
342}
343
344impl StoreFailureClass {
345 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 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 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 CoreError::FailedOperation { source, .. } => source.code(),
440 }
441 }
442
443 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 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#[derive(Debug, Clone, PartialEq, Eq)]
517pub struct WriterFence {
518 pub fenced_epoch: WriterEpoch,
520 pub active_epoch: WriterEpoch,
522 pub active_writer: Option<String>,
524 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 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 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 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 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 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 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 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 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 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 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 assert!(
984 stale
985 .to_string()
986 .ends_with("expected revision 2, found revision 5"),
987 "{stale}"
988 );
989
990 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 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 assert!(CoreError::Internal("boom".to_owned()).details().is_none());
1029 }
1030
1031 #[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}