Skip to main content

loonfs_core/path/read/
materialized_view.rs

1//! [`LoadedMetadataView`]: a verified, seq-pinned view of one namespace
2//! that answers core reads.
3
4use super::listing::{invalid_cursor, validate_cursor_head, validate_directory_cursor};
5use crate::checkpoint::{
6    head_from_manifest, load_basis_metadata_tables, MetadataTableCache, VerifiedMetadataTables,
7    WalTailProjectionCache, WalTailProjectionCacheKey,
8};
9use crate::error::MetadataProjectionLoadError;
10use crate::error::{CoreError, MetadataViewError, Result};
11use crate::metadata::{
12    LeafRevisionPrefetch, MetadataState, MetadataView, MetadataViewSession, ResolvedVisiblePath,
13    RevisionRecord, VisibleChildEntry,
14};
15#[cfg(test)]
16use crate::namespace::basis::read_head_and_metadata_basis;
17use crate::namespace::basis::MetadataBasis;
18use crate::namespace::catalog::VerifiedNamespaceCatalogEntry;
19use crate::path::helpers::{map_path_error_to_core, parse_absolute_path_for_core};
20use crate::storage::content::{content_object_key_for_ref, read_durable_content_bytes};
21use crate::wal::{load_validated_wal_chain, project_validated_wal_tail, WalChainLoadRequest};
22use loonfs_api::wire::control::{HeadState, NamespaceState};
23use loonfs_api::{
24    AbsolutePath, AuthoritativeFileBytes, AuthoritativePathEntry, ChangeSeq, ContentRef,
25    ContentStoreId, DirectoryPageCursor, DisplayName, FileRevision, FileRevisionsPageCursor,
26    InodeId, InodeKind, NamespaceId, Page, PageRequest, RevisionNo, TrashEntry, TrashPageCursor,
27};
28use loonfs_objectstore::ObjectStore;
29use std::sync::Arc;
30use tracing::Instrument;
31
32#[derive(Clone, Copy)]
33pub(crate) enum ReadViewContext<'a> {
34    /// Fresh head+root read with no caches: the shape embedded unit tests
35    /// exercise; production reads always pin an anchor.
36    #[cfg(test)]
37    Latest,
38    PinnedHead {
39        head: &'a HeadState,
40        head_etag: Option<&'a str>,
41        /// Basis pinned together with the head when the snapshot was
42        /// taken. The live root may have moved past a pinned head; the
43        /// pinned pair stays consistent (any manifest at or below the
44        /// pinned seq serves it, with WAL replay covering the rest).
45        basis: &'a MetadataBasis,
46    },
47}
48
49#[derive(Clone, Copy)]
50pub(crate) struct ReadLoadContext<'a> {
51    view: ReadViewContext<'a>,
52    table_cache: Option<&'a MetadataTableCache>,
53    tail_cache: Option<&'a WalTailProjectionCache>,
54}
55
56impl<'a> ReadLoadContext<'a> {
57    #[cfg(test)]
58    pub(crate) fn latest() -> Self {
59        Self {
60            view: ReadViewContext::Latest,
61            table_cache: None,
62            tail_cache: None,
63        }
64    }
65
66    pub(crate) fn pinned_head(
67        head: &'a HeadState,
68        head_etag: Option<&'a str>,
69        basis: &'a MetadataBasis,
70        table_cache: Option<&'a MetadataTableCache>,
71        tail_cache: Option<&'a WalTailProjectionCache>,
72    ) -> Self {
73        Self {
74            view: ReadViewContext::PinnedHead {
75                head,
76                head_etag,
77                basis,
78            },
79            table_cache,
80            tail_cache,
81        }
82    }
83}
84
85pub(crate) async fn load_metadata_view<'a, S: ObjectStore + ?Sized>(
86    store: &'a S,
87    namespace_id: &NamespaceId,
88    context: ReadLoadContext<'a>,
89) -> Result<LoadedMetadataView<'a, S>> {
90    match context.view {
91        #[cfg(test)]
92        ReadViewContext::Latest => {
93            let loaded = read_head_and_metadata_basis(store, namespace_id)
94                .await
95                .map_err(MetadataProjectionLoadError::LoadHead)?;
96            LoadedMetadataView::load_at_head(
97                store,
98                namespace_id,
99                loaded.head.envelope.state,
100                &loaded.basis,
101                context,
102            )
103            .await
104        }
105        ReadViewContext::PinnedHead { head, basis, .. } => {
106            LoadedMetadataView::load_at_head(store, namespace_id, head.clone(), basis, context)
107                .await
108        }
109    }
110}
111
112/// Where one file's bytes actually live, for a host that is about to
113/// authorize a client to read them without passing them through itself.
114///
115/// It names one immutable content object, so it does not go stale when the
116/// path moves on: a commit that replaces the file writes a new object and
117/// leaves this one where it is.
118#[derive(Debug, Clone, PartialEq, Eq)]
119pub struct DirectDownloadTarget {
120    /// Absolute path as rendered from stored display names.
121    pub absolute_path: AbsolutePath,
122    /// Revision this target reads, resolved from the request.
123    pub revision_no: RevisionNo,
124    /// Identity, byte length, and checksum evidence for those bytes.
125    pub content_ref: ContentRef,
126    /// Logical unscoped object key an issuer signs a read of.
127    pub object_key: String,
128}
129
130/// A coherent, seq-pinned namespace read view.
131///
132/// Every read the engine answers for one pinned context runs over one of
133/// these; the view is crate-internal, and consumers reach it through the
134/// engine's read methods.
135pub(crate) struct LoadedMetadataView<'a, S: ObjectStore + ?Sized> {
136    pub(super) namespace_id: NamespaceId,
137    pub(super) content_store_id: ContentStoreId,
138    pub(super) head: HeadState,
139    pub(super) tables: VerifiedMetadataTables<'a, S>,
140    wal_tail_rows: Arc<MetadataState>,
141}
142
143impl<'a, S: ObjectStore + ?Sized> LoadedMetadataView<'a, S> {
144    async fn load_at_head(
145        store: &'a S,
146        namespace_id: &NamespaceId,
147        head: HeadState,
148        basis: &MetadataBasis,
149        load_context: ReadLoadContext<'a>,
150    ) -> Result<Self> {
151        if &head.namespace_id != namespace_id {
152            return Err(CoreError::NamespaceCorrupt(format!(
153                "head namespace `{}` does not match requested namespace `{}`",
154                head.namespace_id, namespace_id
155            )));
156        }
157        if head.state == NamespaceState::Deleted {
158            return Err(CoreError::NamespaceDeleted {
159                namespace_id: namespace_id.clone(),
160            });
161        }
162        let catalog_entry = VerifiedNamespaceCatalogEntry::from_head(&head);
163        let manifest_id = basis.manifest_id();
164        let loaded_basis =
165            load_basis_metadata_tables(store, load_context.table_cache, namespace_id, basis)
166                .await?;
167        let tables = loaded_basis.tables;
168        let manifest_head = head_from_manifest(&head, tables.manifest());
169        let cache_key = match load_context.view {
170            #[cfg(test)]
171            ReadViewContext::Latest => None,
172            ReadViewContext::PinnedHead { head_etag, .. } => {
173                head_etag.map(|etag| WalTailProjectionCacheKey {
174                    namespace_id: namespace_id.clone(),
175                    manifest_id,
176                    manifest_head_seq: manifest_head.seq,
177                    head_seq: head.seq,
178                    head_etag: etag.to_owned(),
179                })
180            }
181        };
182        if let (Some(cache), Some(key)) = (load_context.tail_cache, cache_key.as_ref()) {
183            if let Some(wal_tail_rows) = cache.get(key) {
184                return Ok(Self {
185                    namespace_id: namespace_id.clone(),
186                    content_store_id: catalog_entry.content_store_id().clone(),
187                    head,
188                    tables,
189                    wal_tail_rows,
190                });
191            }
192        }
193        let wal_chain = load_validated_wal_chain(
194            store,
195            WalChainLoadRequest {
196                namespace_id,
197                chain_base_seq: manifest_head.seq,
198                head_seq: head.seq,
199                visible_tip: head.visible_wal_tip.clone(),
200                stop_after_seq: None,
201                recent_segments: &head.recent_segments,
202            },
203        )
204        .await
205        .map_err(|error| {
206            CoreError::MetadataProjection(MetadataProjectionLoadError::WalChainLoad(error))
207        })?;
208        let replayed = {
209            let _span =
210                tracing::info_span!("loonfs.phase", phase = "project_metadata_state").entered();
211            project_validated_wal_tail(
212                &manifest_head,
213                &loaded_basis.base_state,
214                Some(head.writer_epoch),
215                &wal_chain,
216            )
217            .map_err(|error| {
218                CoreError::MetadataProjection(MetadataProjectionLoadError::WalReplay(error))
219            })
220        }?;
221        let wal_tail_rows = Arc::new(replayed.resulting_metadata_state);
222        if let (Some(cache), Some(key)) = (load_context.tail_cache, cache_key) {
223            cache.insert(key, Arc::clone(&wal_tail_rows));
224        }
225        Ok(Self {
226            namespace_id: namespace_id.clone(),
227            content_store_id: catalog_entry.content_store_id().clone(),
228            head,
229            tables,
230            wal_tail_rows,
231        })
232    }
233
234    #[tracing::instrument(
235        level = "info",
236        name = "loonfs.phase",
237        err,
238        skip_all,
239        fields(phase = "walk_path")
240    )]
241    pub(crate) async fn resolve_path(&self, absolute_path: &str) -> Result<AuthoritativePathEntry> {
242        let absolute_path = parse_absolute_path_for_core(absolute_path)?;
243        // One session serves the resolution and the entry build: the walk's
244        // preloaded probes (including the leaf's head revision) are exactly
245        // what the entry build reads back as cache hits.
246        let mut session = self.metadata_view().session();
247        let resolved = session
248            .resolve_visible_path(&absolute_path, LeafRevisionPrefetch::Prefetch)
249            .await?;
250        self.build_authoritative_path_entry_with_session(&mut session, &resolved)
251            .await
252    }
253
254    pub(crate) async fn read_file_bytes(
255        &self,
256        store: &S,
257        absolute_path: &str,
258        max_content_bytes: Option<u64>,
259    ) -> Result<AuthoritativeFileBytes> {
260        let (entry, content_ref) = self.resolve_file_content(absolute_path).await?;
261        ensure_within_read_limit(content_ref.size_bytes, max_content_bytes)?;
262        let read = read_durable_content_bytes(store, &self.content_store_id, &content_ref).await?;
263        Ok(AuthoritativeFileBytes {
264            entry,
265            bytes: read.bytes,
266        })
267    }
268
269    /// Resolves a path to the file it names and the content reference its
270    /// current revision points at: the metadata half both the buffered read
271    /// and the streaming one run, so neither can resolve a path the other
272    /// would refuse.
273    pub(crate) async fn resolve_file_content(
274        &self,
275        absolute_path: &str,
276    ) -> Result<(AuthoritativePathEntry, ContentRef)> {
277        let entry = self.resolve_path(absolute_path).await?;
278        if entry.inode_kind != InodeKind::File {
279            return Err(CoreError::ExpectedFile {
280                path: entry.absolute_path.to_string(),
281                kind: entry.inode_kind,
282            });
283        }
284        let content_ref = entry
285            .content_ref
286            .clone()
287            .ok_or_else(|| CoreError::PathNotFound(absolute_path.to_owned()))?;
288        Ok((entry, content_ref))
289    }
290
291    /// The content store this view's namespace is bound to.
292    pub(crate) fn content_store_id(&self) -> &ContentStoreId {
293        &self.content_store_id
294    }
295
296    /// Resolves a path to the content object a direct read would fetch:
297    /// the reference that names those bytes, and the key that addresses
298    /// them.
299    ///
300    /// No bytes move and no read limit applies. The limit bounds what this
301    /// server buffers for one response, and this is the transport that
302    /// buffers nothing — which is the whole reason it exists, because a
303    /// deployment that allowed a direct upload past that limit has an
304    /// object it could not otherwise hand back.
305    pub(crate) async fn direct_download_target(
306        &self,
307        absolute_path: &str,
308        revision_no: Option<RevisionNo>,
309    ) -> Result<DirectDownloadTarget> {
310        let entry = self.resolve_path(absolute_path).await?;
311        if entry.inode_kind != InodeKind::File {
312            return Err(CoreError::ExpectedFile {
313                path: entry.absolute_path.to_string(),
314                kind: entry.inode_kind,
315            });
316        }
317        let (revision_no, content_ref) = match revision_no {
318            Some(requested) => {
319                let revision = self.revision_for_inode(entry.inode_id, requested).await?;
320                (revision.revision_no, revision.content_ref)
321            }
322            // A visible file carries both; a path that resolves without
323            // them is the same absence a proxied read reports.
324            None => match (entry.revision_no, entry.content_ref) {
325                (Some(revision_no), Some(content_ref)) => (revision_no, content_ref),
326                _ => return Err(CoreError::PathNotFound(absolute_path.to_owned())),
327            },
328        };
329        let object_key = content_object_key_for_ref(&self.content_store_id, &content_ref)?;
330
331        Ok(DirectDownloadTarget {
332            absolute_path: entry.absolute_path,
333            revision_no,
334            content_ref,
335            object_key,
336        })
337    }
338
339    pub(crate) async fn list_file_revisions_page(
340        &self,
341        absolute_path: &str,
342        request: PageRequest<FileRevisionsPageCursor>,
343    ) -> Result<Page<FileRevision, FileRevisionsPageCursor>> {
344        let entry = self.resolve_path(absolute_path).await?;
345        if entry.inode_kind != InodeKind::File {
346            return Err(CoreError::ExpectedFile {
347                path: entry.absolute_path.to_string(),
348                kind: entry.inode_kind,
349            });
350        }
351        self.list_file_revisions_for_inode_page(entry.inode_id, request)
352            .await
353    }
354
355    /// One page of the namespace's recoverable deletions, oldest deletion
356    /// first, as one bounded range scan over the derived active-deletion
357    /// family.
358    ///
359    /// The materializer keeps that family in step with the tombstone family —
360    /// a delete adds the row, an undelete removes it — so the page reads only
361    /// the deletions it returns, never the namespace's whole deletion
362    /// history. Rows are never dropped at the retention floor, so a deletion
363    /// stays listed and recoverable however far the floor advances.
364    pub(crate) async fn list_trash_page(
365        &self,
366        request: PageRequest<TrashPageCursor>,
367    ) -> Result<Page<TrashEntry, TrashPageCursor>> {
368        if let Some(cursor) = request.cursor.as_ref() {
369            if cursor.head_seq > self.head.seq {
370                // Forward-only drift, the same rule as every other cursor.
371                return Err(MetadataViewError::SnapshotUnavailable {
372                    requested_seq: cursor.head_seq,
373                    head_seq: self.head.seq,
374                }
375                .into());
376            }
377        }
378        let start_after = request
379            .cursor
380            .as_ref()
381            .map(|cursor| (cursor.last_deleted_at_seq, cursor.last_root_inode_id));
382        let mut deletions = self
383            .metadata_view()
384            .session()
385            .active_deletions_page(start_after, request.limit.limit_plus_one())
386            .await?;
387        let has_more = deletions.len() > request.limit.as_usize();
388        if has_more {
389            deletions.truncate(request.limit.as_usize());
390        }
391        let next_cursor = if has_more {
392            let last = deletions
393                .last()
394                .expect("non-zero page limit with more entries must return an item");
395            Some(TrashPageCursor {
396                head_seq: self.head.seq,
397                last_deleted_at_seq: last.deleted_at_seq,
398                last_root_inode_id: last.root_inode_id,
399            })
400        } else {
401            None
402        };
403        let entries = deletions
404            .into_iter()
405            .map(|deletion| TrashEntry {
406                root_inode_id: deletion.root_inode_id,
407                deleted_at_seq: deletion.deleted_at_seq,
408                deleted_at_ms: deletion.deleted_at_ms,
409                parent_inode_id: deletion.parent_inode_id,
410                name_key: deletion.name_key,
411                display_name: deletion.display_name,
412            })
413            .collect();
414        Ok(Page {
415            items: entries,
416            next_cursor,
417        })
418    }
419
420    pub(crate) async fn list_file_revisions_for_inode_page(
421        &self,
422        inode_id: InodeId,
423        request: PageRequest<FileRevisionsPageCursor>,
424    ) -> Result<Page<FileRevision, FileRevisionsPageCursor>> {
425        let inode = self
426            .metadata_view()
427            .inode_at_seq(inode_id)
428            .await?
429            .ok_or_else(|| CoreError::PathNotFound(inode_id.to_string()))?;
430        if inode.inode_kind != InodeKind::File {
431            return Err(CoreError::ExpectedFile {
432                path: inode_id.to_string(),
433                kind: inode.inode_kind,
434            });
435        }
436        if let Some(cursor) = request.cursor.as_ref() {
437            validate_file_revisions_cursor(cursor, self.head.seq, inode_id)?;
438        }
439
440        let start_after = request.cursor.as_ref().map(|cursor| {
441            crate::metadata::manifest_index::RevisionPagePosition::after(
442                cursor.last_revision_no,
443                cursor.last_committed_seq,
444                cursor.last_revision_delta_index,
445            )
446        });
447        let mut revision_records = self
448            .metadata_view()
449            .session()
450            .revisions_for_inode_page_desc(inode_id, start_after, request.limit.limit_plus_one())
451            .await?;
452        let has_more = revision_records.len() > request.limit.as_usize();
453        if has_more {
454            revision_records.truncate(request.limit.as_usize());
455        }
456        let next_cursor = if has_more {
457            let last = revision_records
458                .last()
459                .expect("non-zero page limit with more revisions must return an item");
460            Some(FileRevisionsPageCursor {
461                head_seq: self.head.seq,
462                inode_id,
463                last_revision_no: last.revision_no,
464                last_committed_seq: last.committed_seq,
465                last_revision_delta_index: last.revision_delta_index,
466            })
467        } else {
468            None
469        };
470        let revisions = revision_records
471            .into_iter()
472            .map(|revision| FileRevision {
473                inode_id: revision.inode_id,
474                revision_no: revision.revision_no,
475                committed_seq: revision.committed_seq,
476                committed_at_ms: revision.committed_at_ms,
477                content_ref: revision.content_ref,
478            })
479            .collect();
480
481        Ok(Page {
482            items: revisions,
483            next_cursor,
484        })
485    }
486
487    pub(crate) async fn read_file_revision_bytes(
488        &self,
489        store: &S,
490        absolute_path: &str,
491        revision_no: RevisionNo,
492        max_content_bytes: Option<u64>,
493    ) -> Result<AuthoritativeFileBytes> {
494        let mut entry = self.resolve_path(absolute_path).await?;
495        if entry.inode_kind != InodeKind::File {
496            return Err(CoreError::ExpectedFile {
497                path: entry.absolute_path.to_string(),
498                kind: entry.inode_kind,
499            });
500        }
501        let revision = self.revision_for_inode(entry.inode_id, revision_no).await?;
502        entry.revision_no = Some(revision.revision_no);
503        entry.size_bytes = Some(revision.content_ref.size_bytes);
504        entry.content_ref = Some(revision.content_ref.clone());
505        ensure_within_read_limit(revision.content_ref.size_bytes, max_content_bytes)?;
506        let read = read_durable_content_bytes(store, &self.content_store_id, &revision.content_ref)
507            .await?;
508        Ok(AuthoritativeFileBytes {
509            entry,
510            bytes: read.bytes,
511        })
512    }
513
514    pub(crate) async fn read_file_revision_bytes_for_inode(
515        &self,
516        store: &S,
517        inode_id: InodeId,
518        revision_no: RevisionNo,
519        max_content_bytes: Option<u64>,
520    ) -> Result<Vec<u8>> {
521        let revision = self.revision_for_inode(inode_id, revision_no).await?;
522        ensure_within_read_limit(revision.content_ref.size_bytes, max_content_bytes)?;
523        let read = read_durable_content_bytes(store, &self.content_store_id, &revision.content_ref)
524            .await?;
525        Ok(read.bytes)
526    }
527
528    #[tracing::instrument(
529        level = "info",
530        name = "loonfs.phase",
531        err,
532        skip_all,
533        fields(phase = "walk_path")
534    )]
535    pub(crate) async fn list_path_page(
536        &self,
537        absolute_path: &str,
538        request: PageRequest<DirectoryPageCursor>,
539    ) -> Result<Page<AuthoritativePathEntry, DirectoryPageCursor>> {
540        validate_cursor_head(self.head.seq, request.cursor.as_ref())?;
541
542        let absolute_path = parse_absolute_path_for_core(absolute_path)?;
543        let mut session = self.metadata_view().session();
544        let resolved = session
545            .resolve_visible_path(&absolute_path, LeafRevisionPrefetch::Skip)
546            .await?;
547        if let Some(cursor) = request.cursor.as_ref() {
548            validate_directory_cursor(cursor, &resolved)?;
549        }
550
551        if resolved.inode_kind == InodeKind::File {
552            if request.cursor.is_some() {
553                return Err(invalid_cursor(
554                    "directory cursor cannot resume a file listing",
555                ));
556            }
557            return Ok(Page {
558                items: vec![
559                    self.build_authoritative_path_entry_with_session(&mut session, &resolved)
560                        .await?,
561                ],
562                next_cursor: None,
563            });
564        }
565        if resolved.inode_kind != InodeKind::Directory {
566            return Err(CoreError::ExpectedDirectory {
567                path: resolved.absolute_path,
568                kind: resolved.inode_kind,
569            });
570        }
571
572        let start_after = request
573            .cursor
574            .as_ref()
575            .map(|cursor| cursor.last_name_key.as_str());
576        let select_span = tracing::info_span!(
577            "loonfs.phase",
578            phase = "list_page_select_children",
579            list_page_requested_limit = request.limit.as_usize() as u64,
580            list_page_children_returned = tracing::field::Empty,
581        );
582        let mut children = session
583            .visible_children_page_by_name_key(
584                resolved.inode_id,
585                start_after,
586                request.limit.limit_plus_one(),
587            )
588            .instrument(select_span.clone())
589            .await?;
590        select_span.record("list_page_children_returned", children.len() as u64);
591        let has_more = children.len() > request.limit.as_usize();
592        if has_more {
593            children.truncate(request.limit.as_usize());
594        }
595
596        let next_cursor = if has_more {
597            let last = children
598                .last()
599                .expect("non-zero page limit with more children must return an item");
600            Some(DirectoryPageCursor {
601                head_seq: self.head.seq,
602                directory_inode_id: resolved.inode_id,
603                last_name_key: last.binding.name_key.clone(),
604            })
605        } else {
606            None
607        };
608
609        let build_span = tracing::info_span!(
610            "loonfs.phase",
611            phase = "list_page_build_entries",
612            list_page_children_returned = children.len() as u64,
613            list_page_visible_child_calls = tracing::field::Empty,
614            list_page_visible_inode_calls = tracing::field::Empty,
615            list_page_current_parent_binding_calls = tracing::field::Empty,
616            list_page_covering_tombstone_calls = tracing::field::Empty,
617            list_page_latest_revision_calls = tracing::field::Empty,
618            list_page_direntry_child_scan_calls = tracing::field::Empty,
619            list_page_scan_prefix_calls = tracing::field::Empty,
620            list_page_scan_range_page_calls = tracing::field::Empty,
621            list_page_preload_unbind_range_scans = tracing::field::Empty,
622            list_page_preload_child_lookups = tracing::field::Empty,
623        );
624        let entries = async {
625            let mut entries = Vec::with_capacity(children.len());
626            for child in children {
627                entries.push(
628                    self.build_authoritative_path_entry_from_visible_child(
629                        &mut session,
630                        &resolved,
631                        child,
632                    )
633                    .await?,
634                );
635            }
636            Ok::<_, CoreError>(entries)
637        }
638        .instrument(build_span.clone())
639        .await?;
640        let counters = session.counters();
641        build_span.record(
642            "list_page_visible_child_calls",
643            counters.visible_child_calls,
644        );
645        build_span.record(
646            "list_page_visible_inode_calls",
647            counters.visible_inode_calls,
648        );
649        build_span.record(
650            "list_page_current_parent_binding_calls",
651            counters.current_parent_binding_calls,
652        );
653        build_span.record(
654            "list_page_covering_tombstone_calls",
655            counters.covering_tombstone_calls,
656        );
657        build_span.record(
658            "list_page_latest_revision_calls",
659            counters.latest_revision_calls,
660        );
661        build_span.record(
662            "list_page_direntry_child_scan_calls",
663            counters.direntry_child_scan_calls,
664        );
665        build_span.record("list_page_scan_prefix_calls", counters.scan_prefix_calls);
666        build_span.record(
667            "list_page_scan_range_page_calls",
668            counters.scan_range_page_calls,
669        );
670        build_span.record(
671            "list_page_preload_unbind_range_scans",
672            counters.list_preload_unbind_range_scans,
673        );
674        build_span.record(
675            "list_page_preload_child_lookups",
676            counters.list_preload_child_lookups,
677        );
678
679        Ok(Page {
680            items: entries,
681            next_cursor,
682        })
683    }
684
685    /// `resolved` must come from visible resolution or enumeration at this
686    /// session's seq (both callers do), so the revision lookup does not
687    /// re-derive the inode's visibility.
688    async fn build_authoritative_path_entry_with_session(
689        &self,
690        session: &mut MetadataViewSession<'_, '_, S>,
691        resolved: &ResolvedVisiblePath,
692    ) -> Result<AuthoritativePathEntry> {
693        let revision = if resolved.inode_kind == InodeKind::File {
694            session
695                .latest_revision_head_of_visible(resolved.inode_id)
696                .await?
697        } else {
698            None
699        };
700        let content_ref = revision
701            .as_ref()
702            .map(|revision| revision.content_ref.clone());
703        let size_bytes = content_ref
704            .as_ref()
705            .map(|content_ref| content_ref.size_bytes);
706        let absolute_path = AbsolutePath::parse(&resolved.absolute_path).map_err(|error| {
707            CoreError::NamespaceCorrupt(format!(
708                "resolved visible path `{}` is not a valid absolute path: {error}",
709                resolved.absolute_path
710            ))
711        })?;
712        let display_name = resolved
713            .parent_inode_id
714            .map(|_| {
715                DisplayName::parse(&resolved.display_name).map_err(|error| {
716                    CoreError::NamespaceCorrupt(format!(
717                        "stored display name for inode `{}` is invalid: {error}",
718                        resolved.inode_id
719                    ))
720                })
721            })
722            .transpose()?;
723        Ok(AuthoritativePathEntry {
724            namespace_id: self.namespace_id.clone(),
725            absolute_path,
726            inode_id: resolved.inode_id,
727            inode_kind: resolved.inode_kind,
728            head_seq: self.head.seq,
729            parent_inode_id: resolved.parent_inode_id,
730            display_name,
731            revision_no: revision.as_ref().map(|revision| revision.revision_no),
732            size_bytes,
733            content_ref,
734            committed_at_ms: revision.as_ref().map(|revision| revision.committed_at_ms),
735        })
736    }
737
738    async fn build_authoritative_path_entry_from_visible_child(
739        &self,
740        session: &mut MetadataViewSession<'_, '_, S>,
741        resolved_dir: &ResolvedVisiblePath,
742        child: VisibleChildEntry,
743    ) -> Result<AuthoritativePathEntry> {
744        let child_path = AbsolutePath::parse(&resolved_dir.absolute_path)
745            .map_err(map_path_error_to_core)?
746            .join(&child.binding.display_name);
747        self.build_authoritative_path_entry_with_session(
748            session,
749            &ResolvedVisiblePath {
750                absolute_path: child_path.as_str().to_owned(),
751                inode_id: child.binding.child_inode_id,
752                inode_kind: child.inode.inode_kind,
753                parent_inode_id: Some(child.binding.parent_inode_id),
754                display_name: child.binding.display_name.to_string(),
755            },
756        )
757        .await
758    }
759
760    async fn revision_for_inode(
761        &self,
762        inode_id: InodeId,
763        revision_no: RevisionNo,
764    ) -> Result<RevisionRecord> {
765        self.metadata_view()
766            .revision_for_inode(inode_id, revision_no)
767            .await
768    }
769
770    pub(super) fn metadata_view(&self) -> MetadataView<'_, '_, S> {
771        MetadataView::from_loaded_head(&self.head, &self.tables, self.wal_tail_rows.as_ref())
772    }
773}
774
775/// Refuses a buffered content read whose resolved size exceeds the caller's
776/// budget. The check runs on metadata, before any content fetch, so an
777/// over-limit read costs no object-store traffic and allocates nothing.
778pub(crate) fn ensure_within_read_limit(
779    size_bytes: u64,
780    max_content_bytes: Option<u64>,
781) -> Result<()> {
782    match max_content_bytes {
783        Some(max_bytes) if size_bytes > max_bytes => Err(CoreError::ContentTooLarge {
784            size_bytes,
785            max_bytes,
786        }),
787        _ => Ok(()),
788    }
789}
790
791fn validate_file_revisions_cursor(
792    cursor: &FileRevisionsPageCursor,
793    head_seq: ChangeSeq,
794    inode_id: InodeId,
795) -> Result<()> {
796    if cursor.head_seq > head_seq {
797        // Forward-only drift, the same rule as directory listing and grep:
798        // an older cursor resumes strictly after its last returned row at
799        // whatever head is loaded now; only a cursor from the future is
800        // unanswerable (`rebootstrap_required`).
801        return Err(MetadataViewError::SnapshotUnavailable {
802            requested_seq: cursor.head_seq,
803            head_seq,
804        }
805        .into());
806    }
807    if cursor.inode_id != inode_id {
808        return Err(invalid_cursor(
809            "file revisions cursor inode does not match the requested file",
810        ));
811    }
812    Ok(())
813}