Skip to main content

sui_graph_store/
layout.rs

1//! On-disk layout of the graph store.
2//!
3//! ```text
4//! <root>/
5//! ├── index.redb            ← redb DB (one file). ZFS dataset tuning:
6//! │                            recordsize=16K, logbias=latency.
7//! └── blobs/
8//!     └── <kind>/           ← per-graph-kind subtree (cheap directory
9//!         │                   walk for kind-scoped GC, no key prefix
10//!         │                   shenanigans in the index).
11//!         └── <aa>/<bb>/    ← two-byte/two-byte CAS fan-out. Git-shape.
12//!             └── <full-hash>.rkyv
13//! ```
14//!
15//! The fan-out gives 256 × 256 = 65 536 leaf directories per kind,
16//! which keeps any single directory under ~16 entries until the cache
17//! exceeds ~1 M blobs per kind. That sidesteps every well-known
18//! filesystem "too many entries in one dir" cliff (ext4 dir_index, ZFS
19//! large_dnode, you name it).
20//!
21//! ZFS expectations:
22//!
23//! * `<root>` is a dedicated dataset, `recordsize=1M, compression=zstd-3,
24//!   atime=off, xattr=sa`. The 1 M record size is the right knob for sui's
25//!   distribution (small blobs fit in one allocation; the 25 MB lockfile
26//!   reads in 25 sequential records, where zstd's dictionary works best).
27//! * The redb file lives on a sibling dataset
28//!   (`<root>/../index`) tuned `recordsize=16K, logbias=latency`. Putting
29//!   them on one dataset is fine for small fleets; split if write-amp
30//!   from the redb WAL starts dominating zstd compression CPU on blobs.
31//! * `zfs snapshot <pool>/sui/blobs@<rev>` + `zfs send -R | zfs recv`
32//!   replicates the cache atomically — the substituter primitive falls
33//!   out for free.
34
35use std::fmt;
36use std::path::{Path, PathBuf};
37
38use crate::content_address::GraphHash;
39
40/// The kind of graph held under a blob. Drives the kind-subtree in the
41/// on-disk layout. Adding a kind = appending a variant + one new
42/// subdirectory; never breaks the existing layout.
43#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
44pub enum GraphKind {
45    /// A `flake.lock` parsed + follows-resolved into a typed graph.
46    /// Canonical L1 substrate for every flake operation.
47    Lockfile,
48    /// A parsed `.nix` file: rnix CST → typed AST → drop the green tree.
49    /// Identifies a single Nix expression by its source-hash + import set.
50    Ast,
51    /// A compiled NixOS / nix-darwin / home-manager module graph: typed
52    /// IR with worker/wrapper-split setters and topo order. Keyed by
53    /// (module-ast-hashes ⊕ resolved-import-set).
54    Module,
55    /// A serialized eval-cache entry: (ast-hash, env-hash) → value.
56    /// Replicates across the fleet via the substituter protocol.
57    EvalCacheEntry,
58    /// A derivation's typed graph form (pre-realisation), keyed by
59    /// drv-hash. Substituters speak this directly.
60    Derivation,
61}
62
63impl GraphKind {
64    /// Subdirectory name beneath `<root>/blobs/`. Stable identifier;
65    /// renaming would break the on-disk layout.
66    #[must_use]
67    pub fn dir_name(self) -> &'static str {
68        match self {
69            Self::Lockfile => "lockfile",
70            Self::Ast => "ast",
71            Self::Module => "module",
72            Self::EvalCacheEntry => "eval-cache",
73            Self::Derivation => "derivation",
74        }
75    }
76
77    /// Encode as a single byte for the redb key prefix. Stable; never
78    /// shift these values without a migration.
79    #[must_use]
80    pub fn tag(self) -> u8 {
81        match self {
82            Self::Lockfile => 1,
83            Self::Ast => 2,
84            Self::Module => 3,
85            Self::EvalCacheEntry => 4,
86            Self::Derivation => 5,
87        }
88    }
89
90    /// Inverse of [`Self::tag`]. Returns `None` for unknown tags,
91    /// which acts as a forward-compat shield for older binaries
92    /// reading newer indexes.
93    #[must_use]
94    pub fn from_tag(tag: u8) -> Option<Self> {
95        match tag {
96            1 => Some(Self::Lockfile),
97            2 => Some(Self::Ast),
98            3 => Some(Self::Module),
99            4 => Some(Self::EvalCacheEntry),
100            5 => Some(Self::Derivation),
101            _ => None,
102        }
103    }
104}
105
106impl fmt::Display for GraphKind {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        f.write_str(self.dir_name())
109    }
110}
111
112/// Path resolver for the on-disk layout. Cheap to construct; clone-able.
113#[derive(Debug, Clone)]
114pub struct StoreLayout {
115    root: PathBuf,
116}
117
118impl StoreLayout {
119    /// Pin a store layout at a filesystem root. The root and all
120    /// subdirectories are created on first write — read paths fail
121    /// fast with [`crate::Error::NotFound`] when a blob is missing.
122    #[must_use]
123    pub fn at(root: impl Into<PathBuf>) -> Self {
124        Self { root: root.into() }
125    }
126
127    /// Root of the store. Caller created or will create it.
128    #[must_use]
129    pub fn root(&self) -> &Path {
130        &self.root
131    }
132
133    /// Path to the redb index database file.
134    #[must_use]
135    pub fn index_db_path(&self) -> PathBuf {
136        self.root.join("index.redb")
137    }
138
139    /// Directory holding blobs for a single graph kind.
140    #[must_use]
141    pub fn blobs_dir(&self, kind: GraphKind) -> PathBuf {
142        self.root.join("blobs").join(kind.dir_name())
143    }
144
145    /// Full path of one blob (file may or may not exist).
146    #[must_use]
147    pub fn blob_path(&self, kind: GraphKind, hash: GraphHash) -> PathBuf {
148        let (aa, bb) = hash.shard_prefix();
149        self.blobs_dir(kind).join(aa).join(bb).join(format!(
150            "{}.rkyv",
151            hash.display_short(),
152        ))
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use pretty_assertions::assert_eq;
160
161    #[test]
162    fn layout_paths_compose_as_documented() {
163        let layout = StoreLayout::at("/var/lib/sui/graph-store");
164        let hash = GraphHash::of(b"layout test");
165        let (aa, bb) = hash.shard_prefix();
166        let expected = format!(
167            "/var/lib/sui/graph-store/blobs/lockfile/{}/{}/{}.rkyv",
168            aa,
169            bb,
170            hash.display_short()
171        );
172        assert_eq!(
173            layout.blob_path(GraphKind::Lockfile, hash).to_string_lossy(),
174            expected
175        );
176    }
177
178    #[test]
179    fn every_kind_tag_round_trips() {
180        for kind in [
181            GraphKind::Lockfile,
182            GraphKind::Ast,
183            GraphKind::Module,
184            GraphKind::EvalCacheEntry,
185            GraphKind::Derivation,
186        ] {
187            assert_eq!(GraphKind::from_tag(kind.tag()), Some(kind));
188        }
189    }
190
191    #[test]
192    fn unknown_tag_returns_none() {
193        assert!(GraphKind::from_tag(0).is_none());
194        assert!(GraphKind::from_tag(255).is_none());
195    }
196}