Skip to main content

syncweb_core/
allocation.rs

1use std::path::{Component, Path, PathBuf};
2
3use iroh_blobs::Hash;
4use iroh_docs::NamespaceId;
5use serde::{Deserialize, Serialize};
6
7use crate::{Result, SyncwebError};
8
9/// A configured filesystem root that can receive materialized content.
10#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
11#[non_exhaustive]
12pub struct StorageRoot {
13    pub id: String,
14    pub path: PathBuf,
15    pub min_free: u64,
16    pub enabled: bool,
17}
18
19impl StorageRoot {
20    /// Create an enabled storage root.
21    #[must_use]
22    pub fn new(id: impl Into<String>, path: impl Into<PathBuf>, min_free: u64) -> Self {
23        Self {
24            id: id.into(),
25            path: path.into(),
26            min_free,
27            enabled: true,
28        }
29    }
30
31    /// Set whether this root participates in allocation.
32    #[must_use]
33    pub const fn with_enabled(mut self, enabled: bool) -> Self {
34        self.enabled = enabled;
35        self
36    }
37}
38
39/// Capacity information for a storage root.
40///
41/// `available` is the free capacity remaining after existing reservations.
42/// A root's `min_free` is applied separately by [`allocate`].
43#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
44#[non_exhaustive]
45pub struct RootCapacity {
46    pub total: u64,
47    pub free: u64,
48    pub reserved: u64,
49    pub available: u64,
50}
51
52impl RootCapacity {
53    /// Build capacity information from filesystem totals and existing reservations.
54    #[must_use]
55    pub const fn new(total: u64, free: u64, reserved: u64) -> Self {
56        Self {
57            total,
58            free,
59            reserved,
60            available: free.saturating_sub(reserved),
61        }
62    }
63
64    /// Return the capacity that may be allocated while preserving `min_free`.
65    #[must_use]
66    pub const fn allocatable(self, min_free: u64) -> u64 {
67        self.available.saturating_sub(min_free)
68    }
69}
70
71/// A content item that may be materialized under a storage root.
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
73#[non_exhaustive]
74pub struct AllocationCandidate {
75    pub namespace: NamespaceId,
76    pub path: PathBuf,
77    pub hash: Hash,
78    pub size: u64,
79    pub peer_count: usize,
80    pub local: bool,
81}
82
83impl AllocationCandidate {
84    /// Create an allocation candidate.
85    #[must_use]
86    pub fn new(
87        namespace: NamespaceId,
88        path: impl Into<PathBuf>,
89        hash: Hash,
90        size: u64,
91        peer_count: usize,
92        local: bool,
93    ) -> Self {
94        Self {
95            namespace,
96            path: path.into(),
97            hash,
98            size,
99            peer_count,
100            local,
101        }
102    }
103}
104
105/// The root and destination selected for a candidate.
106#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
107#[non_exhaustive]
108pub struct AllocationDecision {
109    pub candidate: AllocationCandidate,
110    pub root_id: String,
111    pub destination: PathBuf,
112}
113
114/// Construct the safe materialization path for a candidate.
115///
116/// The namespace is always one path component, followed by the candidate's
117/// relative path. Absolute paths and parent-directory traversal are rejected.
118///
119/// # Errors
120///
121/// Returns an error if the candidate path is empty, absolute, or traverses a
122/// parent directory.
123pub fn materialization_path(root: &StorageRoot, candidate: &AllocationCandidate) -> Result<PathBuf> {
124    validate_relative_path(&candidate.path)?;
125    Ok(root.path.join(candidate.namespace.to_string()).join(&candidate.path))
126}
127
128/// Allocate candidates to roots using deterministic first-fit packing.
129///
130/// Candidates are considered by increasing peer count, then path, then hash.
131/// Roots are considered in the order supplied. A candidate is allocated at
132/// most once, and each allocation reserves its size for subsequent
133/// candidates on that root. Disabled roots, roots below their minimum free
134/// threshold, and roots without sufficient remaining capacity are skipped.
135///
136/// Invalid candidate paths are returned as unallocated candidates.
137#[must_use]
138pub fn allocate(
139    roots: &[(StorageRoot, RootCapacity)],
140    candidates: &[AllocationCandidate],
141) -> (Vec<AllocationDecision>, Vec<AllocationCandidate>) {
142    let mut ordered = candidates.to_vec();
143    ordered.sort_by(|left, right| {
144        left.peer_count
145            .cmp(&right.peer_count)
146            .then(left.path.cmp(&right.path))
147            .then(left.hash.cmp(&right.hash))
148            .then(left.namespace.to_string().cmp(&right.namespace.to_string()))
149    });
150
151    let mut reserved = vec![0_u64; roots.len()];
152    let mut decisions = Vec::new();
153    let mut unallocated = Vec::new();
154
155    for candidate in ordered {
156        if validate_relative_path(&candidate.path).is_err() {
157            unallocated.push(candidate);
158            continue;
159        }
160
161        let mut decision = None;
162        for ((root, capacity), reserved_for_root) in roots.iter().zip(reserved.iter_mut()) {
163            if !root.enabled {
164                continue;
165            }
166
167            let remaining = capacity.available.saturating_sub(*reserved_for_root);
168            if candidate.size > remaining.saturating_sub(root.min_free) {
169                continue;
170            }
171
172            let Ok(destination) = materialization_path(root, &candidate) else {
173                continue;
174            };
175            *reserved_for_root = reserved_for_root.saturating_add(candidate.size);
176            decision = Some(AllocationDecision {
177                candidate: candidate.clone(),
178                root_id: root.id.clone(),
179                destination,
180            });
181            break;
182        }
183
184        if let Some(selected) = decision {
185            decisions.push(selected);
186        } else {
187            unallocated.push(candidate);
188        }
189    }
190
191    (decisions, unallocated)
192}
193
194fn validate_relative_path(path: &Path) -> Result<()> {
195    if path.as_os_str().is_empty()
196        || path.is_absolute()
197        || path.components().any(|component| {
198            matches!(
199                component,
200                Component::ParentDir | Component::RootDir | Component::Prefix(_)
201            )
202        })
203    {
204        return Err(SyncwebError::InvalidConfig(format!(
205            "materialization path must be a non-empty relative path: {}",
206            path.display()
207        )));
208    }
209    Ok(())
210}
211
212#[cfg(test)]
213mod tests {
214    use std::path::PathBuf;
215
216    use iroh_blobs::Hash;
217
218    use super::{AllocationCandidate, RootCapacity, StorageRoot, allocate, materialization_path};
219
220    fn namespace(value: u8) -> iroh_docs::NamespaceId {
221        iroh_docs::NamespaceId::from([value; 32])
222    }
223
224    fn candidate(name: &str, size: u64, peers: usize, hash_byte: u8) -> AllocationCandidate {
225        AllocationCandidate::new(
226            namespace(hash_byte),
227            PathBuf::from(name),
228            Hash::from([hash_byte; 32]),
229            size,
230            peers,
231            false,
232        )
233    }
234
235    fn root(id: &str, capacity: u64) -> (StorageRoot, RootCapacity) {
236        (
237            StorageRoot::new(id, format!("/storage/{id}"), 0),
238            RootCapacity::new(capacity, capacity, 0),
239        )
240    }
241
242    #[test]
243    fn packs_candidates_across_roots() {
244        let roots = vec![root("one", 10), root("two", 5)];
245        let candidates = vec![
246            candidate("large", 6, 0, 1),
247            candidate("small", 4, 1, 2),
248            candidate("other", 5, 2, 3),
249        ];
250
251        let (decisions, unallocated) = allocate(&roots, &candidates);
252
253        assert_eq!(decisions.len(), 3);
254        assert!(unallocated.is_empty());
255        let assigned_roots = decisions
256            .iter()
257            .map(|decision| decision.root_id.as_str())
258            .collect::<Vec<_>>();
259        assert_eq!(assigned_roots, vec!["one", "one", "two"]);
260    }
261
262    #[test]
263    fn honors_min_free_and_available_capacity() {
264        let roots = vec![
265            (
266                StorageRoot::new("protected", "/protected", 4),
267                RootCapacity::new(20, 10, 2),
268            ),
269            root("available", 5),
270        ];
271        let candidates = vec![candidate("five", 5, 0, 1), candidate("six", 6, 1, 2)];
272
273        let (decisions, unallocated) = allocate(&roots, &candidates);
274
275        assert_eq!(decisions.len(), 1);
276        assert_eq!(
277            decisions.first().map(|decision| decision.root_id.as_str()),
278            Some("available")
279        );
280        assert_eq!(unallocated.len(), 1);
281        assert_eq!(
282            unallocated.first().map(|candidate| candidate.path.clone()),
283            Some(PathBuf::from("six"))
284        );
285    }
286
287    #[test]
288    fn orders_by_peers_path_and_hash() {
289        let roots = vec![root("one", 30)];
290        let candidates = vec![
291            candidate("z", 1, 2, 1),
292            candidate("b", 1, 1, 3),
293            candidate("a", 1, 1, 2),
294            candidate("same", 1, 1, 5),
295            candidate("same", 1, 1, 4),
296        ];
297
298        let (decisions, _) = allocate(&roots, &candidates);
299
300        let paths = decisions
301            .iter()
302            .map(|decision| decision.candidate.path.clone())
303            .collect::<Vec<_>>();
304        assert_eq!(
305            paths,
306            vec![
307                PathBuf::from("a"),
308                PathBuf::from("b"),
309                PathBuf::from("same"),
310                PathBuf::from("same"),
311                PathBuf::from("z")
312            ]
313        );
314        assert_eq!(
315            decisions.get(2).map(|decision| decision.candidate.hash),
316            Some(Hash::from([4; 32]))
317        );
318    }
319
320    #[test]
321    fn skips_disabled_roots() {
322        let roots = vec![
323            (
324                StorageRoot::new("disabled", "/disabled", 0).with_enabled(false),
325                RootCapacity::new(100, 100, 0),
326            ),
327            root("enabled", 1),
328        ];
329
330        let (decisions, unallocated) = allocate(&roots, &[candidate("file", 1, 0, 1)]);
331
332        assert!(unallocated.is_empty());
333        assert_eq!(
334            decisions.first().map(|decision| decision.root_id.as_str()),
335            Some("enabled")
336        );
337    }
338
339    #[test]
340    fn rejects_unsafe_materialization_paths() {
341        let root = StorageRoot::new("one", "/storage", 0);
342        let absolute = AllocationCandidate::new(
343            namespace(1),
344            PathBuf::from("/outside"),
345            Hash::from([1; 32]),
346            1,
347            0,
348            false,
349        );
350        let traversal = AllocationCandidate::new(
351            namespace(1),
352            PathBuf::from("../outside"),
353            Hash::from([2; 32]),
354            1,
355            0,
356            false,
357        );
358
359        assert!(materialization_path(&root, &absolute).is_err());
360        assert!(materialization_path(&root, &traversal).is_err());
361        let (_, unallocated) = allocate(&[(root, RootCapacity::new(10, 10, 0))], &[absolute, traversal]);
362        assert_eq!(unallocated.len(), 2);
363    }
364}