file_backed/lib.rs
1use std::{
2 ops::{Deref, DerefMut},
3 sync::Arc,
4};
5
6use consume_on_drop::{Consume, ConsumeOnDrop};
7use cutoff_list::CutoffList;
8use parking_lot::RwLock;
9use tokio::{
10 sync::{RwLockMappedWriteGuard, RwLockReadGuard},
11 task::JoinHandle,
12};
13
14use uuid::Uuid;
15
16mod entries;
17
18pub mod backing_store;
19pub mod convenience;
20#[cfg(feature = "fbstore")]
21pub mod fbstore;
22#[cfg(feature = "redbstore")]
23pub mod redbstore;
24
25use self::backing_store::{Strategy, TrackedPath};
26use self::entries::{FullEntry, LimitedEntry};
27
28pub use self::backing_store::{BackingStore, BackingStoreT};
29
30/// A handle to data managed by an `FBPool`.
31///
32/// This acts like a `Box` but for potentially large data (`T`) that might
33/// reside either in memory (in an LRU cache) or on disk (in the backing store) or
34/// both.
35/// Accessing the underlying data requires calling one of the `load` methods.
36///
37/// When dropped:
38/// - If the item was never persisted or written to the backing store's temporary
39/// location, it is simply dropped.
40/// - If the item exists in the backing store's temporary location, a background
41/// task is spawned via the `BackingStore` to delete it using `BackingStoreT::delete`.
42// Field ordering is important to remove entries from the cutoff list when
43// the last reference to the entry is dropped
44pub struct Fb<T, B: BackingStoreT> {
45 entry: FullEntry<T, B>,
46 inner: FbInner<T, B>,
47}
48
49struct FbInner<T, B: BackingStoreT> {
50 index: cutoff_list::Index,
51 pool: Arc<FBPool<T, B>>,
52}
53
54impl<T, B: BackingStoreT> Drop for FbInner<T, B> {
55 fn drop(&mut self) {
56 let mut write_guard = self.pool.entries.write();
57 write_guard.remove(self.index).unwrap();
58 }
59}
60
61/// Manages a pool of `Fb` instances, backed by an in-memory cache
62/// and a `BackingStore` for disk persistence.
63///
64/// ## Caching Strategy (Segmented LRU Variant)
65///
66/// The pool utilizes a variation of an LRU (Least Recently Used) cache designed
67/// to reduce overhead for frequently accessed items. The cache is conceptually
68/// divided into two segments (e.g., a "front half" and a "back half", typically
69/// split evenly based on the total `mem_size`).
70///
71/// - **Promotion:** Items loaded from the backing store (`Strategy::load`) or
72/// accessed while residing in the "back half" of the cache are promoted
73/// to the most recently used position (the front of the "front half").
74/// - **No Movement:** Items accessed while *already* in the "front half" do
75/// **not** change position. This avoids the overhead of cache entry shuffling
76/// for frequently hit "hot" items that are already near the front.
77/// - **Insertion:** New items inserted via [`FBPool::insert`] also enter the
78/// front of the "front half".
79/// - **Eviction:** Items are eventually evicted from the least recently used
80/// end of the "back half" when the cache is full.
81///
82/// This approach aims to provide LRU-like behavior while optimizing for workloads
83/// where a subset of items is accessed very frequently.
84///
85/// Internally, we store each item in an `Option<T>`. When an item is evicted from cache,
86/// we will replace `Some(val)` with None. Note that the size of `None::<T>`` on the
87/// stack is the exact same as that of Some(val). What this means is that if T's resources
88/// are primarily represented by its space on the stack (e.g. when `T` is `[f32; 4096]`),
89/// there will be zero savings when it's removed from cache. In these cases, you should
90/// use `Box<T>` instead.
91pub struct FBPool<T, B: BackingStoreT> {
92 entries: RwLock<CutoffList<LimitedEntry<T, B>>>,
93 store: Arc<BackingStore<B>>,
94}
95
96impl<T, B: BackingStoreT> FBPool<T, B> {
97 /// Creates a new pool managing items of type `T`.
98 ///
99 /// # Arguments
100 /// * `store` - The configured `BackingStore` manager.
101 /// * `mem_size` - The maximum number of items to keep loaded in the in-memory cache.
102 /// This size is divided internally (50/50) to implement the
103 /// two-segment caching strategy (see main [`FBPool`] documentation for details).
104 pub fn new(store: Arc<BackingStore<B>>, mem_size: usize) -> Self {
105 let entries = RwLock::new(CutoffList::new(vec![mem_size / 2, mem_size]));
106 Self { entries, store }
107 }
108
109 /// Returns a reference to the underlying `BackingStore`.
110 pub fn store(&self) -> &Arc<BackingStore<B>> {
111 &self.store
112 }
113
114 /// Inserts new `data` into the pool, returning an `Fb` handle.
115 ///
116 /// The data is initially placed only in the in-memory LRU cache. It will only be
117 /// written to the backing store's temporary location if it's evicted from the cache
118 /// or explicitly written via`persist`/`blocking_persist`/`spawn_write_now`/`blocking_write_now`.
119 ///
120 /// Whenever the data is evicted from memory, after being written to the backing store
121 /// with `B::store`, the data will be dropped normally, which means if there's a custom `Drop`
122 /// implementation, it will be called. Each time the data is loaded back into memory, this
123 /// could happen again if the data is evicted again.
124 pub fn insert(self: &Arc<Self>, data: T) -> Fb<T, B>
125 where
126 T: Send + Sync + 'static,
127 B: Strategy<T>,
128 {
129 let entry = FullEntry::new(data);
130 let mut guard = self.entries.write();
131 let index = guard.insert_first(entry.limited());
132 let dump_entry = guard.get(guard.index_following_qth_cutoff(1));
133 if let Some(entry) = dump_entry {
134 entry.try_dump_to_disk(&self.store);
135 }
136 drop(guard);
137 Fb {
138 entry,
139 inner: FbInner {
140 index,
141 pool: Arc::clone(self),
142 },
143 }
144 }
145
146 /// Asynchronously registers an existing item from a persistent path into the pool.
147 ///
148 /// Creates an `Fb` handle for an item identified by `key` located at the
149 /// tracked persistent `path`. This typically involves calling `BackingStoreT::register`
150 /// (e.g., hard-linking the file into the managed temporary area).
151 ///
152 /// The item data is *not* loaded into memory by this call.
153 /// Returns `None` if the registration fails (e.g., the underlying store fails to find the key).
154 pub async fn register(
155 self: &Arc<Self>,
156 path: &Arc<TrackedPath<B::PersistPath>>,
157 key: Uuid,
158 ) -> Option<Fb<T, B>>
159 where
160 T: Send + Sync + 'static,
161 {
162 let entry = FullEntry::register(key, &self.store, path).await?;
163 let index = self.entries.write().insert_last(entry.limited());
164 Some(Fb {
165 entry,
166 inner: FbInner {
167 index,
168 pool: Arc::clone(self),
169 },
170 })
171 }
172
173 /// Blocking version of `register`. Waits for the registration to complete.
174 /// Must not be called from an async context that isn't allowed to block.
175 pub fn blocking_register(
176 self: &Arc<Self>,
177 path: &TrackedPath<B::PersistPath>,
178 key: Uuid,
179 ) -> Option<Fb<T, B>> {
180 let entry = FullEntry::blocking_register(key, &self.store, path)?;
181 let index = self.entries.write().insert_last(entry.limited());
182 Some(Fb {
183 entry,
184 inner: FbInner {
185 index,
186 pool: Arc::clone(self),
187 },
188 })
189 }
190
191 /// Returns the current number of items managed by the pool (both in memory and on disk).
192 pub fn size(&self) -> usize {
193 self.entries.read().len()
194 }
195}
196
197impl<T, B: BackingStoreT> Fb<T, B> {
198 /// Returns the unique identifier (`Uuid`) for the data associated with this handle.
199 /// This key will change if the data is mutated via `try_load_mut` or `make_mut`.
200 pub fn key(&self) -> Uuid {
201 self.entry.key()
202 }
203
204 /// Returns a reference to the `FBPool` this `Fb` belongs to.
205 pub fn pool(&self) -> &Arc<FBPool<T, B>> {
206 &self.inner.pool
207 }
208}
209
210impl<T: Send + Sync + 'static, B: Strategy<T>> Fb<T, B> {
211 /// Asynchronously loads the data and returns a read guard.
212 ///
213 /// Returns a `Future` that resolves to a `ReadGuard` once the data is available
214 /// in memory (either immediately or after loading from the backing store).
215 /// Suitable for use within `async` functions and tasks.
216 pub async fn load(&self) -> ReadGuard<'_, T, B> {
217 // We do this _before_ loading the backing value so that if the caller
218 // cancels the operation, we don't waste the work done to load it by
219 // immediately dumping it back to disk.
220 shift_forward(&self.inner.pool, self.inner.index);
221 // Construct before loading so that if cancelled, the object will be dumped
222 // if necessary (possible if a lot of things are loaded simultaneously).
223 let on_drop = GuardDropper::new(&self.inner.pool, self.inner.index);
224 let data_guard = self.entry.load(&self.inner.pool.store).await;
225 ReadGuard {
226 data_guard,
227 _on_drop: on_drop,
228 }
229 }
230
231 /// Attempts to load the data and return a read guard, returning None if the data is not
232 /// already in memory or is currently being evicted.
233 /// The entry will only be potentially shifted in the LRU cache on success.
234 pub fn try_load(&self) -> Option<ReadGuard<'_, T, B>> {
235 let guard = self.entry.try_load()?;
236 shift_forward(&self.inner.pool, self.inner.index);
237 let on_drop = GuardDropper::new(&self.inner.pool, self.inner.index);
238 Some(ReadGuard {
239 data_guard: guard,
240 _on_drop: on_drop,
241 })
242 }
243
244 /// Loads the data and returns a read guard, performing blocking I/O if necessary.
245 ///
246 /// - If the data is already in the memory cache, returns immediately.
247 /// - If the data is not in memory, it performs a blocking load operation via
248 /// `Strategy::load`.
249 ///
250 /// This method should only be called from a context where blocking is acceptable
251 /// (e.g., outside a Tokio runtime, or within `spawn_blocking` or `block_in_place`).
252 pub fn blocking_load(&self) -> ReadGuard<'_, T, B> {
253 shift_forward(&self.inner.pool, self.inner.index);
254 let on_drop = GuardDropper::new(&self.inner.pool, self.inner.index);
255 let data_guard = self.entry.blocking_load(&self.inner.pool.store);
256 ReadGuard {
257 data_guard,
258 _on_drop: on_drop,
259 }
260 }
261
262 /// Loads the data and returns a read guard for immutable access.
263 ///
264 /// - If the data is already in the memory cache, returns immediately.
265 /// - If the data is not in memory, it uses `tokio::task::block_in_place` to
266 /// call `blocking_load` to load it from the backing store.
267 ///
268 /// This is for the somewhat niche situation where you need to load an FBArc in a
269 /// blocking function nested many blocking calls deep within an async task running
270 /// on a tokio multithreaded runtime. Ideally you would propagate async down and use
271 /// `load` instead.
272 ///
273 /// # Panics
274 /// This method will panic if called from within a `tokio::runtime::Runtime`
275 /// created using `Runtime::new_current_thread`, as `block_in_place` is not
276 /// supported there. Use `load` instead in async contexts and
277 /// `blocking_load` in known blocking contexts.
278 pub fn load_in_place(&self) -> ReadGuard<'_, T, B> {
279 shift_forward(&self.inner.pool, self.inner.index);
280 let on_drop = GuardDropper::new(&self.inner.pool, self.inner.index);
281 let data_guard = self.entry.load_in_place(&self.inner.pool.store);
282 ReadGuard {
283 data_guard,
284 _on_drop: on_drop,
285 }
286 }
287
288 /// Asynchronously acquires mutable access to the data.
289 ///
290 /// On return:
291 /// 1. The data is ensured to be in memory.
292 /// 2. The corresponding file in the backing store's temporary location (if any) is deleted.
293 /// 3. The internal `Uuid` key for this data is changed.
294 /// 4. A `WriteGuard` providing mutable access is returned.
295 pub async fn load_mut(&mut self) -> WriteGuard<'_, T, B> {
296 // We do this _before_ loading the backing value so that if the caller
297 // cancels the operation, we don't waste the work done to load it by
298 // immediately dumping it back to disk.
299 shift_forward(&self.inner.pool, self.inner.index);
300 // Construct before loading so that if cancelled, the object will be dumped
301 // if necessary (possible if a lot of things are loaded simultaneously).
302 let on_drop = GuardDropper::new(&self.inner.pool, self.inner.index);
303 let data_guard = self.entry.load_mut(&self.inner.pool.store).await;
304 WriteGuard {
305 data_guard,
306 _on_drop: on_drop,
307 }
308 }
309
310 /// Attempts to load the data and return a write guard, returning None if the data is not
311 /// already in memory or is currently being evicted.
312 /// The entry will only be potentially shifted in the LRU cache on success.
313 pub fn try_load_mut(&mut self) -> Option<WriteGuard<'_, T, B>> {
314 let guard = self.entry.try_load_mut()?;
315 shift_forward(&self.inner.pool, self.inner.index);
316 let on_drop = GuardDropper::new(&self.inner.pool, self.inner.index);
317 Some(WriteGuard {
318 data_guard: guard,
319 _on_drop: on_drop,
320 })
321 }
322
323 /// Blocking version of `load_mut`. Waits for the operation to complete.
324 /// Must not be called from an async context that isn't allowed to block.
325 pub fn blocking_load_mut(&mut self) -> WriteGuard<'_, T, B> {
326 shift_forward(&self.inner.pool, self.inner.index);
327 let on_drop = GuardDropper::new(&self.inner.pool, self.inner.index);
328 let data_guard = self.entry.blocking_load_mut(&self.inner.pool.store);
329 WriteGuard {
330 data_guard,
331 _on_drop: on_drop,
332 }
333 }
334
335 /// Spawns a background task to immediately write the data to the backing store's
336 /// temporary location if it isn't already there.
337 ///
338 /// Acquires the read guard and then returns a `JoinHandle` that completes when the write
339 /// operation finishes.
340 pub async fn spawn_write_now(&self) -> JoinHandle<()> {
341 self.entry.spawn_write_now(&self.inner.pool.store).await
342 }
343
344 /// Performs a blocking write of the data to the backing store's temporary location
345 /// if it is isn't already there. Waits for the write to complete.
346 /// Must not be called from an async context that isn't allowed to block.
347 pub fn blocking_write_now(&self) {
348 self.entry.blocking_write_now(&self.inner.pool.store);
349 }
350
351 /// Spawns a background task to persist the data to the specified `TrackedPath`.
352 ///
353 /// This calls `BackingStoreT::persist` (typically a hard-link). If the data
354 /// is currently only in memory, it ensures it's written to the temporary
355 /// location first before attempting persistence.
356 /// If the data is already in the persistent location, this is a no-op.
357 ///
358 /// Acquires the read guard and then returns a `JoinHandle` that completes when the persistence
359 /// operation finishes.
360 pub async fn spawn_persist(&self, path: &Arc<TrackedPath<B::PersistPath>>) -> JoinHandle<()> {
361 self.entry.spawn_persist(&self.inner.pool.store, path).await
362 }
363
364 /// Performs a blocking persistence of the data to the specified `TrackedPath`.
365 /// Waits for the operation (including any preliminary writes) to complete.
366 /// Must not be called from an async context that isn't allowed to block.
367 pub fn blocking_persist(&self, path: &TrackedPath<B::PersistPath>) {
368 self.entry.blocking_persist(&self.inner.pool.store, path)
369 }
370}
371
372fn shift_forward<T: Send + Sync + 'static, B: Strategy<T>>(
373 pool: &FBPool<T, B>,
374 index: cutoff_list::Index,
375) {
376 let read_guard = pool.entries.read();
377 let preceding_cutoffs = read_guard.preceding_cutoffs(index).unwrap();
378 if preceding_cutoffs == 0 {
379 return;
380 }
381 drop(read_guard);
382 let mut write_guard = pool.entries.write();
383 let preceding_cutoffs = write_guard.preceding_cutoffs(index).unwrap();
384 if preceding_cutoffs == 0 {
385 return;
386 }
387 write_guard.shift_to_front(index);
388 if preceding_cutoffs == 1 {
389 return;
390 }
391 assert!(preceding_cutoffs == 2);
392 let read_guard = parking_lot::RwLockWriteGuard::downgrade(write_guard);
393 let dump_entry = read_guard
394 .get(read_guard.index_following_qth_cutoff(1))
395 .unwrap();
396 dump_entry.try_dump_to_disk(&pool.store);
397}
398
399/// An RAII guard providing immutable access (`Deref`) to the underlying data `T`.
400///
401/// While this guard is alive, the data is guaranteed to remain loaded in memory
402/// and will not be immediately evicted if it leaves the LRU cache.
403// Field ordering is important for try_dump_to_disk to succeed on drop
404pub struct ReadGuard<'a, T: Send + Sync + 'static, B: Strategy<T>> {
405 data_guard: RwLockReadGuard<'a, T>,
406 _on_drop: ConsumeOnDrop<GuardDropper<'a, T, B>>,
407}
408
409impl<T: Send + Sync + 'static, B: Strategy<T>> Deref for ReadGuard<'_, T, B> {
410 type Target = T;
411
412 /// Dereferences to the immutable underlying data `T`.
413 fn deref(&self) -> &Self::Target {
414 &self.data_guard
415 }
416}
417
418/// An RAII guard providing mutable access (`DerefMut`) to the underlying data `T`.
419///
420/// While this guard is alive, the data is guaranteed to remain loaded in memory.
421// Field ordering is important for try_dump_to_disk to succeed on drop
422pub struct WriteGuard<'a, T: Send + Sync + 'static, B: Strategy<T>> {
423 data_guard: RwLockMappedWriteGuard<'a, T>,
424 _on_drop: ConsumeOnDrop<GuardDropper<'a, T, B>>,
425}
426
427impl<T: Send + Sync + 'static, B: Strategy<T>> Deref for WriteGuard<'_, T, B> {
428 type Target = T;
429
430 fn deref(&self) -> &Self::Target {
431 &self.data_guard
432 }
433}
434
435impl<T: Send + Sync + 'static, B: Strategy<T>> DerefMut for WriteGuard<'_, T, B> {
436 fn deref_mut(&mut self) -> &mut Self::Target {
437 &mut self.data_guard
438 }
439}
440
441struct GuardDropper<'a, T: Send + Sync + 'static, B: Strategy<T>> {
442 pool: &'a FBPool<T, B>,
443 index: cutoff_list::Index,
444}
445
446impl<T: Send + Sync + 'static, B: Strategy<T>> Consume for GuardDropper<'_, T, B> {
447 fn consume(self) {
448 let entry_guard = self.pool.entries.read();
449 let preceding_cutoffs = entry_guard.preceding_cutoffs(self.index).unwrap();
450 assert!(preceding_cutoffs <= 2);
451 if preceding_cutoffs == 2 {
452 entry_guard
453 .get(self.index)
454 .unwrap()
455 .try_dump_to_disk(&self.pool.store);
456 }
457 }
458}
459
460impl<'a, T: Send + Sync + 'static, B: Strategy<T>> GuardDropper<'a, T, B> {
461 pub fn new(pool: &'a FBPool<T, B>, index: cutoff_list::Index) -> ConsumeOnDrop<Self> {
462 ConsumeOnDrop::new(GuardDropper { pool, index })
463 }
464}