Skip to main content

snix_castore/directoryservice/
directory_graph.rs

1#![deny(missing_docs)]
2//! Produces a graph of [Directory],
3//!
4//! Use [DirectoryGraphBuilder] with the chosen insertion order,
5//! call [DirectoryGraphBuilder::try_insert] to insert a Node.
6//! Once the whole closure has been inserted, [DirectoryGraphBuilder::build]
7//! can be called to return a [DirectoryGraph].
8//!
9//! This [DirectoryGraph] can then be drained in Root-To-Leaves or
10//! Leaves-To-Root order.
11
12use futures::{StreamExt, TryStreamExt};
13use petgraph::{
14    graph::{DiGraph, NodeIndex},
15    visit::{Bfs, DfsPostOrder, Walker},
16};
17use std::collections::HashMap;
18use tracing::{instrument, warn};
19
20use crate::directoryservice::{
21    DirectoryService,
22    order_validator::{self, LeavesToRoot, OrderValidator, RootToLeaves},
23};
24use crate::{B3Digest, Directory, Node};
25
26/// This represents a full (and validated) graph of [Directory] nodes.
27/// It can be constructed using [DirectoryGraphBuilder], and is normally used to
28/// receive in one or the other insertion order, validate, and then drain in
29/// Leaves-To-Root order.
30/// If you just want to validate an order without keeping the results,
31/// `RootToLeavesValidator` or `LeavesToRootValidator` can be used.
32#[derive(Default)]
33pub struct DirectoryGraph {
34    // A directed graph, using Directory as node weight.
35    // Edges point from parents to children.
36    graph: DiGraph<Directory, ()>,
37
38    // Points to the root.
39    root_idx: NodeIndex,
40}
41
42#[derive(PartialEq, Eq, Debug)]
43enum DrainOrder {
44    /// Start with the root.
45    /// Validates that newly received directories are already referenced from
46    /// the root via existing directories.
47    RootToLeaves,
48    /// Each directory may only refer to directories already sent previously.
49    LeavesToRoot,
50}
51
52impl DirectoryGraph {
53    /// Drains the graph, returning node weights in the chosen [DrainOrder].
54    fn drain(self, order: DrainOrder) -> impl Iterator<Item = Directory> {
55        let order = match order {
56            DrainOrder::RootToLeaves => {
57                // do a BFS traversal of the graph, starting with the root node
58                Bfs::new(&self.graph, self.root_idx)
59                    .iter(&self.graph)
60                    .collect::<Vec<_>>()
61            }
62            DrainOrder::LeavesToRoot => {
63                // do a DFS Post-Order traversal of the graph, starting with the root node
64                DfsPostOrder::new(&self.graph, self.root_idx)
65                    .iter(&self.graph)
66                    .collect::<Vec<_>>()
67            }
68        };
69
70        let (mut nodes, _edges) = self.graph.into_nodes_edges();
71        order
72            .into_iter()
73            .map(move |i| std::mem::take(&mut nodes[i.index()].weight))
74    }
75
76    /// Drains the graph in Leaves-To-Root Order.
77    #[instrument(level = "trace", skip_all)]
78    pub fn drain_leaves_to_root(self) -> impl Iterator<Item = Directory> {
79        self.drain(DrainOrder::LeavesToRoot)
80    }
81
82    /// Drains the graph in Root-To-Leaves Order.
83    #[instrument(level = "trace", skip_all)]
84    pub fn drain_root_to_leaves(self) -> impl Iterator<Item = Directory> {
85        self.drain(DrainOrder::RootToLeaves)
86    }
87
88    /// Returns the directory at the root of the graph
89    pub fn root(&self) -> &Directory {
90        self.graph
91            .node_weight(self.root_idx)
92            .expect("Snix bug: root not found")
93    }
94}
95
96/// Constructs a [DirectoryGraph] with a chosen insertion order.
97///
98/// After deciding on the insertion order (OV generic), and calling
99/// new (wants the expected root digest in the Root-To-Leaves case),
100/// different [Directory] can be passed to [Self::try_insert].
101/// A [Self::build] consumes the builder, returning a validated [DirectoryGraph],
102/// or an error.
103/// The resulting [DirectoryGraph] can be used to drain the graph in
104/// Leaves-To-Root or Root-To-Leaves order.
105pub struct DirectoryGraphBuilder<OV> {
106    /// Stores the order validator for the chosen insertion order.
107    order_validator: OV,
108
109    /// A directed graph, using Directory as node weight.
110    /// Directories are Options to allow drawing edges
111    /// to not-yet-received Directories in Root-To-Leaves order.
112    /// Edges point from parents to children.
113    graph: DiGraph<Option<Directory>, ()>,
114
115    /// A lookup table from directory digest to node index.
116    /// Used to lookup where to draw edges.
117    digest_to_node_idx: HashMap<B3Digest, NodeIndex>,
118}
119
120impl DirectoryGraphBuilder<LeavesToRoot> {
121    /// Constructs a new [DirectoryGraphBuilder] accepting directories in
122    /// Leaves-To-Root order.
123    pub fn new() -> Self {
124        Self::default()
125    }
126}
127
128impl Default for DirectoryGraphBuilder<LeavesToRoot> {
129    fn default() -> Self {
130        Self {
131            order_validator: Default::default(),
132            graph: Default::default(),
133            digest_to_node_idx: Default::default(),
134        }
135    }
136}
137
138impl DirectoryGraphBuilder<RootToLeaves> {
139    /// Constructs a new [DirectoryGraphBuilder] accepting directories in
140    /// Root-To-Leaves order.
141    /// The expected root Directory needs to be passed as an argument,
142    /// and is validated to match the one inserted on the first call to
143    /// [Self::try_insert].
144    pub fn new(root_digest: B3Digest) -> Self {
145        Self {
146            order_validator: RootToLeaves::new_with_root_digest(root_digest),
147            graph: Default::default(),
148            digest_to_node_idx: Default::default(),
149        }
150    }
151}
152
153impl<OV> DirectoryGraphBuilder<OV>
154where
155    OV: OrderValidator,
156{
157    /// Accepts a directory if previously introduced, or returns an error if it's unknown.
158    #[instrument(level = "trace", skip_all, fields(directory.digest = %directory.digest(), directory.size = directory.size()), err)]
159    pub fn try_insert(
160        &mut self,
161        directory: Directory,
162    ) -> Result<(), order_validator::OrderingError> {
163        // Validates ordering and sizes.
164        self.order_validator.try_accept(&directory)?;
165
166        // Ensure we have a NodeIndex for the directory we try to insert
167        let self_ix = *self
168            .digest_to_node_idx
169            .entry(directory.digest())
170            .or_insert_with(|| self.graph.add_node(None));
171
172        // If the directory is already in the graph, we don't actually need to pass it by the validator.
173        // The order validator already complained about receiving multiple times,
174        // so we don't debug!() here again.
175        if self.graph[self_ix].is_some() {
176            return Ok(());
177        }
178
179        // Everything below happens only once for each Directory.
180
181        // draw edges.
182        for (_, node) in directory.nodes() {
183            let Node::Directory {
184                digest: refereced_digest,
185                ..
186            } = node
187            else {
188                continue;
189            };
190
191            let referenced_ix = *self
192                .digest_to_node_idx
193                .entry(*refereced_digest)
194                .or_insert_with(|| {
195                    // NOTE: this only needs to ever populate a None in the Root-To-Leaves case,
196                    // but we can rely on the order validator to reject this.
197                    self.graph.add_node(None)
198                });
199
200            self.graph.add_edge(self_ix, referenced_ix, ());
201        }
202
203        // Insert node into the graph.
204        self.graph[self_ix] = Some(directory);
205
206        Ok(())
207    }
208
209    /// Ensures there's no more directories missing, returns the validated [DirectoryGraph].
210    pub fn build(self) -> Result<DirectoryGraph, order_validator::OrderingError> {
211        self.order_validator.finalize()?;
212
213        // Construct the final graph which no longer has Option<> around Directory.
214        let graph: DiGraph<Directory, ()> = self.graph.map_owned(
215            |_ix, mut n| n.take().expect("Snix bug: no pending directories"),
216            |_ix, e| e,
217        );
218
219        // NOTE: We already know there's only one incomings, else the validator would not have validated
220        let mut incomings = graph.externals(petgraph::Incoming);
221        let root_idx = incomings
222            .next()
223            .expect("Snix bug: There must be 1 incoming external");
224        debug_assert!(
225            incomings.next().is_none(),
226            "Snix bug: There must be 1 incoming external"
227        );
228        Ok(DirectoryGraph { graph, root_idx })
229    }
230}
231
232#[cfg(feature = "compat-accept-bigger-sizes")]
233impl DirectoryGraph {
234    /// Returns a new [DirectoryGraph] for which all sizes have been recomputed.
235    /// If there's any change, it'll cause referencing Directories to also have
236    /// different digests.
237    /// Data migration code to remove size calculation introduced in cl/12216 and cl/31479.
238    pub fn with_recalculated_sizes(self) -> Self {
239        /// Traverses the [Directory], assembling a new [Directory]
240        /// while recursing for each [Node::Directory].
241        /// Inserts it to `new_closure`, then returns a [crate::Node] which
242        /// contains the (possibly updated) digest and size.
243        fn fix_sizes_recursive(
244            directory: &Directory,
245            source: &HashMap<B3Digest, crate::Directory>,
246            new_closure: &mut DirectoryGraphBuilder<LeavesToRoot>,
247        ) -> crate::Node {
248            let new_dir = Directory::try_from_iter(directory.nodes().map(|(path, node)| {
249                (
250                    path.to_owned(),
251                    if let Node::Directory {
252                        digest,
253                        size: _size,
254                    } = node
255                    {
256                        fix_sizes_recursive(
257                            source
258                                .get(digest)
259                                .expect("Snix bug: digest not found in source"),
260                            source,
261                            new_closure,
262                        )
263                    } else {
264                        node.to_owned()
265                    },
266                )
267            }))
268            .expect("Snix bug: constructed invalid directory");
269
270            let new_digest = new_dir.digest();
271            let new_size = new_dir.size();
272
273            new_closure
274                .try_insert(new_dir)
275                .expect("Snix bug: rewriting produced invalid closure");
276
277            crate::Node::Directory {
278                digest: new_digest,
279                size: new_size,
280            }
281        }
282
283        let root_digest = self.root().digest();
284
285        let directories = HashMap::from_iter(
286            self.drain_leaves_to_root()
287                .map(|directory| (directory.digest(), directory)),
288        );
289
290        let mut new_graph = DirectoryGraphBuilder::<LeavesToRoot>::new();
291
292        fix_sizes_recursive(
293            directories
294                .get(&root_digest)
295                .expect("Snix bug: root digest not found"),
296            &directories,
297            &mut new_graph,
298        );
299
300        new_graph
301            .build()
302            .expect("Snix bug: rewriting produced invalid closure")
303    }
304}
305
306/// Extension trait to get a [DirectoryGraph] from a [DirectoryService], and insert into it.
307#[tonic::async_trait]
308pub trait DirectoryServiceGraphExt {
309    /// Queries the [DirectoryService] for the [DirectoryGraph] with the given root digest.
310    async fn get_directory_graph(
311        &self,
312        digest: &B3Digest,
313    ) -> Result<Option<DirectoryGraph>, super::Error>;
314
315    /// Inserts the given [DirectoryGraph] into the [DirectoryService].
316    async fn put_directory_graph(
317        &self,
318        directory_graph: DirectoryGraph,
319    ) -> Result<B3Digest, super::Error>;
320
321    // FUTUREWORK: get_recursive_validated to get a stream wrapped with order validator?
322}
323
324#[tonic::async_trait]
325impl<T> DirectoryServiceGraphExt for T
326where
327    T: DirectoryService,
328{
329    /// Queries the DirectoryService for the directory graph with the given root digest.
330    async fn get_directory_graph(
331        &self,
332        digest: &B3Digest,
333    ) -> Result<Option<DirectoryGraph>, super::Error> {
334        let mut builder = DirectoryGraphBuilder::<RootToLeaves>::new(digest.to_owned());
335        let mut directories = std::pin::pin!(self.get_recursive(digest).peekable());
336
337        if directories.as_mut().peek().await.is_none() {
338            return Ok(None);
339        }
340
341        while let Some(directory) = directories.try_next().await? {
342            builder.try_insert(directory)?;
343        }
344
345        Ok(Some(builder.build()?))
346    }
347
348    /// Inserts the given [DirectoryGraph] into the service.
349    async fn put_directory_graph(
350        &self,
351        directory_graph: DirectoryGraph,
352    ) -> Result<B3Digest, super::Error> {
353        let mut putter = self.put_multiple_start();
354
355        for directory in directory_graph.drain_leaves_to_root() {
356            putter.put(directory).await?;
357        }
358
359        Ok(putter.close().await?)
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use crate::directoryservice::directory_graph::DirectoryGraphBuilder;
366    use crate::directoryservice::order_validator::{LeavesToRoot, OrderValidator, RootToLeaves};
367    use crate::fixtures::{DIRECTORY_A, DIRECTORY_B, DIRECTORY_C};
368    use crate::{Directory, Node};
369    use rstest::rstest;
370    use std::sync::LazyLock;
371
372    pub static BROKEN_PARENT_DIRECTORY: LazyLock<Directory> = LazyLock::new(|| {
373        Directory::try_from_iter([(
374            "foo".try_into().unwrap(),
375            Node::Directory {
376                digest: DIRECTORY_A.digest(),
377                size: DIRECTORY_A.size() + 42, // wrong!
378            },
379        )])
380        .unwrap()
381    });
382
383    #[rstest]
384    /// Uploading no directories at all should fail, the empty graph is invalid.
385    #[case::ltr_empty_graph(DirectoryGraphBuilder::<LeavesToRoot>::new(), &[], false, None)]
386    /// Uploading an empty directory should succeed.
387    #[case::ltr_empty_directory(DirectoryGraphBuilder::<LeavesToRoot>::new(), &[&*DIRECTORY_A], false, Some(vec![&*DIRECTORY_A]))]
388    /// Uploading A, then B (referring to A) should succeed.
389    #[case::ltr_simple_closure(DirectoryGraphBuilder::<LeavesToRoot>::new(), &[&*DIRECTORY_A, &*DIRECTORY_B], false, Some(vec![&*DIRECTORY_A, &*DIRECTORY_B]))]
390    /// Uploading A, then A, then C (referring to A twice) should succeed.
391    /// We pretend to be a dumb client not deduping directories.
392    #[case::ltr_same_child(DirectoryGraphBuilder::<LeavesToRoot>::new(), &[&*DIRECTORY_A, &*DIRECTORY_A, &*DIRECTORY_C], false, Some(vec![&*DIRECTORY_A, &*DIRECTORY_C]))]
393    /// Uploading A, then C (referring to A twice) should succeed.
394    #[case::ltr_same_child_dedup(DirectoryGraphBuilder::<LeavesToRoot>::new(), &[&*DIRECTORY_A, &*DIRECTORY_C], false, Some(vec![&*DIRECTORY_A, &*DIRECTORY_C]))]
395    /// Uploading A, then C (referring to A twice), then B (itself referring to A) should fail during close,
396    /// as B itself would be left unconnected.
397    #[case::ltr_unconnected_node(DirectoryGraphBuilder::<LeavesToRoot>::new(), &[&*DIRECTORY_A, &*DIRECTORY_C, &*DIRECTORY_B], false, None)]
398    /// Uploading B (referring to A) should fail immediately, because A was never uploaded.
399    #[case::ltr_dangling_pointer(DirectoryGraphBuilder::<LeavesToRoot>::new(), &[&*DIRECTORY_B], true, None)]
400    /// Uploading a directory which refers to another Directory with a wrong size should fail.
401    #[case::ltr_wrong_size_in_parent(DirectoryGraphBuilder::<LeavesToRoot>::new(), &[&*DIRECTORY_A, &*BROKEN_PARENT_DIRECTORY], true, None)]
402
403    /// Downloading an empty directory should succeed.
404    #[case::rtl_empty_directory(DirectoryGraphBuilder::<RootToLeaves>::new(DIRECTORY_A.digest()), &[&*DIRECTORY_A], false, Some(vec![&*DIRECTORY_A]))]
405    /// Downlading B, then A (referenced by B) should succeed.
406    #[case::rtl_simple_closure(DirectoryGraphBuilder::<RootToLeaves>::new(DIRECTORY_B.digest()), &[&*DIRECTORY_B, &*DIRECTORY_A], false, Some(vec![&*DIRECTORY_A, &*DIRECTORY_B]))]
407    /// Downloading C (referring to A twice), then A should succeed.
408    #[case::rtl_same_child_dedup(DirectoryGraphBuilder::<RootToLeaves>::new(DIRECTORY_C.digest()), &[&*DIRECTORY_C, &*DIRECTORY_A], false, Some(vec![&*DIRECTORY_A, &*DIRECTORY_C]))]
409    /// Downloading C, then B (both referring to A but not referring to each other) should fail immediately as B has no connection to C (the root)
410    #[case::rtl_unconnected_node(DirectoryGraphBuilder::<RootToLeaves>::new(DIRECTORY_C.digest()), &[&*DIRECTORY_C, &*DIRECTORY_B], true, None)]
411    /// Downloading a directory which refers to another Directory with a wrong size should fail.
412    #[case::rtl_wrong_size_in_parent(DirectoryGraphBuilder::<RootToLeaves>::new(BROKEN_PARENT_DIRECTORY.digest()), &[&*BROKEN_PARENT_DIRECTORY, &*DIRECTORY_A], true, None)]
413    fn directory_graph(
414        #[case] mut builder: DirectoryGraphBuilder<impl OrderValidator>,
415        #[case] directories_to_upload: &[&Directory],
416        #[case] exp_fail_upload_last: bool,
417        #[case] exp_build: Option<Vec<&Directory>>, // Some(_) if finalize successful, None if not.
418    ) {
419        let mut it = directories_to_upload.iter().peekable();
420        while let Some(d) = it.next() {
421            if it.peek().is_none() /* is last */ && exp_fail_upload_last {
422                builder
423                    .try_insert((*d).to_owned())
424                    .expect_err("last insert to fail");
425            } else {
426                builder
427                    .try_insert((*d).to_owned())
428                    .expect("insert to succeed");
429            }
430        }
431
432        if exp_fail_upload_last {
433            return;
434        }
435
436        if let Some(exp_drain_ltr) = exp_build {
437            let directory_graph = builder.build().expect("build to succeed");
438
439            // drain
440            let drained_ltr = directory_graph.drain_leaves_to_root().collect::<Vec<_>>();
441
442            assert_eq!(
443                exp_drain_ltr
444                    .iter()
445                    .map(|d| (*d).to_owned())
446                    .collect::<Vec<_>>(),
447                drained_ltr
448            );
449        } else {
450            assert!(builder.build().is_err(), "expected build to fail");
451        }
452    }
453
454    #[test]
455    /// Inserting a first directory into [DirectoryGraphBuilder] that has a
456    /// different digest than what was specified in `new_root_to_leaves` should fail.
457    fn rtl_wrong_digest() {
458        let mut builder = DirectoryGraphBuilder::<RootToLeaves>::new(DIRECTORY_B.digest());
459        builder
460            .try_insert(DIRECTORY_A.clone())
461            .expect_err("expect insert of root with wrong digest to fail");
462    }
463}