kismet_cache/lib.rs
1//! Kismet implements multiprocess lock-free[^lock-free-fs]
2//! crash-safe and (roughly) bounded persistent caches stored
3//! in filesystem directories, with a
4//! [Second Chance](https://en.wikipedia.org/wiki/Page_replacement_algorithm#Second-chance)
5//! eviction strategy. The maintenance logic is batched and invoked
6//! at periodic jittered intervals to make sure accesses amortise to a
7//! constant number of filesystem system calls and logarithmic (in the
8//! number of cached file) time complexity. That's good for performance,
9//! and enables lock-freedom,[^unlike-ccache] but does mean that
10//! caches are expected to temporarily grow past their capacity
11//! limits, although rarely by more than a factor of 2 or 3.
12//!
13//! [^lock-free-fs]: Inasmuch as anything that makes a lot of syscalls
14//! can be "lock-free." The cache access algorithms implement a
15//! protocol that makes a bounded number of file open, rename, or link
16//! syscalls; in other words, reads and writes are as wait-free as
17//! these syscalls. The batched second-chance maintenance algorithm,
18//! on the other hand, is merely lock-free: it could theoretically get
19//! stuck if writers kept adding new files to a cache (sub)directory.
20//! Again, this guarantee is in terms of file access and directory
21//! enumeration syscalls, and maintenance is only as lock-free as the
22//! underlying syscalls. However, we can assume the kernel is
23//! reasonably well designed, and doesn't let any sequence of syscalls
24//! keep its hand on kernel locks forever.
25//!
26//! [^unlike-ccache]: This design choice is different from, e.g.,
27//! [ccache](https://ccache.dev/)'s, which attempts to maintain
28//! statistics per shard with locked files. Under high load, the lock
29//! to update ccache statistics becomes a bottleneck. Yet, despite
30//! taking this hit, ccache batches evictions like Kismet, because
31//! cleaning up a directory is slow; up-to-date access statistics
32//! aren't enough to enforce tight cache capacity limits.
33//!
34//! In addition to constant per-cache space overhead, each Kismet
35//! cache maintains a variable-length [`std::path::PathBuf`] for the
36//! directory, one byte of lock-free metadata per shard, and no other
37//! non-heap resource (i.e., Kismet caches do not hold on to file
38//! objects). This holds for individual cache directories; when
39//! stacking multiple caches in a [`Cache`] or [`ReadOnlyCache`], the
40//! read-write cache and all constituent read-only caches will each
41//! have their own `PathBuf` and per-shard metadata.
42//!
43//! When a Kismet cache triggers second chance evictions, it will
44//! allocate temporary data. That data's size is proportional to the
45//! number of files in the cache shard subdirectory undergoing
46//! eviction (or the whole directory for a plain unsharded cache), and
47//! includes a copy of the basename (without the path prefix) for each
48//! cached file in the subdirectory (or plain cache directory). This
49//! eviction process is linearithmic-time in the number of files in
50//! the cache subdirectory (directory), and is invoked periodically,
51//! so as to amortise the maintenance overhead to logarithmic (in the
52//! total number of files in the subdirectory) time per write to a
53//! cache subdirectory, and constant file operations per write.
54//!
55//! Kismet does not pre-allocate any long-lived file object, so it may
56//! need to temporarily open file objects. Each call nevertheless
57//! bounds the number of concurrently allocated file objects; the
58//! current logic never allocates more than two concurrent file
59//! objects.
60//!
61//! The load (number of files) in each cache may exceed the cache's
62//! capacity because there is no centralised accounting, except for
63//! what filesystems provide natively. This design choice forces
64//! Kismet to amortise maintenance calls with randomisation, but also
65//! means that any number of threads or processes may safely access
66//! the same cache directories without any explicit synchronisation.
67//!
68//! Filesystems can't be trusted to provide much; Kismet only relies
69//! on file modification times (`mtime`), and on file access times
70//! (`atime`) that are either less than or equal to the `mtime`, or
71//! greater than the `mtime` (i.e., `relatime` is acceptable). This
72//! implies that cached files should not be linked in multiple Kismet
73//! cache directories. It is however safe to hardlink cached files in
74//! multiple places, as long as the files are not modified, or their
75//! `mtime` otherwise updated, through these non-Kismet links.
76//!
77//! # Plain and sharded caches
78//!
79//! Kismet cache directories are plain (unsharded) or sharded.
80//!
81//! Plain Kismet caches are simply directories where the cache entry for
82//! "key" is the file named "key." These are most effective for
83//! read-only access to cache directories managed by some other
84//! process, or for small caches of up to ~100 cached files.
85//!
86//! Sharded caches scale to higher capacities, by indexing into one of
87//! a constant number of shard subdirectories with a hash, and letting
88//! each shard manage fewer files (ideally 10-100 files). They are
89//! also much less likely to grow to multiples of the target capacity
90//! than plain (unsharded) cache directories.
91//!
92//! Simple usage should be covered by the [`ReadOnlyCache`] or
93//! [`Cache`] structs, which wrap [`plain::Cache`] and
94//! [`sharded::Cache`] in a convenient type-erased interface.
95//!
96//! While the cache code syncs cached data files by default, it does
97//! not sync the parent cache directories: we assume that it's safe,
98//! if unfortunate, for caches to lose data or revert to an older
99//! state after kernel or hardware crashes. In general, the code
100//! attempts to be robust again direct manipulation of the cache
101//! directories. It's always safe to delete cache files from kismet
102//! directories (ideally not recently created files in `.kismet_temp`
103//! subdirectories), and even *adding* files should mostly do what one
104//! expects: they will be picked up if they're in the correct place
105//! (in a plain unsharded cache directory or in the correct shard
106//! subdirectory), and eventually evicted if useless or in the wrong
107//! shard.
108//!
109//! It is however essential to only publish files atomically to the
110//! cache directories, and it probably never makes sense to modify
111//! cached file objects in place. In fact, Kismet always set files
112//! readonly before publishing them to the cache and always returns
113//! read-only [`std::fs::File`] objects for cached data.
114//!
115//! # Sample usage
116//!
117//! One could access a list of read-only caches with a [`ReadOnlyCache`].
118//!
119//! ```no_run
120//! const NUM_SHARDS: usize = 10;
121//!
122//! let read_only = kismet_cache::ReadOnlyCacheBuilder::new()
123//! .plain("/tmp/plain_cache") // Read first here
124//! .sharded("/tmp/sharded_cache", NUM_SHARDS) // Then try there.
125//! .take()
126//! .build();
127//!
128//! // Attempt to read the file for key "foo", with primary hash 1
129//! // and second hash 2, first from `/tmp/plain.cache`, and then
130//! // from `/tmp/sharded_cache`. In practice, the hashes should
131//! // probably be populated with by implementing the `From<&'a T>`
132//! // trait, and passing a `&T` to the cache methods.
133//! read_only.get(kismet_cache::Key::new("foo", 1, 2));
134//! ```
135//!
136//! Read-write accesses should use a [`Cache`]:
137//!
138//! ```no_run
139//! struct CacheKey {
140//! // ...
141//! }
142//!
143//! fn get_contents(key: &CacheKey) -> Vec<u8> {
144//! // ...
145//! # unreachable!()
146//! }
147//!
148//! impl<'a> From<&'a CacheKey> for kismet_cache::Key<'a> {
149//! fn from(key: &CacheKey) -> kismet_cache::Key {
150//! // ...
151//! # unreachable!()
152//! }
153//! }
154//!
155//!
156//! // It's easier to increase the capacity than the number of shards,
157//! // so, when in doubt, prefer a few too many shards with a lower
158//! // capacity. It's not incorrect to increase the number of shards,
159//! // but will result in lost cached data (eventually deleted), since
160//! // Kismet does not assign shards with a consistent hash.
161//! const NUM_SHARDS: usize = 100;
162//! const CAPACITY: usize = 1000;
163//!
164//! # fn main() -> std::io::Result<()> {
165//! use std::io::Read;
166//! use std::io::Write;
167//!
168//! let cache = kismet_cache::CacheBuilder::new()
169//! .sharded_writer("/tmp/root_cache", NUM_SHARDS, CAPACITY)
170//! .plain_reader("/tmp/extra_cache") // Try to fill cache misses here
171//! .take()
172//! .build();
173//!
174//! let key: CacheKey = // ...
175//! # CacheKey {}
176//! ;
177//!
178//! // Fetches the current cached value for `key`, or populates it with
179//! // the closure argument if missing.
180//! let mut cached_file = cache
181//! .ensure(&key, |file| file.write_all(&get_contents(&key)))?;
182//! let mut contents = Vec::new();
183//! cached_file.read_to_end(&mut contents)?;
184//! # Ok(())
185//! # }
186//! ```
187//!
188//! # Cache directory structure
189//!
190//! Plain (unsharded) cache directories simply store the value for
191//! each `key` under a file named `key`. They also have a single
192//! `.kismet_temp` subdirectory, for temporary files.
193//!
194//! The second chance algorithm relies on mtime / atime (`relatime`
195//! updates suffice), so merely opening a file automatically updates
196//! the relevant read tracking metadata.
197//!
198//! Sharded cache directories store the values for each `key` under
199//! one of two shard subdirectories. The primary and second potential
200//! shards are respectively determined by multiplying `Key::hash` and
201//! `Key::secondary_hash` by different odd integers before mapping the
202//! result to the shard universe with a fixed point scaling.
203//!
204//! Each subdirectory is named `.kismet_$HEX_SHARD_ID`, and contains
205//! cached files with name equal to the cache key, and a
206//! `.kismet_temp` subsubdirectory, just like plain unsharded caches.
207//! In fact, each such shard is managed exactly like a plain cache.
208//!
209//! Sharded caches attempt to balance load between two potential
210//! shards for each cache key in an attempt to make all shards grow at
211//! roughly the same rate. Once all the shards have reached their
212//! capacity, the sharded cache will slowly revert to storing cache
213//! keys in their primary shards.
214//!
215//! This scheme lets plain cache directories easily interoperate with
216//! other programs that are not aware of Kismet, and also lets an
217//! application use the same directory to back both a plain and a
218//! sharded cache (concurrently or not) without any possibility of
219//! collision between cached files and Kismet's internal directories.
220//!
221//! Kismet will always store its internal data in files or directories
222//! start start with a `.kismet` prefix, and cached data lives in
223//! files with names equal to their keys. Since Kismet sanitises
224//! cache keys to forbid them from starting with `.`, `/`, or `\`, it
225//! is always safe for an application to store additional data in
226//! files or directories that start with a `.`, as long as they do not
227//! collide with the `.kismet` prefix.
228mod cache_dir;
229mod multiplicative_hash;
230pub mod plain;
231pub mod raw_cache;
232mod readonly;
233pub mod second_chance;
234pub mod sharded;
235mod stack;
236mod trigger;
237
238pub use readonly::ReadOnlyCache;
239pub use readonly::ReadOnlyCacheBuilder;
240pub use stack::Cache;
241pub use stack::CacheBuilder;
242pub use stack::CacheHit;
243pub use stack::CacheHitAction;
244
245use std::fs::File;
246use std::io::Result;
247
248/// Kismet cache directories put temporary files under this
249/// subdirectory in each cache or cache shard directory.
250pub const KISMET_TEMPORARY_SUBDIRECTORY: &str = ".kismet_temp";
251
252/// Cache keys consist of a filename and two hash values. The two
253/// hashes should ideally be computed by distinct functions of the
254/// key's name, but Kismet will function correctly if the `hash` and
255/// `secondary_hash` are the same. Each hash function **must** be
256/// identical for all processes that access the same sharded cache
257/// directory.
258///
259/// The `name` should not be empty nor start with a dot, forward
260/// slash, a backslash: caches will reject any operation on such names
261/// with an `ErrorKind::InvalidInput` error.
262#[derive(Clone, Copy, Debug)]
263pub struct Key<'a> {
264 pub name: &'a str,
265 pub hash: u64,
266 pub secondary_hash: u64,
267}
268
269impl<'a> Key<'a> {
270 /// Returns a new `Key` for this `name`, `hash`, and `secondary_hash`.
271 pub fn new(name: &str, hash: u64, secondary_hash: u64) -> Key {
272 Key {
273 name,
274 hash,
275 secondary_hash,
276 }
277 }
278}
279
280/// Compares the contents of two files, and returns `Ok(())` iff their
281/// contents are identical.
282pub fn byte_equality_checker(x: &mut File, y: &mut File) -> Result<()> {
283 use std::io::Read;
284
285 let mut x_contents = Vec::new();
286 let mut y_contents = Vec::new();
287
288 x.read_to_end(&mut x_contents)?;
289 y.read_to_end(&mut y_contents)?;
290
291 if x_contents == y_contents {
292 Ok(())
293 } else {
294 Err(std::io::Error::new(std::io::ErrorKind::Other, "mismatch"))
295 }
296}
297
298/// Compares the contents of two files, and panics unless their
299/// contents are identical. Returns `Ok(())` on equality.
300pub fn panicking_byte_equality_checker(x: &mut File, y: &mut File) -> Result<()> {
301 byte_equality_checker(x, y).expect("file contents do not match");
302 Ok(())
303}
304
305#[test]
306fn test_no_panic() {
307 use test_dir::{DirBuilder, FileType, TestDir};
308
309 let temp = TestDir::temp()
310 .create("1", FileType::ZeroFile(1))
311 .create("2", FileType::ZeroFile(1));
312
313 let mut x = File::open(temp.path("1")).expect("open should succeed");
314 let mut y = File::open(temp.path("2")).expect("open should succeed");
315
316 panicking_byte_equality_checker(&mut x, &mut y).expect("should succeed");
317}
318
319#[test]
320#[should_panic(expected = "file contents do not match")]
321fn test_panic() {
322 use test_dir::{DirBuilder, FileType, TestDir};
323
324 let temp = TestDir::temp()
325 .create("1", FileType::ZeroFile(1))
326 .create("2", FileType::ZeroFile(2));
327
328 let mut x = File::open(temp.path("1")).expect("open should succeed");
329 let mut y = File::open(temp.path("2")).expect("open should succeed");
330
331 panicking_byte_equality_checker(&mut x, &mut y).expect("should panic instead of erroring");
332}