Skip to main content

loonfs_core/
engine.rs

1//! [`NamespaceEngine`]: the namespace-scoped entry point for reads, writes,
2//! uploads, checkpoints, and maintenance.
3
4use crate::cache::{MetadataTableCache, WalTailProjectionCache};
5use crate::checkpoint::{CheckpointFilesPage, CheckpointFilesPageCursor};
6use crate::commit_engine::CommitCandidate;
7use crate::context::MutationContext;
8use crate::error::{CoreError, Result};
9use crate::namespace::basis::MetadataBasis;
10use crate::namespace::catalog::VerifiedNamespaceCatalogEntry;
11use crate::namespace::{bootstrap, fork, BootstrapNamespaceError};
12use crate::options::{BootstrapOptions, DeleteNamespaceOptions};
13use crate::path::read::{
14    load_metadata_view, CurrentFileState, DirectDownloadTarget, LoadedMetadataView, ReadLoadContext,
15};
16use crate::protocol::CompletedUpload;
17use crate::storage::content::FileContentStream;
18use crate::storage::content_admission::{CompletedUploadReceipt, PreparedContent};
19use crate::time::current_time_ms;
20use loonfs_api::v0::{
21    AbortUploadResponse, BeginUploadRequest, BeginUploadResponse, ChangesResponse, CommitResponse,
22    CompleteUploadRequest, CompleteUploadResponse, DirectMultipartUploadOptions,
23    DirectPutContentClaim, UploadContentResponse, UploadPartChecksumClaim, UploadStatusResponse,
24};
25use loonfs_api::wire::control::{CheckpointOwner, HeadState, NamespaceState};
26use loonfs_api::EffectiveLimit;
27use loonfs_api::{
28    AdvanceRetentionResponse, AuthoritativeFileBytes, AuthoritativePathEntry, ChangeSeq,
29    CheckpointId, ContentRef, CreateCheckpointResponse, DeleteNamespaceResponse,
30    DirectoryPageCursor, FileRevision, FileRevisionsPageCursor, FlushWalResponse, InodeId,
31    ListCheckpointsResponse, NamespaceId, NamespaceSummary, Page, PageRequest,
32    ReleaseCheckpointResponse, RevisionNo, StorageChecksum, TrashEntry, TrashPageCursor, UploadId,
33};
34use loonfs_objectstore::{ByteStream, ObjectStore};
35use std::num::NonZeroU64;
36use std::sync::Arc;
37use thiserror::Error;
38
39/// The pinned inputs the runtime resolves once per read: the head anchored
40/// to its manifest, the shared caches, and (when the runtime has it) the
41/// namespace's immutable catalog pair.
42///
43/// This is the runtime seam: the `loonfs` crate pins one context per
44/// request and fans every read of that request through it, so the whole
45/// request observes a single snapshot and shares the caches. It is a
46/// sanctioned public hook (STYLE, "Harness hooks are sanctioned"), not an
47/// application API — embedded applications use the `loonfs` handles, which
48/// drive this seam internally.
49#[derive(Debug, Clone)]
50pub struct RuntimeReadContext {
51    pub head: HeadState,
52    pub head_etag: String,
53    /// The materialized basis the head authorized when the anchor was
54    /// taken. It carries the namespace's own root when it has one, and the
55    /// genesis or fork basis until then.
56    pub basis: MetadataBasis,
57    pub table_cache: Arc<MetadataTableCache>,
58    pub tail_cache: Arc<WalTailProjectionCache>,
59}
60
61fn runtime_read_load_context(context: &RuntimeReadContext) -> ReadLoadContext<'_> {
62    ReadLoadContext::pinned_head(
63        &context.head,
64        Some(context.head_etag.as_str()),
65        &context.basis,
66        Some(&context.table_cache),
67        Some(&context.tail_cache),
68    )
69}
70
71/// Internal target used by server integrations before they mint a presigned URL.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub struct DirectPutUploadTarget {
74    pub content_ref: ContentRef,
75    pub object_key: String,
76}
77
78/// Internal response for preparing a direct_put session before URL signing.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct BeginDirectPutUploadTargetResponse {
81    pub namespace_id: NamespaceId,
82    pub upload_id: UploadId,
83    pub target: DirectPutUploadTarget,
84}
85
86/// Internal target used by server integrations before they mint part URLs.
87///
88/// There is no content ref: a multipart session is opened before anything
89/// is known about the payload, so identity exists but the reference that
90/// describes it does not yet.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct DirectMultipartUploadTarget {
93    pub object_key: String,
94    pub part_size_bytes: u64,
95}
96
97/// Internal response for preparing a direct_multipart session.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct BeginDirectMultipartUploadTargetResponse {
100    pub namespace_id: NamespaceId,
101    pub upload_id: UploadId,
102    pub target: DirectMultipartUploadTarget,
103}
104
105/// One part a server integration is about to sign.
106#[derive(Debug, Clone, PartialEq, Eq)]
107pub struct MultipartPartTarget {
108    pub part_number: u32,
109    pub checksum: StorageChecksum,
110}
111
112/// Everything a server integration needs to sign one wave of part uploads.
113#[derive(Debug, Clone, PartialEq, Eq)]
114pub struct MultipartPartTargets {
115    pub object_key: String,
116    pub provider_upload_id: String,
117    pub parts: Vec<MultipartPartTarget>,
118}
119
120/// The actor identity a mutating engine publishes under.
121#[derive(Debug)]
122struct EngineWriter {
123    writer_id: String,
124}
125
126/// A namespace-scoped core API.
127///
128/// `NamespaceEngine` owns an object store handle plus, for a mutating engine,
129/// the writer identity used for mutations. It is the main entrypoint for
130/// direct reads, path writes, explicit commits, uploads, checkpoints, and
131/// retention work.
132///
133/// An engine built by [`NamespaceEngineBuilder::build_reader`] carries no
134/// writer identity: it serves reads, and every mutation refuses.
135#[derive(Debug)]
136pub struct NamespaceEngine<S> {
137    store: S,
138    namespace_id: NamespaceId,
139    writer: Option<EngineWriter>,
140}
141
142impl<S: ObjectStore> NamespaceEngine<S> {
143    /// Starts an engine builder for the supplied object store.
144    ///
145    /// The builder requires a namespace id, and a writer id unless it is
146    /// finished with [`NamespaceEngineBuilder::build_reader`].
147    pub fn builder(store: S) -> NamespaceEngineBuilder<S> {
148        NamespaceEngineBuilder {
149            store,
150            namespace_id: None,
151            writer_id: None,
152        }
153    }
154
155    /// Returns the namespace this engine is bound to.
156    pub fn namespace_id(&self) -> &NamespaceId {
157        &self.namespace_id
158    }
159
160    /// Returns the writer id used for epoch acquisition and commit
161    /// publication, or `None` for a read-only engine.
162    pub fn writer_id(&self) -> Option<&str> {
163        self.writer.as_ref().map(|writer| writer.writer_id.as_str())
164    }
165
166    /// Creates the namespace if it does not already exist.
167    ///
168    /// Use this before normal reads and writes for a new namespace.
169    pub async fn bootstrap_namespace(
170        &self,
171        options: BootstrapOptions,
172    ) -> std::result::Result<NamespaceSummary, BootstrapNamespaceError> {
173        bootstrap::bootstrap_namespace(
174            &self.store,
175            &self.namespace_id,
176            &self.mutation_context()?,
177            options.allow_existing,
178        )
179        .await
180    }
181
182    /// Creates a new namespace at the current head of this namespace.
183    ///
184    /// The fork shares immutable file bytes but gets its own metadata history.
185    pub async fn fork_namespace(&self, target: &NamespaceId) -> Result<NamespaceSummary> {
186        fork::fork_namespace(
187            &self.store,
188            &self.namespace_id,
189            target,
190            &self.mutation_context()?,
191        )
192        .await
193    }
194
195    /// Deletes this namespace: a fenced, terminal head-state transition.
196    /// Commits acknowledged before the swap stay committed; everything that
197    /// observes the deleted head afterward fails with `namespace_deleted`.
198    pub async fn delete_namespace(
199        &self,
200        options: DeleteNamespaceOptions,
201    ) -> Result<DeleteNamespaceResponse> {
202        crate::commit_engine::delete_namespace(
203            &self.store,
204            &self.namespace_id,
205            options,
206            &self.mutation_context()?,
207        )
208        .await
209    }
210
211    /// Stats one path against the pinned runtime read context.
212    pub async fn resolve_path(
213        &self,
214        path: impl AsRef<str>,
215        context: &RuntimeReadContext,
216    ) -> Result<AuthoritativePathEntry> {
217        let view = self.load_read_view(context).await?;
218        view.resolve_path(path.as_ref()).await
219    }
220
221    /// Lists one directory page against the pinned runtime read context.
222    pub async fn list_path_page(
223        &self,
224        path: impl AsRef<str>,
225        request: PageRequest<DirectoryPageCursor>,
226        context: &RuntimeReadContext,
227    ) -> Result<Page<AuthoritativePathEntry, DirectoryPageCursor>> {
228        let view = self.load_read_view(context).await?;
229        view.list_path_page(path.as_ref(), request).await
230    }
231
232    /// Reads file content against the pinned runtime read context.
233    pub async fn read_file(
234        &self,
235        path: impl AsRef<str>,
236        context: &RuntimeReadContext,
237        max_content_bytes: Option<u64>,
238    ) -> Result<AuthoritativeFileBytes> {
239        let view = self.load_read_view(context).await?;
240        view.read_file_bytes(&self.store, path.as_ref(), max_content_bytes)
241            .await
242    }
243
244    /// Opens a bounded streaming read of a file's current content against the
245    /// pinned runtime read context.
246    ///
247    /// The path resolves exactly as it does for [`Self::read_file`]; what
248    /// differs is everything after. The content arrives as `chunk_bytes`
249    /// ranged reads with the verifying digest folded over them, so what the
250    /// read costs in memory is one chunk rather than the file's size, and the
251    /// deployment's buffered-read cap deliberately does not apply — that cap
252    /// bounds what a caller materializes, and this caller materializes a
253    /// chunk.
254    ///
255    /// The pinned context resolves the path; it does not have to survive the
256    /// read. The reference names one immutable object at a random id, so no
257    /// commit landing mid-read can change the bytes under a reader.
258    ///
259    /// `start_offset` skips bytes the caller already holds; the stream still
260    /// verifies the whole object, so those bytes reach it through
261    /// [`FileContentStream::fold_resumed_prefix`] before it fetches
262    /// anything.
263    pub async fn read_file_stream(
264        &self,
265        path: impl AsRef<str>,
266        context: &RuntimeReadContext,
267        chunk_bytes: NonZeroU64,
268        start_offset: u64,
269    ) -> Result<FileContentStream<S>>
270    where
271        S: Clone,
272    {
273        let view = self.load_read_view(context).await?;
274        let (entry, content_ref) = view.resolve_file_content(path.as_ref()).await?;
275        if start_offset > content_ref.size_bytes {
276            return Err(CoreError::ResumeOffsetOutOfRange {
277                start_offset,
278                size_bytes: content_ref.size_bytes,
279            });
280        }
281        Ok(FileContentStream::open(
282            self.store.clone(),
283            view.content_store_id(),
284            entry,
285            content_ref,
286            chunk_bytes,
287            start_offset,
288        )
289        .await?)
290    }
291
292    /// Resolves a path to the content object a direct read would fetch,
293    /// against the pinned runtime read context.
294    ///
295    /// This reads metadata only. It is the read-side counterpart of
296    /// [`Self::begin_direct_put_upload_target`]: both hand a host the one
297    /// object key it needs in order to sign a transfer, and neither moves
298    /// a byte.
299    pub async fn direct_download_target(
300        &self,
301        path: impl AsRef<str>,
302        revision_no: Option<RevisionNo>,
303        context: &RuntimeReadContext,
304    ) -> Result<DirectDownloadTarget> {
305        let view = self.load_read_view(context).await?;
306        view.direct_download_target(path.as_ref(), revision_no)
307            .await
308    }
309
310    /// Lists one revision page for a path against the pinned runtime read context.
311    pub async fn list_file_revisions_page(
312        &self,
313        path: impl AsRef<str>,
314        request: PageRequest<FileRevisionsPageCursor>,
315        context: &RuntimeReadContext,
316    ) -> Result<Page<FileRevision, FileRevisionsPageCursor>> {
317        let view = self.load_read_view(context).await?;
318        view.list_file_revisions_page(path.as_ref(), request).await
319    }
320
321    /// Lists one trash page against the pinned runtime read context.
322    pub async fn list_trash_page(
323        &self,
324        request: PageRequest<TrashPageCursor>,
325        context: &RuntimeReadContext,
326    ) -> Result<Page<TrashEntry, TrashPageCursor>> {
327        let view = self.load_read_view(context).await?;
328        view.list_trash_page(request).await
329    }
330
331    /// Reads one page of the files visible in the state `checkpoint_id`
332    /// pins, in ascending inode-id order.
333    ///
334    /// The checkpoint's pinned manifest is the only state enumerated: the
335    /// context's own basis and WAL tail are deliberately not read, so a
336    /// commit landing while a consumer pages through changes nothing it
337    /// sees. Everything after the pinned sequence is the change feed's job.
338    /// The context supplies the namespace's immutable identity and proves
339    /// the namespace is still live.
340    pub async fn list_checkpoint_files_page(
341        &self,
342        checkpoint_id: &CheckpointId,
343        request: PageRequest<CheckpointFilesPageCursor>,
344        context: &RuntimeReadContext,
345    ) -> Result<CheckpointFilesPage> {
346        // Rejects a mismatched or deleted namespace before any read work.
347        self.live_catalog(context)?;
348        crate::checkpoint::list_checkpoint_files_page(
349            &self.store,
350            Some(context.table_cache.as_ref()),
351            &self.namespace_id,
352            checkpoint_id,
353            request,
354        )
355        .await
356    }
357
358    /// Answers, for each inode id, what it looks like in the namespace's
359    /// current state: whether it is visible, its current revision, and its
360    /// current path.
361    ///
362    /// One pinned read serves the whole batch, so every answer describes the
363    /// same state, and answers come back in input order. Ids that name
364    /// nothing are answered as not visible rather than refused — a consumer
365    /// holding ids from an earlier enumeration routinely holds stale ones.
366    /// At most [`MAX_RESOLVE_CURRENT_FILES`](crate::MAX_RESOLVE_CURRENT_FILES)
367    /// ids per call; a larger batch is refused before anything is read.
368    pub async fn resolve_current_files(
369        &self,
370        inode_ids: &[InodeId],
371        context: &RuntimeReadContext,
372    ) -> Result<Vec<CurrentFileState>> {
373        crate::path::read::ensure_resolve_batch_within_cap(inode_ids.len())?;
374        let view = self.load_read_view(context).await?;
375        crate::path::read::resolve_current_files(&view, inode_ids).await
376    }
377
378    /// Reads one immutable content object by reference.
379    ///
380    /// `max_bytes` is the caller's own budget for this read, checked against
381    /// the reference's declared size before any fetch; it is independent of
382    /// any deployment-wide download limit, so a consumer sizes its own
383    /// buffers. After the fetch the bytes are verified against the
384    /// reference's size and digest, and a mismatch fails the read — there is
385    /// no partial answer and no second attempt against another key.
386    pub async fn read_content_ref(
387        &self,
388        content_ref: &ContentRef,
389        max_bytes: u64,
390        context: &RuntimeReadContext,
391    ) -> Result<Vec<u8>> {
392        let catalog = self.live_catalog(context)?;
393        crate::path::read::ensure_within_read_limit(content_ref.size_bytes, Some(max_bytes))?;
394        let read = crate::storage::content::read_durable_content_bytes(
395            &self.store,
396            catalog.content_store_id(),
397            content_ref,
398        )
399        .await?;
400        Ok(read.bytes)
401    }
402
403    /// The namespace's immutable identity, read off the pinned head after
404    /// refusing a head that is not this namespace's or that is a tombstone.
405    ///
406    /// Reads that load a metadata view get both checks from the view load;
407    /// this is for the reads that deliberately do not load one.
408    fn live_catalog(&self, context: &RuntimeReadContext) -> Result<VerifiedNamespaceCatalogEntry> {
409        if context.head.namespace_id != self.namespace_id {
410            return Err(crate::error::CoreError::NamespaceCorrupt(format!(
411                "head namespace `{}` does not match requested namespace `{}`",
412                context.head.namespace_id, self.namespace_id
413            )));
414        }
415        if context.head.state == NamespaceState::Deleted {
416            return Err(crate::error::CoreError::NamespaceDeleted {
417                namespace_id: self.namespace_id.clone(),
418            });
419        }
420        Ok(VerifiedNamespaceCatalogEntry::from_head(&context.head))
421    }
422
423    /// Lists one revision page for an inode against the pinned runtime read context.
424    pub async fn list_file_revisions_for_inode_page(
425        &self,
426        inode_id: InodeId,
427        request: PageRequest<FileRevisionsPageCursor>,
428        context: &RuntimeReadContext,
429    ) -> Result<Page<FileRevision, FileRevisionsPageCursor>> {
430        let view = self.load_read_view(context).await?;
431        view.list_file_revisions_for_inode_page(inode_id, request)
432            .await
433    }
434
435    /// Reads one revision's content by path against the pinned runtime
436    /// read context.
437    pub async fn read_file_revision(
438        &self,
439        path: impl AsRef<str>,
440        revision_no: RevisionNo,
441        context: &RuntimeReadContext,
442        max_content_bytes: Option<u64>,
443    ) -> Result<AuthoritativeFileBytes> {
444        let view = self.load_read_view(context).await?;
445        view.read_file_revision_bytes(&self.store, path.as_ref(), revision_no, max_content_bytes)
446            .await
447    }
448
449    /// Reads one revision's content against the pinned runtime read context.
450    pub async fn read_file_revision_for_inode(
451        &self,
452        inode_id: InodeId,
453        revision_no: RevisionNo,
454        context: &RuntimeReadContext,
455        max_content_bytes: Option<u64>,
456    ) -> Result<Vec<u8>> {
457        let view = self.load_read_view(context).await?;
458        view.read_file_revision_bytes_for_inode(
459            &self.store,
460            inode_id,
461            revision_no,
462            max_content_bytes,
463        )
464        .await
465    }
466
467    async fn load_read_view<'a>(
468        &'a self,
469        context: &'a RuntimeReadContext,
470    ) -> Result<LoadedMetadataView<'a, S>> {
471        load_metadata_view(
472            &self.store,
473            &self.namespace_id,
474            runtime_read_load_context(context),
475        )
476        .await
477    }
478
479    /// Publishes already-classified mutation candidates as one batch: one WAL
480    /// segment, one head compare-and-swap, one result per candidate in order.
481    pub async fn publish_namespace_commits_batch(
482        &self,
483        candidates: Vec<CommitCandidate>,
484    ) -> Vec<Result<CommitResponse>> {
485        let context = match self.mutation_context() {
486            Ok(context) => context,
487            Err(error) => return candidates.iter().map(|_| Err(error.clone())).collect(),
488        };
489        crate::commit_engine::publish_namespace_commits_batch(
490            &self.store,
491            &self.namespace_id,
492            candidates,
493            &context,
494        )
495        .await
496    }
497
498    /// Reads up to `limit` committed changes after `after_seq`.
499    pub async fn list_changes_after(
500        &self,
501        after_seq: ChangeSeq,
502        limit: EffectiveLimit,
503    ) -> Result<ChangesResponse> {
504        crate::protocol::list_changes_after(&self.store, &self.namespace_id, after_seq, limit).await
505    }
506
507    /// Starts a durable upload session with explicit transport options.
508    pub async fn begin_upload(&self, request: BeginUploadRequest) -> Result<BeginUploadResponse> {
509        crate::protocol::begin_upload(
510            &self.store,
511            &self.namespace_id,
512            request,
513            &self.mutation_context()?,
514        )
515        .await
516    }
517
518    /// Mints a direct_put upload target: a fresh content identity, the
519    /// reference that names it, and the internal object key to sign.
520    pub async fn begin_direct_put_upload_target(
521        &self,
522        claim: DirectPutContentClaim,
523    ) -> Result<BeginDirectPutUploadTargetResponse> {
524        crate::protocol::begin_direct_put_upload_target(
525            &self.store,
526            &self.namespace_id,
527            claim,
528            &self.mutation_context()?,
529        )
530        .await
531    }
532
533    /// Mints a direct_multipart upload target: a fresh content identity, the
534    /// provider upload that assembles it, and the part geometry the client
535    /// cuts its payload to. What the payload turns out to be is claimed at
536    /// completion.
537    pub async fn begin_direct_multipart_upload_target(
538        &self,
539        options: DirectMultipartUploadOptions,
540    ) -> Result<BeginDirectMultipartUploadTargetResponse> {
541        crate::protocol::begin_direct_multipart_upload_target(
542            &self.store,
543            &self.namespace_id,
544            options,
545            &self.mutation_context()?,
546        )
547        .await
548    }
549
550    /// Resolves one wave of parts for signing against the session that owns
551    /// them. Nothing durable is written: parts are the client's bookkeeping.
552    pub async fn direct_multipart_part_targets(
553        &self,
554        upload_id: &UploadId,
555        requested: &[UploadPartChecksumClaim],
556    ) -> Result<MultipartPartTargets> {
557        crate::protocol::direct_multipart_part_targets(
558            &self.store,
559            &self.namespace_id,
560            upload_id,
561            requested,
562        )
563        .await
564    }
565
566    /// Uploads whole-file content into an upload session.
567    pub async fn upload_content(
568        &self,
569        upload_id: &UploadId,
570        bytes: &[u8],
571    ) -> Result<UploadContentResponse> {
572        crate::protocol::upload_content(&self.store, &self.namespace_id, upload_id, bytes).await
573    }
574
575    /// Uploads content that arrives as a stream into an upload session,
576    /// hashing it on the way through instead of holding it.
577    pub async fn upload_streamed_content(
578        &self,
579        upload_id: &UploadId,
580        body: ByteStream,
581    ) -> Result<UploadContentResponse> {
582        crate::protocol::upload_streamed_content(&self.store, &self.namespace_id, upload_id, body)
583            .await
584    }
585
586    /// Completes an upload session when the expected content ref matches.
587    pub async fn complete_upload(
588        &self,
589        upload_id: &UploadId,
590        request: &CompleteUploadRequest,
591    ) -> Result<CompleteUploadResponse> {
592        Ok(self
593            .complete_upload_prepared(upload_id, request)
594            .await?
595            .response)
596    }
597
598    /// Completes an upload session and returns proof for later publication.
599    ///
600    /// Service-proxied completion performs no content-blob I/O. Direct-put
601    /// completion performs one content-blob HEAD and no content-blob GET.
602    pub async fn complete_upload_prepared(
603        &self,
604        upload_id: &UploadId,
605        request: &CompleteUploadRequest,
606    ) -> Result<CompletedUpload> {
607        let catalog = crate::namespace::catalog::load_namespace_catalog_entry(
608            &self.store,
609            &self.namespace_id,
610        )
611        .await?;
612        self.complete_upload_prepared_with_catalog(&catalog, upload_id, request)
613            .await
614    }
615
616    /// Completes an upload with a namespace catalog binding already resolved
617    /// by the runtime.
618    pub async fn complete_upload_prepared_with_catalog(
619        &self,
620        catalog: &VerifiedNamespaceCatalogEntry,
621        upload_id: &UploadId,
622        request: &CompleteUploadRequest,
623    ) -> Result<CompletedUpload> {
624        let catalog = self.own_catalog(catalog)?;
625        crate::protocol::complete_upload(
626            &self.store,
627            &self.namespace_id,
628            catalog.content_store_id(),
629            upload_id,
630            request,
631            &self.mutation_context()?,
632        )
633        .await
634    }
635
636    /// Stages bytes this process holds as content a session owns, ready to
637    /// publish here.
638    ///
639    /// This is the whole upload lifecycle for the caller that is also the
640    /// uploader: a session opens, the bytes land under the identity it
641    /// allocated, and the session completes — with no wire step between them
642    /// and no receipt at the end, because the publication happens in this
643    /// process and takes the reference directly. What it does not skip is
644    /// the session record, which is what content garbage collection reads to
645    /// decide an object's fate; without one the bytes would be reachable by
646    /// nothing and reclaimable by nothing.
647    ///
648    /// Two small control writes on top of the content write, in that order.
649    pub async fn stage_owned_bytes(
650        &self,
651        catalog: &VerifiedNamespaceCatalogEntry,
652        bytes: &[u8],
653    ) -> Result<PreparedContent> {
654        crate::protocol::stage_owned_bytes(
655            &self.store,
656            self.own_catalog(catalog)?,
657            bytes,
658            &self.mutation_context()?,
659        )
660        .await
661    }
662
663    /// Stages a streamed payload as content a session owns, hashing it on
664    /// the way through instead of holding it.
665    ///
666    /// The streaming twin of [`Self::stage_owned_bytes`]; ownership and cost
667    /// are identical.
668    pub async fn stage_owned_stream(
669        &self,
670        catalog: &VerifiedNamespaceCatalogEntry,
671        body: ByteStream,
672    ) -> Result<PreparedContent> {
673        crate::protocol::stage_owned_stream(
674            &self.store,
675            self.own_catalog(catalog)?,
676            body,
677            &self.mutation_context()?,
678        )
679        .await
680    }
681
682    /// Refuses a catalog resolved for some other namespace.
683    ///
684    /// A mismatch is the host's wiring mistake rather than anything a request
685    /// did, so naming the two namespaces says more than naming what was being
686    /// written would — and a multipart completion has no content id to name
687    /// anyway.
688    fn own_catalog<'c>(
689        &self,
690        catalog: &'c VerifiedNamespaceCatalogEntry,
691    ) -> Result<&'c VerifiedNamespaceCatalogEntry> {
692        if catalog.namespace_id() != &self.namespace_id {
693            return Err(CoreError::Internal(format!(
694                "an operation on namespace `{}` was given namespace `{}`'s catalog",
695                self.namespace_id,
696                catalog.namespace_id()
697            )));
698        }
699        Ok(catalog)
700    }
701
702    /// Aborts an upload session, then deletes the content object it owned.
703    ///
704    /// Terminal and idempotent: repeating it succeeds, and it refuses a
705    /// session that already completed, whose content may be published.
706    pub async fn abort_upload(&self, upload_id: &UploadId) -> Result<AbortUploadResponse> {
707        let content_store_id = crate::namespace::catalog::load_namespace_content_store_id(
708            &self.store,
709            &self.namespace_id,
710        )
711        .await?;
712        crate::protocol::abort_upload(
713            &self.store,
714            &self.namespace_id,
715            &content_store_id,
716            upload_id,
717            &self.mutation_context()?,
718        )
719        .await
720    }
721
722    /// Reads one upload session, minting a fresh receipt when it is
723    /// completed so a lost commit response never costs a retransfer.
724    pub async fn read_upload_status(
725        &self,
726        upload_id: &UploadId,
727    ) -> Result<(UploadStatusResponse, Option<CompletedUploadReceipt>)> {
728        let content_store_id = crate::namespace::catalog::load_namespace_content_store_id(
729            &self.store,
730            &self.namespace_id,
731        )
732        .await?;
733        crate::protocol::read_upload_status(
734            &self.store,
735            &self.namespace_id,
736            &content_store_id,
737            upload_id,
738            self.mutation_context()?.now_ms,
739        )
740        .await
741    }
742
743    /// Creates or reuses a named checkpoint pinning the current namespace
744    /// head for the calling user.
745    ///
746    /// A checkpoint pins a manifest version for retention/provenance. If the
747    /// current head has no manifest yet, this first publishes one for the
748    /// current durable namespace state; it is not a request to compact
749    /// metadata. `ttl_ms` computes the record's expiry from the engine's
750    /// clock; absent means the pin holds until explicitly released.
751    pub async fn create_checkpoint(
752        &self,
753        name: String,
754        ttl_ms: Option<u64>,
755    ) -> Result<CreateCheckpointResponse> {
756        let context = self.mutation_context()?;
757        let expires_at_ms = ttl_ms.map(|ttl_ms| context.now_ms.saturating_add(ttl_ms));
758        crate::checkpoint::create_checkpoint(
759            &self.store,
760            &self.namespace_id,
761            CheckpointOwner::User { name },
762            expires_at_ms,
763            &context,
764        )
765        .await
766    }
767
768    /// Lists every active checkpoint record on the namespace, oldest first.
769    ///
770    /// A read: nothing here releases, expires, or reaps a record. A record
771    /// whose expiry has passed but which no collection pass has released is
772    /// still active and is still listed, with that expiry in the answer.
773    pub async fn list_checkpoints(&self) -> Result<ListCheckpointsResponse> {
774        crate::checkpoint::list_checkpoints(&self.store, &self.namespace_id).await
775    }
776
777    /// Releases a user-owned checkpoint by id.
778    ///
779    /// Idempotent: releasing an already-released or reaped record succeeds.
780    /// The record is reaped by a later garbage-collection pass; its basis
781    /// becomes collectable only on the pass after that.
782    pub async fn release_checkpoint(
783        &self,
784        checkpoint_id: &CheckpointId,
785    ) -> Result<ReleaseCheckpointResponse> {
786        crate::checkpoint::release_checkpoint(
787            &self.store,
788            &self.namespace_id,
789            checkpoint_id,
790            &self.mutation_context()?,
791        )
792        .await
793    }
794
795    /// Flushes the visible WAL tail and advances `metadata/root.json` to a
796    /// manifest covering the current head.
797    ///
798    /// This is the latest-state maintenance operation: it absorbs the visible
799    /// WAL tail into a published manifest and advances the root, creating no
800    /// checkpoint record. Superseded manifests become garbage-collection
801    /// candidates once nothing pins them.
802    pub async fn flush_wal(&self) -> Result<FlushWalResponse> {
803        crate::checkpoint::flush_wal(&self.store, &self.namespace_id, &self.mutation_context()?)
804            .await
805    }
806
807    /// Runs at most one metadata reorganization unit: folds one family
808    /// group's L0 delta rows into new base segments and publishes a manifest
809    /// swapping that group's references. Checkpoints only append L0 runs, so
810    /// calling this from maintenance is what keeps read fan-out bounded.
811    /// Repeat until the report says `NotNeeded`; every call re-reads durable
812    /// state, so interrupted reorganizations resume from the live manifest.
813    pub async fn reorganize_metadata(&self) -> Result<crate::checkpoint::MetadataReorganizeReport> {
814        crate::checkpoint::reorganize_metadata_step(
815            &self.store,
816            &self.namespace_id,
817            &self.mutation_context()?,
818            crate::checkpoint::MetadataLsmPolicy::default(),
819        )
820        .await
821    }
822
823    /// Advances the retention floor when a verified checkpoint makes it safe.
824    pub async fn advance_retention_floor(&self) -> Result<AdvanceRetentionResponse> {
825        crate::checkpoint::advance_retention_floor(
826            &self.store,
827            &self.namespace_id,
828            &self.mutation_context()?,
829        )
830        .await
831    }
832
833    /// The identity every mutation publishes under.
834    ///
835    /// A read-only engine has none, so this fails instead of inventing one.
836    /// Nothing routes that error: the runtime hands read-only engines only to
837    /// read paths, and this exists so a wiring mistake fails honestly rather
838    /// than publishing under a fabricated writer.
839    fn mutation_context(&self) -> Result<MutationContext> {
840        let writer = self.writer.as_ref().ok_or_else(|| {
841            crate::error::CoreError::Internal(
842                "engine built without writer identity cannot mutate".to_owned(),
843            )
844        })?;
845        Ok(MutationContext {
846            writer_id: writer.writer_id.clone(),
847            now_ms: current_time_ms()?,
848        })
849    }
850}
851
852/// Builder for [`NamespaceEngine`].
853///
854/// The builder keeps construction explicit: choose a namespace, choose the
855/// writer identity, then build the engine.
856#[derive(Debug)]
857pub struct NamespaceEngineBuilder<S> {
858    store: S,
859    namespace_id: Option<NamespaceId>,
860    writer_id: Option<String>,
861}
862
863impl<S: ObjectStore> NamespaceEngineBuilder<S> {
864    /// Sets the namespace this engine will operate on.
865    pub fn namespace_id(mut self, namespace_id: NamespaceId) -> Self {
866        self.namespace_id = Some(namespace_id);
867        self
868    }
869
870    /// Sets the writer identity used for epoch acquisition and commits.
871    pub fn writer_id(mut self, writer_id: impl Into<String>) -> Self {
872        self.writer_id = Some(writer_id.into());
873        self
874    }
875
876    /// Builds a mutating engine after required fields are present.
877    pub fn build(self) -> std::result::Result<NamespaceEngine<S>, NamespaceEngineBuildError> {
878        let namespace_id = self
879            .namespace_id
880            .ok_or(NamespaceEngineBuildError::MissingNamespace)?;
881        let writer_id = self
882            .writer_id
883            .ok_or(NamespaceEngineBuildError::MissingWriter)?;
884        if writer_id.trim().is_empty() {
885            return Err(NamespaceEngineBuildError::EmptyWriter);
886        }
887
888        Ok(NamespaceEngine {
889            store: self.store,
890            namespace_id,
891            writer: Some(EngineWriter { writer_id }),
892        })
893    }
894
895    /// Builds a read-only engine: no writer identity at all.
896    ///
897    /// Only the namespace is required. Any writer identity set on the
898    /// builder is dropped — a read-only engine carries none by definition.
899    pub fn build_reader(
900        self,
901    ) -> std::result::Result<NamespaceEngine<S>, NamespaceEngineBuildError> {
902        let namespace_id = self
903            .namespace_id
904            .ok_or(NamespaceEngineBuildError::MissingNamespace)?;
905        Ok(NamespaceEngine {
906            store: self.store,
907            namespace_id,
908            writer: None,
909        })
910    }
911}
912
913/// Error returned when a [`NamespaceEngine`] cannot be built.
914#[derive(Debug, Error, Clone, PartialEq, Eq)]
915pub enum NamespaceEngineBuildError {
916    /// A namespace id was not supplied.
917    #[error("namespace is required")]
918    MissingNamespace,
919    /// A writer id was not supplied.
920    #[error("writer identity is required")]
921    MissingWriter,
922    /// The writer id was empty or whitespace.
923    #[error("writer identity must not be empty")]
924    EmptyWriter,
925}
926
927#[cfg(test)]
928mod tests {
929    use super::*;
930    use loonfs_objectstore::local_fs_store::LocalFsStore;
931    use tempfile::tempdir;
932
933    #[test]
934    fn namespace_engine_builds_with_required_identity() {
935        let temp_dir = tempdir().expect("tempdir");
936        let store = LocalFsStore::new(temp_dir.path()).expect("store");
937        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
938
939        let engine = NamespaceEngine::builder(store)
940            .namespace_id(namespace_id.clone())
941            .writer_id("writer-a")
942            .build()
943            .expect("engine builds");
944
945        assert_eq!(engine.namespace_id(), &namespace_id);
946        assert_eq!(engine.writer_id(), Some("writer-a"));
947    }
948
949    #[test]
950    fn reader_engine_builds_without_any_writer_identity() {
951        let temp_dir = tempdir().expect("tempdir");
952        let store = LocalFsStore::new(temp_dir.path()).expect("store");
953        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
954
955        let engine = NamespaceEngine::builder(store)
956            .namespace_id(namespace_id.clone())
957            .build_reader()
958            .expect("reader engine builds without a writer");
959
960        assert_eq!(engine.namespace_id(), &namespace_id);
961        assert_eq!(engine.writer_id(), None);
962    }
963
964    #[tokio::test]
965    async fn reader_engine_still_serves_reads() {
966        let temp_dir = tempdir().expect("tempdir");
967        let namespace_id = NamespaceId::parse("demo").expect("valid namespace id");
968        NamespaceEngine::builder(LocalFsStore::new(temp_dir.path()).expect("store"))
969            .namespace_id(namespace_id.clone())
970            .writer_id("writer-a")
971            .build()
972            .expect("engine builds")
973            .bootstrap_namespace(BootstrapOptions::default())
974            .await
975            .expect("bootstrap namespace");
976
977        let reader = NamespaceEngine::builder(LocalFsStore::new(temp_dir.path()).expect("store"))
978            .namespace_id(namespace_id.clone())
979            .build_reader()
980            .expect("reader engine builds");
981        let changes = reader
982            .list_changes_after(
983                ChangeSeq(0),
984                loonfs_api::PaginationPolicy::default()
985                    .resolve_limit(None)
986                    .expect("default limit"),
987            )
988            .await
989            .expect("a reader-built engine serves reads");
990        assert_eq!(changes.namespace_id, namespace_id);
991    }
992
993    #[tokio::test]
994    async fn reader_engine_refuses_to_mutate() {
995        let temp_dir = tempdir().expect("tempdir");
996        let store = LocalFsStore::new(temp_dir.path()).expect("store");
997        let reader = NamespaceEngine::builder(store)
998            .namespace_id(NamespaceId::parse("demo").expect("valid namespace id"))
999            .build_reader()
1000            .expect("reader engine builds");
1001
1002        let error = reader
1003            .flush_wal()
1004            .await
1005            .expect_err("a reader-built engine must refuse mutations");
1006        assert!(
1007            error
1008                .to_string()
1009                .contains("engine built without writer identity cannot mutate"),
1010            "unexpected error: {error}"
1011        );
1012    }
1013
1014    #[test]
1015    fn namespace_engine_builder_rejects_missing_required_fields() {
1016        let temp_dir = tempdir().expect("tempdir");
1017        let store = LocalFsStore::new(temp_dir.path()).expect("store");
1018        let err = NamespaceEngine::builder(store)
1019            .build()
1020            .expect_err("missing namespace");
1021        assert_eq!(err, NamespaceEngineBuildError::MissingNamespace);
1022
1023        let temp_dir = tempdir().expect("tempdir");
1024        let store = LocalFsStore::new(temp_dir.path()).expect("store");
1025        let err = NamespaceEngine::builder(store)
1026            .namespace_id(NamespaceId::parse("demo").expect("valid namespace id"))
1027            .build()
1028            .expect_err("missing writer");
1029        assert_eq!(err, NamespaceEngineBuildError::MissingWriter);
1030    }
1031}