Skip to main content

loonfs_core/path/read/
current_files.rs

1//! Answering "where is this inode now?" for a batch of inode ids.
2//!
3//! A consumer that holds inode ids from an earlier enumeration asks this
4//! what became of them: whether each is still visible, which revision it
5//! carries now, and where it currently sits. Stale ids are ordinary input —
6//! a candidate generator routinely holds ids that were deleted since — so
7//! an unknown id is answered, not refused.
8
9use super::materialized_view::LoadedMetadataView;
10use crate::error::{CoreError, Result};
11use crate::metadata::MetadataViewSession;
12use loonfs_api::{AbsolutePath, InodeId, InodeKind, RevisionNo, ROOT_INODE_ID};
13use loonfs_objectstore::ObjectStore;
14use std::collections::{HashMap, HashSet};
15
16/// The most inode ids [`resolve_current_files`] answers in one call.
17///
18/// One item costs about what one row of a listing page costs, so the batch
19/// is bounded by the same number: the pagination policy's maximum page limit
20/// ([`loonfs_api::DEFAULT_MAX_PAGE_LIMIT`]).
21pub const MAX_RESOLVE_CURRENT_FILES: usize = loonfs_api::DEFAULT_MAX_PAGE_LIMIT as usize;
22
23/// What one inode looks like in the namespace's current state.
24///
25/// The shape is file-oriented but tolerant of anything an id can name: a
26/// directory answers `visible` with a path and no revision, and an id that
27/// names nothing answers not visible with nothing else filled in.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct CurrentFileState {
30    /// The inode this answer is about, echoed so batched answers stay
31    /// self-describing.
32    pub inode_id: InodeId,
33    /// Whether the inode is currently visible: it exists, no subtree
34    /// tombstone covers it or an ancestor, and its bindings still reach the
35    /// namespace root.
36    pub visible: bool,
37    /// The current revision number — `Some` only for a visible file that
38    /// has one.
39    pub current_revision_no: Option<RevisionNo>,
40    /// Where the inode currently sits — `Some` exactly when `visible`.
41    pub current_path: Option<AbsolutePath>,
42}
43
44/// Refuses an oversized batch before anything is loaded.
45///
46/// Called at the API boundary so an over-cap request costs no reads.
47pub(crate) fn ensure_resolve_batch_within_cap(requested: usize) -> Result<()> {
48    if requested > MAX_RESOLVE_CURRENT_FILES {
49        return Err(CoreError::BatchTooLarge {
50            requested,
51            max: MAX_RESOLVE_CURRENT_FILES,
52        });
53    }
54    Ok(())
55}
56
57/// Resolves every inode id against one loaded view, in input order.
58///
59/// The whole batch reads one view, so every answer describes the same
60/// namespace state. Ancestor walks are shared: the batch memoizes each
61/// ancestor directory's path the first time a walk passes through it, so
62/// files under one directory pay for that chain once — the walking cost is
63/// bounded by the number of distinct ancestors in the batch, not by the
64/// number of items.
65pub(crate) async fn resolve_current_files<S: ObjectStore + ?Sized>(
66    view: &LoadedMetadataView<'_, S>,
67    inode_ids: &[InodeId],
68) -> Result<Vec<CurrentFileState>> {
69    ensure_resolve_batch_within_cap(inode_ids.len())?;
70    let mut session = view.metadata_view().session();
71    let mut ancestor_paths = HashMap::new();
72    let mut states = Vec::with_capacity(inode_ids.len());
73    for &inode_id in inode_ids {
74        states.push(resolve_one(&mut session, &mut ancestor_paths, inode_id).await?);
75    }
76    Ok(states)
77}
78
79async fn resolve_one<S: ObjectStore + ?Sized>(
80    session: &mut MetadataViewSession<'_, '_, S>,
81    ancestor_paths: &mut HashMap<InodeId, AbsolutePath>,
82    inode_id: InodeId,
83) -> Result<CurrentFileState> {
84    let Some(inode) = session.visible_inode(inode_id).await? else {
85        return Ok(missing(inode_id));
86    };
87    let Some(current_path) = current_path(session, ancestor_paths, inode_id).await? else {
88        return Ok(missing(inode_id));
89    };
90    let current_revision_no = if inode.inode_kind == InodeKind::File {
91        session
92            .latest_revision_head_of_visible(inode_id)
93            .await?
94            .map(|revision| revision.revision_no)
95    } else {
96        None
97    };
98    Ok(CurrentFileState {
99        inode_id,
100        visible: true,
101        current_revision_no,
102        current_path: Some(current_path),
103    })
104}
105
106fn missing(inode_id: InodeId) -> CurrentFileState {
107    CurrentFileState {
108        inode_id,
109        visible: false,
110        current_revision_no: None,
111        current_path: None,
112    }
113}
114
115/// Derives the inode's current path by following parent bindings up to the
116/// root, then spelling the chain back out.
117///
118/// This is the same binding relation a path resolution walks downward, read
119/// from the other end. `None` means the chain never reaches the root — a
120/// binding cycle, or a dead end — which commit validation prevents; the walk
121/// reports it as "not visible" rather than inventing a path for state it
122/// cannot name.
123async fn current_path<S: ObjectStore + ?Sized>(
124    session: &mut MetadataViewSession<'_, '_, S>,
125    ancestor_paths: &mut HashMap<InodeId, AbsolutePath>,
126    inode_id: InodeId,
127) -> Result<Option<AbsolutePath>> {
128    let mut climbed = Vec::new();
129    let mut visited = HashSet::new();
130    let mut current = inode_id;
131    let base = loop {
132        if current == ROOT_INODE_ID {
133            break AbsolutePath::root();
134        }
135        if let Some(known) = ancestor_paths.get(&current) {
136            break known.clone();
137        }
138        if !visited.insert(current) {
139            return Ok(None);
140        }
141        let Some(binding) = session.current_parent_binding_for_child(current).await? else {
142            return Ok(None);
143        };
144        current = binding.parent_inode_id;
145        climbed.push(binding);
146    };
147
148    // `climbed` runs leaf-first; spelling it out from the known base
149    // downward names every directory passed on the way, which is what the
150    // next item under the same directory reuses.
151    let mut path = base;
152    for binding in climbed.iter().rev() {
153        path = path.join(&binding.display_name);
154        ancestor_paths.insert(binding.child_inode_id, path.clone());
155    }
156    Ok(Some(path))
157}
158
159#[cfg(test)]
160mod tests {
161    use super::{ensure_resolve_batch_within_cap, MAX_RESOLVE_CURRENT_FILES};
162    use loonfs_api::{ErrorCode, PaginationPolicy};
163
164    #[test]
165    fn the_batch_cap_is_the_pagination_maximum() {
166        assert_eq!(
167            MAX_RESOLVE_CURRENT_FILES,
168            PaginationPolicy::default().max_limit().get() as usize,
169            "the batch cap is the page limit, not a second number"
170        );
171    }
172
173    #[test]
174    fn a_batch_at_the_cap_is_accepted_and_one_past_it_names_the_cap() {
175        assert!(ensure_resolve_batch_within_cap(MAX_RESOLVE_CURRENT_FILES).is_ok());
176        let error = ensure_resolve_batch_within_cap(MAX_RESOLVE_CURRENT_FILES + 1)
177            .expect_err("one past the cap is refused");
178        assert_eq!(error.code(), ErrorCode::InvalidRequest);
179        assert!(
180            error
181                .to_string()
182                .contains(&MAX_RESOLVE_CURRENT_FILES.to_string()),
183            "the refusal should name the cap: {error}"
184        );
185    }
186}