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}