Files
proton-drive-linux/crates/pdfs-core/src/cache.rs
T
nvrl 3b80e9bc8f test(core): make under-budget cache check deterministic
The timing ratio between a near-empty and a 2500-entry index measured the
CI runner's load and flaked on main. Plant an oversized index row behind
the running total instead: any budget check that consults the index would
evict and reseed, so the test fails without depending on wall-clock time.
2026-10-08 21:26:56 +02:00

3592 lines
149 KiB
Rust

//! On-disk content cache and pin registry.
//!
//! Files-On-Demand keeps file *content* off local disk by default: a `read`
//! hydrates the requested byte range straight from the remote. Two things make
//! that cache-worthy:
//!
//! * **Pinning** — the user marks a file "always keep on this device". Its full
//! plaintext is downloaded once and stored under [`AppDirs::cache_dir`]; every
//! later `read` is served from disk with no network.
//! * **Validation** — Proton does not hand us a stable revision id on the public
//! [`Node`], so a cached blob is validated against the node's
//! `(modification_time, plaintext_size)` pair. A new revision changes at least
//! the mtime, so a stale blob is detected and ignored (and the event poller
//! evicts it eagerly anyway).
//!
//! [`AppDirs::cache_dir`]: crate::config::AppDirs::cache_dir
//! [`Node`]: proton_drive_rs::Node
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::io::{Read, Seek, SeekFrom};
use std::os::unix::fs::PermissionsExt as _;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::{SystemTime, UNIX_EPOCH};
use proton_drive_rs::proton_sdk::ids::NodeUid;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use tracing::debug;
use parking_lot::Mutex;
use crate::db::{CacheEntryInput, Db};
use crate::error::{Error, Result};
/// `cache_entries.kind` tag for whole-file content blobs.
const KIND_BLOB: &str = "blob";
static STAGING_N: AtomicU64 = AtomicU64::new(0);
/// `cache_entries.kind` tag for on-demand block-cache chunks.
const KIND_BLOCK: &str = "block";
/// `cache_entries.kind` tag for cached thumbnails.
const KIND_THUMB: &str = "thumb";
/// How the configured budget is divided between the three pools, in percent.
///
/// The alternative — one shared cap across all kinds — was rejected: the pools
/// have genuinely different lifetimes, and sharing lets one starve another. A
/// single streaming read is millions of block bytes and would evict the whole
/// blob cache behind it; a gallery scroll would do the same with thumbnails.
///
/// Whole-file blobs get the bulk: they are what "available offline" means, and
/// they are the only pool a user explicitly asks for (by pinning). Blocks are
/// transient partial reads, cheaply re-fetched. Thumbnails are tiny
/// individually — a few percent buys tens of thousands of them — but there is
/// one per photo, so the pool needs a ceiling, which is what C6 was about.
///
/// Must sum to 100; asserted at compile time below.
const SPLIT_BLOB: u64 = 70;
const SPLIT_BLOCK: u64 = 25;
const SPLIT_THUMB: u64 = 5;
const _: () = assert!(SPLIT_BLOB + SPLIT_BLOCK + SPLIT_THUMB == 100);
/// Current wall-clock time in unix *milliseconds*, for the LRU `last_accessed`
/// column. Milliseconds (not seconds) so two cache events in the same second
/// still order correctly — a coarse second-granularity clock would make LRU
/// eviction order indeterminate under bursty access.
fn now_millis() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
/// Block size the on-demand block cache falls back to when a revision's real
/// geometry is not known yet. Matches the SDK's default content block size
/// (`DEFAULT_BLOCK_SIZE`, 4 MiB), which is what this client's own uploads
/// produce — so for a file this client wrote, the fallback *is* the geometry.
///
/// It is only a fallback, though. See [`BlockGeometry`].
pub const BLOCK_SIZE: u64 = 1 << 22;
/// Buffered LRU touches that trigger a flush. Sized so a sequential read of a
/// large file flushes a few times rather than per block, while an idle cache
/// never holds more than a few hundred pending keys.
const TOUCH_FLUSH_AT: usize = 256;
/// How long block `idx` of a file of `size` bytes must be: a full
/// [`BLOCK_SIZE`] except for the last one, which holds the remainder.
///
/// Public because it is the *contract* a block has to satisfy, not a detail of
/// this cache: the network read path validates against it too, so a block that
/// comes back short is refused before it can truncate a file (`docs/BUGS.md` B84).
pub fn block_len(size: u64, idx: u64) -> u64 {
let start = idx.saturating_mul(BLOCK_SIZE);
size.saturating_sub(start).min(BLOCK_SIZE)
}
/// One block of a revision, as the read path and the block cache address it:
/// its index in the revision's block table, and the plaintext range it covers.
///
/// `start` travels with `idx` because the index alone stopped being enough once
/// the geometry came from the revision rather than from a constant — see
/// [`BlockGeometry`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct BlockSpan {
/// Position in the revision's block table.
pub idx: u64,
/// Plaintext offset the block begins at.
pub start: u64,
/// Plaintext length of the block.
pub len: u64,
}
impl BlockSpan {
/// One past the block's last plaintext byte.
pub fn end(&self) -> u64 {
self.start + self.len
}
}
/// Where a revision's content blocks actually begin and end in its plaintext.
///
/// The read path used to assume the answer: [`BLOCK_SIZE`] blocks with a
/// remainder at the end, encoded as `idx * BLOCK_SIZE` and [`block_len`]. That
/// holds for revisions this client uploaded, and it does not hold in general —
/// `RevisionReader::block_sizes()` reports per-block plaintext sizes that some
/// accounts have revisions for which are neither 4 MiB nor uniform
/// (`docs/BUGS.md` B85).
///
/// Getting it wrong is not a truncation: `read_at` plans over the real sizes
/// and clamps to the range asked for, so the bytes are right either way. What
/// it costs is work. A client block that straddles two server blocks makes the
/// reader fetch and decrypt both to answer for one, and the block written to
/// the on-disk cache then lines up with nothing the next read will ask for.
/// Planning from the revision restores the one-cached-block-is-one-fetched-block
/// property the cache was designed around.
///
/// [`BlockGeometry::uniform`] is the old assumption, kept as the answer for a
/// file whose real geometry has not been learned yet — the first read of a file
/// has no reader open, and resolving one just to plan would reintroduce the
/// per-read key derivation the block cache exists to avoid.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BlockGeometry {
/// Plaintext start offset of each block; same length as `sizes`.
starts: Vec<u64>,
sizes: Vec<u64>,
size: u64,
}
impl BlockGeometry {
/// The geometry a revision reports, as a prefix sum over its block sizes.
///
/// Trailing zero-length blocks are dropped: they name no bytes, so they can
/// only produce empty spans that every caller would have to special-case.
pub fn from_sizes(sizes: &[u64]) -> Self {
let mut kept: Vec<u64> = sizes.to_vec();
while kept.last() == Some(&0) {
kept.pop();
}
let mut starts = Vec::with_capacity(kept.len());
let mut at = 0_u64;
for &len in &kept {
starts.push(at);
at = at.saturating_add(len);
}
Self {
starts,
sizes: kept,
size: at,
}
}
/// The [`BLOCK_SIZE`] assumption, for a file of `size` bytes.
pub fn uniform(size: u64) -> Self {
let blocks = size.div_ceil(BLOCK_SIZE);
Self::from_sizes(&(0..blocks).map(|i| block_len(size, i)).collect::<Vec<_>>())
}
/// Total plaintext size this geometry accounts for.
pub fn size(&self) -> u64 {
self.size
}
/// Number of blocks.
pub fn blocks(&self) -> u64 {
self.sizes.len() as u64
}
/// Block `idx`, or `None` past the end of the table.
pub fn span(&self, idx: u64) -> Option<BlockSpan> {
let i = usize::try_from(idx).ok()?;
Some(BlockSpan {
idx,
start: *self.starts.get(i)?,
len: self.sizes[i],
})
}
/// The index of the block containing plaintext offset `at`, or `None` at or
/// past the end.
pub fn index_at(&self, at: u64) -> Option<u64> {
if at >= self.size {
return None;
}
// `starts` is sorted, so the containing block is the last one that
// begins at or before `at`.
let i = self.starts.partition_point(|&s| s <= at).checked_sub(1)?;
Some(i as u64)
}
/// Every block overlapping the plaintext range `[offset, end)`, in order.
/// Empty when the range is empty or lies wholly past the end.
pub fn spans(&self, offset: u64, end: u64) -> Vec<BlockSpan> {
let Some(first) = self.index_at(offset) else {
return Vec::new();
};
if end <= offset {
return Vec::new();
}
let last = self
.index_at(end - 1)
.unwrap_or_else(|| self.blocks().saturating_sub(1));
(first..=last).filter_map(|i| self.span(i)).collect()
}
}
/// How many eviction candidates to pull per batch when a pool is over budget.
/// Large enough that a normal overshoot (a store or two past the cap) is settled
/// in one query, small enough that a badly-over-budget cache does not read its
/// whole index to drop the oldest few.
const EVICT_BATCH: usize = 64;
/// Validity tag stored alongside a cached blob. A blob is fresh only if both
/// fields still match the node's current metadata.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
struct Meta {
/// Node modification time, epoch seconds.
mtime: i64,
/// Plaintext size in bytes.
size: u64,
}
/// Validity tag stored alongside a cached *block*: [`Meta`] plus where in the
/// plaintext the block begins.
///
/// The offset is what makes the block re-plannable. See
/// [`ContentCache::block_tag`] for why `(mtime, size)` alone stopped being
/// enough, and why `start` is optional.
#[derive(Serialize, Deserialize, Clone, Copy, PartialEq, Eq)]
struct BlockMeta {
mtime: i64,
size: u64,
/// Absent in sidecars written before the geometry became per-revision;
/// those are read as "wherever the 4 MiB assumption would have put it".
#[serde(default)]
start: Option<u64>,
}
/// A revision's block geometry as cached on disk, so a read can plan without
/// opening a [`RevisionReader`](proton_sdk) first.
///
/// Learning the geometry costs a revision listing and the file's key
/// derivation, which is exactly the per-read work the block cache exists to
/// avoid. So it is learned as a side effect of the first read — which has to
/// open a reader anyway to fetch anything — written here, and used to plan
/// every read after it. The first read of a file therefore still plans on
/// [`BlockGeometry::uniform`]; if that turns out to be wrong, the blocks it
/// cached are simply not the blocks the next read asks for, and they age out.
#[derive(Serialize, Deserialize, Clone)]
struct GeometryMeta {
mtime: i64,
size: u64,
/// Plaintext size of each content block, in block order.
sizes: Vec<u64>,
}
/// One pin, as carried over the control socket and listed in `status`.
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct Pin {
/// Node uid in `volume~link` display form.
pub uid: String,
/// Last path the node was pinned under, for display in `status`. Advisory
/// only — the uid is the identity.
pub path: String,
/// A folder pin whose whole subtree is kept on disk. `false` for a single
/// file. Defaulted so a legacy `pins.json` (file pins only) still parses.
#[serde(default)]
pub recursive: bool,
/// Whether the pinned node is a folder, when the node cache knows.
///
/// `recursive` is pin *policy*, not node kind: a folder pinned
/// non-recursively has `recursive: false`, and a front-end reading it as
/// "this is a file" draws a file icon and sends an invalid `OpenFile`. This
/// is resolved from the `nodes` row at list time, so it is `None` for a pin
/// whose node is not cached — and absent from an older daemon's reply,
/// which is why it is defaulted.
#[serde(default)]
pub is_dir: Option<bool>,
/// A pinned file whose content is on disk right now. The daemon fills it
/// in when it answers `ListPins`; folders and older daemons say `false`.
#[serde(default)]
pub cached: bool,
}
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
/// the DB once on open. Live pins are owned by the `pins` table.
#[derive(Serialize, Deserialize, Default)]
struct PinFile {
pins: BTreeMap<String, Pin>,
}
/// What a staged file actually contains, written beside it as `<name>.json`.
///
/// A staged file is **not** necessarily valid whole-file content, and that is
/// the whole reason this exists. A partial overwrite commits by filling the
/// untouched regions from the remote base — which is exactly what fails when the
/// network is down, so what lands in staging is the authored bytes with *zeros*
/// in the gaps. Uploading that as-is would silently corrupt the file.
/// `authored` says which ranges are real; `complete` says whether the file can
/// be uploaded as it stands.
/// What `staging/` currently holds, from [`ContentCache::staging_usage`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct StagingUsage {
/// Bytes across every staged blob.
pub bytes: u64,
/// Staged blobs, not counting their sidecars.
pub files: u64,
/// Age of the oldest staged blob, in seconds. Zero when there are none —
/// what makes a figure here interesting is it being *large*, which is the
/// shape of a write that is never going to drain by itself.
pub oldest_secs: u64,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct StagedWrite {
/// Node the write targeted, in `volume~link` display form.
pub uid: String,
/// Length of the intended new content.
pub len: u64,
/// Size and mtime of the revision the file's *untouched* ranges came from,
/// i.e. what a gap-fill reads. Meaningless once `complete` is true.
pub base_size: u64,
pub base_mtime: i64,
/// Locally authored `[start, end)` ranges. Everything else in the file is a
/// zero-filled hole, not content.
pub authored: Vec<(u64, u64)>,
/// True when `authored` covers the whole file, i.e. the staged bytes are the
/// complete new content and can be uploaded directly.
pub complete: bool,
/// The remote revision this change was made against, if it is known.
///
/// Distinct from `base_size`/`base_mtime`, which describe wherever the
/// untouched bytes are to be read from — for a write that supersedes an
/// earlier queued one, that is the *previous staged blob*, not the remote.
/// This instead always names the server's revision, carried across
/// supersedes, so the drain can tell whether the remote moved on under a
/// queued change and keep a conflict copy rather than clobber it.
///
/// `None` on a sidecar written before this field existed, and for a node
/// that has never existed remotely; both mean "do not conflict-check".
#[serde(default)]
pub based_on: Option<Baseline>,
}
impl StagedWrite {
/// Whether `authored` ranges cover a file of `len` bytes, so the staged
/// bytes are its complete new content. An empty file has nothing to cover.
pub fn covers(authored: &[(u64, u64)], len: u64) -> bool {
len == 0 || authored == [(0, len)]
}
}
/// The identity of a remote revision, as far as we can observe it.
///
/// `revision_id` is the authoritative identity when present: the server assigns a
/// fresh id to every sealed revision, so an unchanged id proves no one rewrote
/// the file — even when the server re-stamped its `mtime` on the *same* revision,
/// which is the drift that used to cut spurious conflict copies (B16/B25).
/// `mtime`/`size` remain the fallback for a baseline recorded before the id was
/// observable, or a node read through a surface that does not carry it.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)]
pub struct Baseline {
pub mtime: i64,
pub size: u64,
#[serde(default)]
pub hash: Option<String>,
/// Server revision id this change was made against (`None` if unknown).
#[serde(default)]
pub revision_id: Option<String>,
}
/// A denied-write rescue that retained the bytes but could not fully publish
/// their recovery metadata.
#[derive(Debug)]
pub struct PreserveWriteError {
/// The actual path that still contains the accepted bytes.
pub surviving_path: PathBuf,
/// The recovery metadata that still describes those bytes, when one could
/// be made durable. Callers must never clear this after an error.
pub marker_path: Option<PathBuf>,
error: Error,
}
impl fmt::Display for PreserveWriteError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
formatter,
"{} (bytes retained at {})",
self.error,
self.surviving_path.display()
)
}
}
impl std::error::Error for PreserveWriteError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
Some(&self.error)
}
}
/// Content cache rooted at a directory, with a sibling pin-registry file.
pub struct ContentCache {
/// Directory holding `<key>` blobs and `<key>.meta` tags.
content_dir: PathBuf,
/// Subdirectory holding cached thumbnails (`<key>.t<n>` + `.meta`). Kept out
/// of `content_dir` so the budget scan never sees thumbnail files.
thumb_dir: PathBuf,
/// Owner-protected scratch space for decrypted camera RAWs passed to
/// exiftool. Files are removed on drop and swept on the next cache open after
/// an unclean shutdown.
raw_thumb_dir: PathBuf,
/// Subdirectory holding on-demand block-cache files (`<key>.b<idx>` +
/// `.meta`). Kept out of `content_dir` so the whole-file budget scan never
/// sees them; blocks carry their own LRU budget.
block_dir: PathBuf,
/// Subdirectory for write-handle scratch files (disk-backed write buffers).
/// Emptied on open so a crashed run leaves no orphans.
scratch_dir: PathBuf,
/// Subdirectory holding the bytes of writes that have not been uploaded yet.
/// Every released write passes through here, since the upload is a queued
/// op performed later. Unlike `scratch_dir` this is
/// **never** emptied on open: these are the only copy of content the user
/// authored, and the whole point is that they outlive the daemon.
staging_dir: PathBuf,
/// Subdirectory holding scratch files rescued from an unclean shutdown —
/// writes an application had `fsync`ed but not yet closed. Populated only at
/// open, drained by the daemon into `staging_dir` once it can address the
/// nodes they belong to. Like `staging_dir` and unlike `scratch_dir`, it is
/// never emptied blindly.
recovery_dir: PathBuf,
/// JSON pin registry path.
pins_path: PathBuf,
/// Soft cap on total blob bytes. Exceeded only transiently: a `store`
/// evicts least-recently-used *unpinned* blobs back under the cap. `0`
/// disables the cap. Pinned blobs are never evicted, so pins alone may push
/// the cache over budget. Atomic so the daemon can retune it at runtime
/// (a Settings-page change) via [`set_budget`](Self::set_budget) without
/// taking a lock on every cache read.
max_bytes: AtomicU64,
/// Running byte totals for the two budgeted pools, so the overwhelmingly
/// common "still under budget" answer costs an atomic load instead of a
/// database query.
///
/// These are a *fast path*, not the source of truth — the `cache_entries`
/// index is. They are seeded from it at open (right after `reconcile`, so
/// they describe what is actually on disk), advanced on each store, and
/// **re-seeded from the index after every eviction pass**, which is what
/// keeps a long-running daemon from drifting: the only operations that
/// change a total without going through here are external file deletions,
/// and the next open reconciles those.
blob_bytes: AtomicU64,
block_bytes: AtomicU64,
thumb_bytes: AtomicU64,
/// Unified metadata DB. Its `cache_entries` table is the LRU index: every
/// store/read/evict updates it, and the budget enforcers query it instead of
/// scanning the cache directories.
db: Arc<Db>,
/// LRU touches not yet written to `cache_entries`, keyed by cache key.
///
/// A cache hit only reorders eviction candidates, so it does not have to be
/// durable — but as one write transaction per hit it was 32 transactions per
/// 4 MiB block delivered, taken on the read path, under the connection mutex
/// every `lookup` also needs. Buffered here and flushed in one transaction
/// once [`TOUCH_FLUSH_AT`] have accumulated, and always before an eviction
/// pass reads the ordering these describe.
touches: Mutex<HashMap<String, i64>>,
/// Whether `recovery/` may hold a write the daemon has not queued yet.
///
/// The drain asks for recovered writes on every idle pass — a `read_dir` of
/// a directory that is empty in every run that did not crash. The directory
/// gains entries in exactly two places (the rescue at open, and the release
/// path that parks a still-authoritative scratch file), both of which set
/// this; a scan that finds nothing actionable clears it.
recovery_pending: AtomicBool,
}
impl ContentCache {
fn staging_path(&self, meta: &StagedWrite) -> PathBuf {
// The uid goes in the name so a staged file can be tied back to its node
// without a database; `/` in a uid would otherwise open a path.
let safe_uid = meta.uid.replace(['/', '\\'], "_");
self.staging_dir.join(format!(
"{}-{}-{}",
safe_uid,
now_millis(),
STAGING_N.fetch_add(1, Ordering::Relaxed)
))
}
/// Open (and create) a cache under `content_dir`, with the pin registry at
/// `pins_path` and a `max_bytes` size cap (`0` = unlimited). Both parent
/// directories are created if missing. `db` is the shared metadata database
/// whose `cache_entries` table backs LRU eviction; the on-disk cache is
/// reconciled into it on open.
pub fn open(
content_dir: PathBuf,
pins_path: PathBuf,
max_bytes: u64,
db: Arc<Db>,
) -> Result<Self> {
std::fs::create_dir_all(&content_dir)?;
let thumb_dir = content_dir.join("thumbs");
std::fs::create_dir_all(&thumb_dir)?;
let raw_thumb_dir = content_dir.join("raw-thumbnails");
std::fs::create_dir_all(&raw_thumb_dir)?;
std::fs::set_permissions(&raw_thumb_dir, std::fs::Permissions::from_mode(0o700))?;
if let Ok(entries) = std::fs::read_dir(&raw_thumb_dir) {
for entry in entries.flatten() {
let name = entry.file_name();
if name.to_string_lossy().starts_with("pdfs-raw-")
&& entry.file_type().is_ok_and(|kind| kind.is_file())
{
let _ = std::fs::remove_file(entry.path());
}
}
}
let block_dir = content_dir.join("blocks");
std::fs::create_dir_all(&block_dir)?;
// Scratch holds disk-backed write buffers. A previous run's leftovers are
// worthless *except* where the application asked for durability: a handle
// that was `fsync`ed but never closed left a sidecar behind, and POSIX
// says those bytes are on stable storage. Rescue those, discard the rest.
let scratch_dir = content_dir.join("scratch");
let recovery_dir = content_dir.join("recovery");
std::fs::create_dir_all(&recovery_dir)?;
Self::rescue_scratch(&scratch_dir, &recovery_dir);
// A failed rescue may leave the only durable copy here. Never clear the
// directory wholesale; discard only blobs that carry no fsync marker.
Self::discard_unmarked_scratch(&scratch_dir);
std::fs::create_dir_all(&scratch_dir)?;
// Staging, by contrast, is deliberately preserved across runs.
let staging_dir = content_dir.join("staging");
std::fs::create_dir_all(&staging_dir)?;
if let Some(parent) = pins_path.parent() {
std::fs::create_dir_all(parent)?;
}
let cache = Self {
content_dir,
thumb_dir,
raw_thumb_dir,
block_dir,
scratch_dir,
staging_dir,
recovery_dir,
pins_path,
max_bytes: AtomicU64::new(max_bytes),
blob_bytes: AtomicU64::new(0),
block_bytes: AtomicU64::new(0),
thumb_bytes: AtomicU64::new(0),
db,
touches: Mutex::new(HashMap::new()),
// The rescue above has already run, so start by looking once.
recovery_pending: AtomicBool::new(true),
};
cache.reconcile()?;
// After reconcile, so the totals describe the index that now describes
// the disk.
cache.reseed_totals();
cache.import_legacy_pins()?;
Ok(cache)
}
/// One-time migration of a pre-P5 `pins.json` into the DB `pins` table, then
/// delete the file so it never re-imports. Legacy pins were all whole-file
/// (non-recursive). Absent file → nothing to do.
fn import_legacy_pins(&self) -> Result<()> {
let Ok(bytes) = std::fs::read(&self.pins_path) else {
return Ok(());
};
if let Ok(file) = serde_json::from_slice::<PinFile>(&bytes) {
for (uid, pin) in file.pins {
self.db.pin_add(&uid, &pin.path, false)?;
}
}
let _ = std::fs::remove_file(&self.pins_path);
Ok(())
}
/// Rebuild the `cache_entries` LRU index from the on-disk cache. Called once
/// on open: the index is cleared, then every blob (`content_dir`) and block
/// (`block_dir`) file is re-registered with its on-disk size and last-modified
/// time as the initial LRU key. This makes the DB authoritative even after a
/// crash or an external file deletion, and picks up caches written by builds
/// predating the index. In-run accesses then refine the ordering.
fn reconcile(&self) -> Result<()> {
// Both directories are walked before anything is written: the index is
// replaced in a single transaction (see [`Db::cache_rebuild`]), so the
// rows have to be in hand first. Names are owned for the same reason —
// `DirEntry::file_name` does not outlive the iteration.
let mut names: Vec<(String, &str, u64, i64)> = Vec::new();
for (dir, kind) in [
(&self.content_dir, KIND_BLOB),
(&self.block_dir, KIND_BLOCK),
(&self.thumb_dir, KIND_THUMB),
] {
let Ok(rd) = std::fs::read_dir(dir) else {
continue;
};
for entry in rd.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
// `.geom` is a block-geometry sidecar (see `GeometryMeta`), tens
// of bytes and not a cached block; like `.meta` it is neither
// budgeted nor evictable on its own.
if name.ends_with(".meta") || name.ends_with(".geom") {
continue;
}
// A `.tmp` is a staging file from a store that never completed
// its rename — a crashed or killed run. Nothing will ever claim
// it, and no pass other than this one looks in these
// directories, so it would sit there forever. Sweep it.
if name.ends_with(".tmp") {
let _ = std::fs::remove_file(entry.path());
continue;
}
let Ok(meta) = entry.metadata() else { continue };
if !meta.is_file() {
continue;
}
let at = meta
.modified()
.ok()
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
names.push((name.to_string(), kind, meta.len(), at));
}
}
let entries: Vec<CacheEntryInput<'_>> = names
.iter()
.map(|(key, kind, size, at)| CacheEntryInput {
key,
kind,
size: *size,
last_accessed: *at,
})
.collect();
self.db.cache_rebuild(&entries)?;
Ok(())
}
/// Filesystem-safe, fixed-length cache key for a uid display string: the
/// SHA-256 of `s`, hex-encoded (64 chars). A plain hex of the uid would
/// *double* its length, and Proton's `volume~link` ids are long enough
/// (each half can be 60+ chars) that the doubled key overflows `NAME_MAX`
/// (255) — `store` then fails with ENAMETOOLONG. Hashing bounds the key at
/// 64 chars; reversibility is not needed and collisions are negligible.
fn key_str(s: &str) -> String {
let digest = Sha256::digest(s.as_bytes());
let mut out = String::with_capacity(digest.len() * 2);
for b in digest {
out.push(char::from_digit((b >> 4) as u32, 16).unwrap());
out.push(char::from_digit((b & 0xf) as u32, 16).unwrap());
}
out
}
/// Filesystem-safe cache key for `uid`.
fn key(uid: &NodeUid) -> String {
Self::key_str(&uid.to_string())
}
fn blob_path(&self, uid: &NodeUid) -> PathBuf {
self.content_dir.join(Self::key(uid))
}
fn meta_path(&self, uid: &NodeUid) -> PathBuf {
self.content_dir.join(format!("{}.meta", Self::key(uid)))
}
/// The validated blob path for `uid`, or `None` if absent or stale against
/// `(mtime, size)`.
fn valid_blob(&self, uid: &NodeUid, mtime: i64, size: u64) -> Option<PathBuf> {
let want = Meta { mtime, size };
let meta: Meta = serde_json::from_slice(&std::fs::read(self.meta_path(uid)).ok()?).ok()?;
if meta != want {
return None;
}
let blob = self.blob_path(uid);
// Guard against a torn write: the blob must match the recorded size.
match std::fs::metadata(&blob) {
Ok(m) if m.len() == size => Some(blob),
_ => None,
}
}
/// Whether a fresh cached blob exists for `uid`.
pub fn is_cached(&self, uid: &NodeUid, mtime: i64, size: u64) -> bool {
self.valid_blob(uid, mtime, size).is_some()
}
/// Serve `len` bytes from `offset` out of the cached blob, or `None` on a
/// miss/stale entry. Reads only the requested window off disk.
pub fn read_range(
&self,
uid: &NodeUid,
mtime: i64,
size: u64,
offset: u64,
len: u64,
) -> Option<Vec<u8>> {
let blob = self.valid_blob(uid, mtime, size)?;
// Record the access for LRU in the cache index. Best effort — a failed
// update only makes eviction order slightly less accurate.
self.touch(Self::key(uid));
let mut f = std::fs::File::open(&blob).ok()?;
if offset >= size {
return Some(Vec::new());
}
let end = offset.saturating_add(len).min(size);
let want = (end - offset) as usize;
f.seek(SeekFrom::Start(offset)).ok()?;
let mut buf = vec![0u8; want];
f.read_exact(&mut buf).ok()?;
Some(buf)
}
/// Write the whole content of `uid` at `(mtime, size)` to `dst` from the
/// cache alone, and report whether that worked.
///
/// A whole-file blob is cloned. Otherwise every block of the revision has to
/// be cached, under its recorded geometry or, failing that, the uniform one
/// the read path would use. Anything short of the full content leaves
/// nothing at `dst`, so a caller falls back to a download with a clean slate.
///
/// This is what lets a mirror folder take a file an on-demand mount has
/// already read, after a move between them, without fetching it again. The
/// trust is the read path's: the same `(mtime, size)` key, and blocks that
/// were validated when they were fetched.
pub fn copy_cached_to(&self, uid: &NodeUid, mtime: i64, size: u64, dst: &Path) -> bool {
if size == 0 {
return false;
}
if let Some(blob) = self.valid_blob(uid, mtime, size) {
if Self::clone_or_copy(&blob, dst).is_ok_and(|len| len == size) {
self.touch(Self::key(uid));
return true;
}
let _ = std::fs::remove_file(dst);
return false;
}
let geometry = self
.block_geometry(uid, mtime, size)
.unwrap_or_else(|| BlockGeometry::uniform(size));
if geometry.size() != size {
return false;
}
let spans = geometry.spans(0, size);
// Checked up front so a file with one block missing costs stats, not a
// copy of every block before it.
if !spans
.iter()
.all(|&span| self.has_block(uid, mtime, size, span))
{
return false;
}
let written = (|| -> Option<()> {
use std::io::Write;
let mut out = std::fs::File::create(dst).ok()?;
for span in spans {
out.write_all(&self.cached_block(uid, mtime, size, span)?)
.ok()?;
}
(out.metadata().ok()?.len() == size).then_some(())
})();
if written.is_none() {
let _ = std::fs::remove_file(dst);
}
written.is_some()
}
/// Store `bytes` as the cached content for `uid`, tagged with `(mtime,
/// size)`. The blob is written to a temp file then renamed so a concurrent
/// reader never sees a partial blob; the meta tag is written last so a
/// crash mid-store fails validation rather than serving truncated data.
pub fn store(&self, uid: &NodeUid, mtime: i64, size: u64, bytes: &[u8]) -> Result<()> {
let blob = self.blob_path(uid);
// `with_extension` is safe *here* only because a blob path is a bare
// 64-char hex key with no `.`, so this appends rather than replaces.
// `store_thumbnail` cannot use the same idiom — see the note there.
let tmp = blob.with_extension("tmp");
{
let mut f = std::fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(bytes)?;
f.sync_all()?;
}
std::fs::rename(&tmp, &blob)?;
let meta = serde_json::to_vec(&Meta { mtime, size })?;
std::fs::write(self.meta_path(uid), meta)?;
// Register the blob in the LRU index before enforcing the budget so the
// newest entry is weighed (and ordered most-recent) like any other.
self.db
.cache_touch(&Self::key(uid), KIND_BLOB, bytes.len() as u64, now_millis())?;
self.blob_bytes
.fetch_add(bytes.len() as u64, Ordering::Relaxed);
self.enforce_budget();
Ok(())
}
/// Adopt an existing on-disk file as `uid`'s cached content, without reading
/// it into memory.
///
/// [`Self::store`] takes the bytes as a slice, which means a caller holding a
/// file has to load all of it first — fine for a thumbnail, an OOM for a
/// pinned video. This takes the path instead and hardlinks it into place,
/// falling back to a copy when `src` lives on another filesystem.
///
/// The link makes the cache blob a second name for the same inode, so `src`
/// must not be rewritten in place afterwards — unlinking it (the staging
/// case, which is what this exists for) is fine and leaves the blob intact.
pub fn store_file(&self, uid: &NodeUid, mtime: i64, size: u64, src: &Path) -> Result<()> {
let blob = self.blob_path(uid);
let tmp = blob.with_extension("tmp");
// A stale tmp from an interrupted run would fail the link with EEXIST.
let _ = std::fs::remove_file(&tmp);
if std::fs::hard_link(src, &tmp).is_err() {
std::fs::copy(src, &tmp)?;
}
if let Ok(f) = std::fs::File::open(&tmp) {
let _ = f.sync_all();
}
let bytes = std::fs::metadata(&tmp)?.len();
std::fs::rename(&tmp, &blob)?;
// Linked from the blob already cached, the temp name was a second name
// of it, and the rename did nothing (`docs/BUGS.md` B187).
let _ = std::fs::remove_file(&tmp);
let meta = serde_json::to_vec(&Meta { mtime, size })?;
std::fs::write(self.meta_path(uid), meta)?;
self.db
.cache_touch(&Self::key(uid), KIND_BLOB, bytes, now_millis())?;
self.blob_bytes.fetch_add(bytes, Ordering::Relaxed);
self.enforce_budget();
Ok(())
}
/// Cache-index key for block `idx` of `uid` — the block file's name, which
/// is also its `cache_entries.cache_key`.
fn block_key(&self, uid: &NodeUid, idx: u64) -> String {
format!("{}.b{idx}", Self::key(uid))
}
fn block_blob(&self, uid: &NodeUid, idx: u64) -> PathBuf {
self.block_dir.join(self.block_key(uid, idx))
}
fn block_meta(&self, uid: &NodeUid, idx: u64) -> PathBuf {
self.block_dir
.join(format!("{}.b{idx}.meta", Self::key(uid)))
}
fn geometry_meta(&self, uid: &NodeUid) -> PathBuf {
self.block_dir.join(format!("{}.geom", Self::key(uid)))
}
/// The block geometry recorded for this revision of `uid`, or `None` if it
/// has not been learned (or belongs to an older revision).
///
/// Callers fall back to [`BlockGeometry::uniform`], which is what every
/// read did unconditionally before `docs/BUGS.md` B85.
pub fn block_geometry(&self, uid: &NodeUid, mtime: i64, size: u64) -> Option<BlockGeometry> {
let meta: GeometryMeta =
serde_json::from_slice(&std::fs::read(self.geometry_meta(uid)).ok()?).ok()?;
if meta.mtime != mtime || meta.size != size {
return None;
}
let geometry = BlockGeometry::from_sizes(&meta.sizes);
// A table that does not add up to the node's size describes a different
// file than the one being read; planning on it would place every block
// after the discrepancy wrongly. Refusing it costs a fallback to the
// uniform assumption, which is where B84's repair path takes over.
(geometry.size() == size).then_some(geometry)
}
/// Record the block geometry `sizes` for this revision of `uid`.
///
/// Best effort and disposable, like every other file under the block cache:
/// losing it costs the next read a plan on the uniform assumption, never
/// correctness. Not counted against the block budget — it is tens of bytes
/// per file and is what keeps the blocks that *are* counted addressable.
pub fn store_block_geometry(&self, uid: &NodeUid, mtime: i64, size: u64, sizes: &[u64]) {
let meta = GeometryMeta {
mtime,
size,
sizes: sizes.to_vec(),
};
if let Ok(bytes) = serde_json::to_vec(&meta) {
let _ = std::fs::write(self.geometry_meta(uid), bytes);
}
}
/// Whether `span` of `uid` is cached and fresh for `(mtime, size)`, without
/// reading it. For a caller deciding whether a block is worth *fetching* —
/// reading 4 MiB to answer that would defeat the point.
pub fn has_block(&self, uid: &NodeUid, mtime: i64, size: u64, span: BlockSpan) -> bool {
self.block_tag(uid, mtime, size, span).is_some()
&& std::fs::metadata(self.block_blob(uid, span.idx)).is_ok_and(|m| m.len() == span.len)
}
/// The sidecar of block `span.idx`, if it describes the block the caller
/// means.
///
/// `(mtime, size)` catches a new revision, and used to be the whole check:
/// with the geometry fixed at [`BLOCK_SIZE`], an index named one byte range
/// and only one. It no longer does — the same index names a different range
/// under the revision's own geometry than under the fallback — so the
/// sidecar records the block's plaintext `start` and it has to agree too
/// (`docs/BUGS.md` B85).
///
/// A sidecar written before that field existed deserialises with `start`
/// absent. Those are honoured only where the old assumption and the caller
/// agree on the offset, which is every file this client uploaded: the
/// existing cache survives intact for them, and is quietly refetched for the
/// files it was placing wrongly.
fn block_tag(
&self,
uid: &NodeUid,
mtime: i64,
size: u64,
span: BlockSpan,
) -> Option<BlockMeta> {
let meta: BlockMeta =
serde_json::from_slice(&std::fs::read(self.block_meta(uid, span.idx)).ok()?).ok()?;
if meta.mtime != mtime || meta.size != size {
return None;
}
let recorded = meta
.start
.unwrap_or_else(|| span.idx.saturating_mul(BLOCK_SIZE));
(recorded == span.start).then_some(meta)
}
/// Serve the cached block at `span` of `uid`, or `None` on miss/stale.
/// Validated against `(mtime, size)` and the block's plaintext start, so
/// both a new revision and a re-planned geometry are detected. Bumps the
/// block's mtime for LRU, best effort.
pub fn cached_block(
&self,
uid: &NodeUid,
mtime: i64,
size: u64,
span: BlockSpan,
) -> Option<Vec<u8>> {
self.block_tag(uid, mtime, size, span)?;
let mut f = std::fs::File::open(self.block_blob(uid, span.idx)).ok()?;
// The blob is not fsynced on the way in (see `store_block`), so a crash
// can leave a short one behind the sidecar that describes it. Length is
// what makes that detectable, the same check `valid_blob` applies to a
// whole-file blob.
if f.metadata().ok()?.len() != span.len {
return None;
}
// LRU touch, buffered rather than written (see `touch`).
self.touch(self.block_key(uid, span.idx));
let mut buf = Vec::new();
f.read_to_end(&mut buf).ok()?;
Some(buf)
}
/// Store `bytes` as the cached block at `span` of `uid`, tagged
/// `(mtime, size)` and the block's plaintext start. Temp-file-then-rename
/// like [`store`](Self::store); meta written last so a crash mid-store fails
/// validation. Enforces the block-cache LRU budget.
///
/// The caller owes it exactly `span.len` bytes; anything else reads back as
/// a miss ([`cached_block`](Self::cached_block) checks), so a bad block
/// cannot escape this cache even though this does not check. Validating a
/// block is the *reader's* job, at the point it is fetched — see
/// `Core::read_block` (`docs/BUGS.md` B84).
pub fn store_block(
&self,
uid: &NodeUid,
mtime: i64,
size: u64,
span: BlockSpan,
bytes: &[u8],
) -> Result<()> {
let idx = span.idx;
let blob = self.block_blob(uid, idx);
let tmp = self
.block_dir
.join(format!("{}.b{idx}.tmp", Self::key(uid)));
{
let mut f = std::fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(bytes)?;
// Deliberately not fsynced. This runs inline on the thread a kernel
// read is waiting on, and the block cache is disposable: the worst a
// crash can do is leave a short blob, which `cached_block` rejects
// on length and refetches.
}
std::fs::rename(&tmp, &blob)?;
std::fs::write(
self.block_meta(uid, idx),
serde_json::to_vec(&BlockMeta {
mtime,
size,
start: Some(span.start),
})?,
)?;
self.db.cache_touch(
&self.block_key(uid, idx),
KIND_BLOCK,
bytes.len() as u64,
now_millis(),
)?;
self.block_bytes
.fetch_add(bytes.len() as u64, Ordering::Relaxed);
self.enforce_block_budget();
Ok(())
}
/// Record a cache hit for LRU ordering, without touching the database.
///
/// Later touches of the same key overwrite earlier ones — only the most
/// recent matters — so a hot file costs one map entry however many times it
/// is read.
fn touch(&self, key: String) {
let flush = {
let mut touches = self.touches.lock();
touches.insert(key, now_millis());
touches.len() >= TOUCH_FLUSH_AT
};
if flush {
self.flush_touches();
}
}
/// Write the buffered LRU touches out. Best effort: losing them costs
/// eviction accuracy, nothing else.
///
/// Public because eviction ordering is only as good as what has been
/// flushed, and the daemon flushes on its idle passes as well as here.
pub fn flush_touches(&self) {
let batch: Vec<(String, i64)> = {
let mut touches = self.touches.lock();
if touches.is_empty() {
return;
}
touches.drain().collect()
};
if let Err(error) = self.db.cache_accessed_batch(&batch) {
debug!(?error, "flushing cache LRU touches failed");
}
}
/// Re-read every pool total from the cache index, which is authoritative.
fn reseed_totals(&self) {
for kind in [KIND_BLOB, KIND_BLOCK, KIND_THUMB] {
if let Ok(n) = self.db.cache_total_bytes(kind) {
self.pool(kind).store(n, Ordering::Relaxed);
}
}
}
/// The running total for a pool.
fn pool(&self, kind: &str) -> &AtomicU64 {
match kind {
KIND_BLOCK => &self.block_bytes,
KIND_THUMB => &self.thumb_bytes,
_ => &self.blob_bytes,
}
}
/// This pool's share of the configured budget (`0` = unlimited, as for the
/// whole cache). See [`SPLIT_BLOB`] for why the budget is divided rather
/// than shared.
fn pool_cap(&self, kind: &str) -> u64 {
let cap = self.cap();
if cap == 0 {
return 0;
}
let share = match kind {
KIND_BLOCK => SPLIT_BLOCK,
KIND_THUMB => SPLIT_THUMB,
_ => SPLIT_BLOB,
};
// Multiply before dividing: `cap / 100 * share` truncates a small budget
// to zero, which `pool_cap`'s callers would read as "unlimited" — the
// opposite of what a small budget asks for.
//
// At least one byte, for the same reason.
(cap.saturating_mul(share) / 100).max(1)
}
/// Evict least-recently-used block-cache files until the block dir fits
/// `max_bytes`. No-op when the cap is disabled (`0`). All blocks are
/// evictable — pinned files are served from whole-file blobs, never blocks.
fn enforce_block_budget(&self) {
self.enforce_pool(KIND_BLOCK, &self.block_dir, None);
}
/// Evict least-recently-used entries of one pool until it fits the cap.
///
/// Runs on every store, so the under-budget case is the one that matters:
/// it is an atomic load and nothing else. Only once the running total says
/// we are over does this touch the database, and then it works in bounded
/// batches rather than reading the whole index — a cache that is 10 GB over
/// budget should not materialize every row to drop the oldest few.
///
/// `exempt` names keys that count toward the total but are never victims
/// (pinned blobs). A pass that can find no eligible victim stops rather than
/// looping: a cache held entirely by pins legitimately stays over budget.
fn enforce_pool(&self, kind: &str, dir: &Path, exempt: Option<&HashSet<String>>) {
let cap = self.pool_cap(kind);
if cap == 0 {
return;
}
let total = self.pool(kind);
if total.load(Ordering::Relaxed) <= cap {
return; // the common case: one atomic load, no query
}
// Over budget by the running count — now do the accurate work. The
// buffered hits are what say which entries are cold, so they have to
// reach the index before it is asked.
self.flush_touches();
let mut running = match self.db.cache_total_bytes(kind) {
Ok(n) => n,
Err(_) => return,
};
let mut skipped = 0usize;
while running > cap {
// Fetch a batch past whatever we have already skipped as exempt.
let want = skipped + EVICT_BATCH;
let Ok(batch) = self.db.cache_eviction_candidates(kind, want) else {
break;
};
if batch.len() <= skipped {
break; // nothing new to consider
}
let mut evicted_any = false;
for (key, size) in batch.into_iter().skip(skipped) {
if running <= cap {
break;
}
if exempt.is_some_and(|set| set.contains(&key)) {
skipped += 1; // counts toward the total, never a victim
continue;
}
let _ = std::fs::remove_file(dir.join(&key));
let _ = std::fs::remove_file(dir.join(format!("{key}.meta")));
let _ = self.db.cache_remove(&key);
running = running.saturating_sub(size);
evicted_any = true;
}
if !evicted_any {
break; // every remaining candidate is exempt
}
}
// The index is the truth; realign the fast path with it.
self.reseed_totals();
}
/// Duplicate `src` onto `dst`, preferring a reflink.
///
/// The caller is copying a staged revision — a whole file, up to whatever
/// the user stores — so the difference between the three strategies is the
/// difference between instant and minutes:
///
/// 1. `FICLONE` shares the extents copy-on-write. On btrfs, XFS with
/// `reflink=1`, and bcachefs this is O(1) and consumes no extra space.
/// 2. `copy_file_range` still copies, but entirely inside the kernel — no
/// round trip through a user-space buffer.
/// 3. `std::fs::copy` is the portable fallback, and what this used to
/// always do.
///
/// `dst` is truncated. Returns the number of bytes the destination ends up
/// holding.
pub fn clone_or_copy(src: &Path, dst: &Path) -> Result<u64> {
use std::os::fd::AsRawFd as _;
// `_IOW(0x94, 9, int)` — the whole-file reflink ioctl.
const FICLONE: libc::c_ulong = 0x4004_9409;
let source = std::fs::File::open(src)?;
let len = source.metadata()?.len();
let target = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(dst)?;
// SAFETY: both descriptors are owned and open; FICLONE takes the source
// fd by value and writes nothing back through the pointer-shaped arg.
if unsafe { libc::ioctl(target.as_raw_fd(), FICLONE, source.as_raw_fd()) } == 0 {
return Ok(len);
}
let mut copied = 0u64;
while copied < len {
let want = (len - copied) as usize;
// SAFETY: owned descriptors; null offsets advance the file offsets,
// which is what a whole-file copy wants.
let n = unsafe {
libc::copy_file_range(
source.as_raw_fd(),
std::ptr::null_mut(),
target.as_raw_fd(),
std::ptr::null_mut(),
want,
0,
)
};
if n <= 0 {
break;
}
copied += n as u64;
}
if copied == len {
return Ok(len);
}
// A short or refused `copy_file_range` (cross-filesystem on older
// kernels, an unsupported fs) leaves a partial destination; the
// portable copy truncates and rewrites it from scratch.
Ok(std::fs::copy(src, dst)?)
}
/// Create a fresh, empty read-write scratch file for a disk-backed write
/// handle. Returns the open file and its path (for cleanup on release).
pub fn create_scratch(&self) -> Result<(std::fs::File, PathBuf)> {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
loop {
let path = self.scratch_dir.join(format!(
"w-{}-{}-{}",
std::process::id(),
now_millis(),
N.fetch_add(1, Ordering::Relaxed)
));
match std::fs::OpenOptions::new()
.create_new(true)
.read(true)
.write(true)
.open(&path)
{
Ok(file) => return Ok((file, path)),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(e) => return Err(e.into()),
}
}
}
/// The sidecar path for a scratch file, holding the [`StagedWrite`] that
/// describes it. Written by `fsync` and removed when the write is released;
/// its presence at open is what marks a scratch file as durable rather than
/// as a crashed run's rubbish.
///
/// Safe as `with_extension` only because scratch names are dot-free (see
/// [`Self::create_scratch`]), so this appends rather than replaces.
pub fn scratch_sidecar(scratch: &Path) -> PathBuf {
scratch.with_extension("json")
}
/// Record that the write buffered in `scratch` is durable, so a crash before
/// `close(2)` does not lose it.
///
/// Called from `fsync(2)`, whose contract is that the bytes survive a crash.
/// The scratch file itself does survive — it is a real file — but nothing
/// else knows it holds anything worth keeping, and open() clears the scratch
/// directory. The sidecar is that knowledge: uid, logical length, and which
/// ranges are authored rather than holes, which is exactly what recovery
/// needs to hand the blob to the drain.
///
/// Written whole to a temp name and renamed, so a crash mid-write leaves the
/// previous sidecar rather than a truncated one. The caller is expected to
/// have synced `scratch` first: a sidecar promising bytes that never reached
/// the disk is worse than no sidecar.
pub fn mark_scratch_durable(&self, scratch: &Path, meta: &StagedWrite) -> Result<()> {
use std::io::Write as _;
let side = Self::scratch_sidecar(scratch);
let tmp = side.with_extension("json.tmp");
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp)?;
file.write_all(&serde_json::to_vec(meta)?)?;
file.sync_all()?;
drop(file);
std::fs::rename(&tmp, &side)?;
// The rename is what publishes the sidecar; fsync the directory so the
// rename itself survives, not just the bytes it points at.
if let Some(dir) = side.parent() {
std::fs::File::open(dir)?.sync_all()?;
}
Ok(())
}
/// Drop a scratch file's durability sidecar, once the write it described has
/// been staged (or turned out not to be dirty at all). Best effort: a
/// leftover sidecar whose blob is gone is ignored by recovery anyway.
pub fn clear_scratch_durable(&self, scratch: &Path) {
let _ = std::fs::remove_file(Self::scratch_sidecar(scratch));
}
/// Move every scratch file that carries a sidecar into `recovery`, before the
/// caller empties the scratch directory. A sidecar that does not parse is
/// dropped and its blob moved anyway, unaccompanied — see below.
///
/// Blob first, then sidecar: a blob in recovery without its sidecar is
/// dropped on the next open (indistinguishable from rubbish), whereas a
/// sidecar whose blob never arrived would describe nothing. Neither order is
/// lossless if we crash mid-rescue, but this one fails toward "unrecovered
/// bytes on disk" rather than "op queued against missing content".
fn rescue_scratch(scratch_dir: &Path, recovery_dir: &Path) {
let Ok(entries) = std::fs::read_dir(scratch_dir) else {
return;
};
for entry in entries.flatten() {
let blob = entry.path();
if blob.extension().is_some_and(|e| e == "json") {
continue;
}
let side = Self::scratch_sidecar(&blob);
if !side.exists() {
// Not marked durable, or durability marker was explicitly cleared on release
continue;
}
// A sidecar that does not parse describes nothing, and inventing one
// is worse than having none: a fabricated uid parses, so recovery
// would queue an op against a node that does not exist and retry it
// forever, while a fabricated `complete: true` claims the blob is the
// whole file and stops any gap-fill. The blob still moves to
// recovery — sidecar-less, which `recovered_writes` skips and so
// leaves on disk for a human, the right outcome for bytes we cannot
// describe.
let has_valid_sidecar = std::fs::read(&side)
.ok()
.and_then(|b| serde_json::from_slice::<StagedWrite>(&b).ok())
.is_some();
let Some(name) = blob.file_name() else {
continue;
};
if std::fs::rename(&blob, recovery_dir.join(name)).is_err() {
continue;
}
if !has_valid_sidecar {
let _ = std::fs::remove_file(&side);
continue;
}
let Some(side_name) = side.file_name() else {
continue;
};
let _ = std::fs::rename(&side, recovery_dir.join(side_name));
}
}
fn discard_unmarked_scratch(scratch_dir: &Path) {
let Ok(entries) = std::fs::read_dir(scratch_dir) else {
return;
};
for entry in entries.flatten() {
let blob = entry.path();
if blob.extension().is_some_and(|e| e == "json") {
continue;
}
if !Self::scratch_sidecar(&blob).exists() {
let _ = std::fs::remove_file(blob);
}
}
}
/// Emergency eviction of unpinned blobs when local disk runs out of space (ENOSPC).
pub fn emergency_evict(&self) {
let pinned: HashSet<String> = self
.db
.pinned_uids()
.unwrap_or_default()
.iter()
.map(|uid| Self::key_str(uid))
.collect();
let content_dir = self.content_dir.clone();
// Force pool enforcement ignoring current cap check to free disk space immediately
let Ok(list) = self.db.cache_eviction_candidates(KIND_BLOB, 10) else {
return;
};
for (key, bytes) in list {
if pinned.contains(&key) {
continue;
}
let p = content_dir.join(&key);
let m = content_dir.join(format!("{key}.meta"));
let _ = std::fs::remove_file(&p);
let _ = std::fs::remove_file(&m);
let _ = self.db.cache_remove(&key);
self.blob_bytes.fetch_sub(bytes, Ordering::Relaxed);
break; // Evict one item at a time until space is freed
}
}
/// Writes rescued from an unclean shutdown, as `(blob, meta)` pairs for the
/// daemon to hand to the upload queue. A blob whose sidecar did not survive
/// is not actionable automatically, but is retained for manual recovery:
/// unexplained bytes are never evidence that user data is disposable.
///
/// Asked for on every idle drain pass, so the usual answer — a directory
/// that has been empty since the last clean shutdown — costs an atomic load
/// rather than a `read_dir` (see [`recovery_pending`](Self::recovery_pending)).
pub fn recovered_writes(&self) -> Vec<(PathBuf, StagedWrite)> {
if !self.recovery_pending.load(Ordering::Relaxed) {
return Vec::new();
}
let Ok(entries) = std::fs::read_dir(&self.recovery_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let blob = entry.path();
if blob.extension().is_some_and(|e| e == "json") {
continue;
}
if let Some(meta) = std::fs::read(Self::scratch_sidecar(&blob))
.ok()
.and_then(|b| serde_json::from_slice::<StagedWrite>(&b).ok())
{
out.push((blob, meta));
}
}
// Blobs whose sidecar did not survive stay here for a human and are
// never actionable, so "nothing to queue" is the condition that stops
// the polling — not "the directory is empty".
if out.is_empty() {
self.recovery_pending.store(false, Ordering::Relaxed);
}
out
}
/// Every blob in `staging/`, paired with its sidecar when it has a readable
/// one.
///
/// For the startup reconcile: staging holds the only copy of writes the
/// kernel has already been told succeeded, and three failure paths can
/// leave one there with no `pending_op` row pointing at it. Nothing walked
/// this directory, so those bytes were invisible to the daemon and to the
/// user both — `hydrate_pending` walks the op table and
/// [`recovered_writes`](Self::recovered_writes) walks `recovery/`.
pub fn staged_writes(&self) -> Vec<(PathBuf, Option<StagedWrite>)> {
let Ok(entries) = std::fs::read_dir(&self.staging_dir) else {
return Vec::new();
};
let mut out = Vec::new();
for entry in entries.flatten() {
let blob = entry.path();
if blob.extension().is_some_and(|e| e == "json") {
continue;
}
let meta = std::fs::read(Self::scratch_sidecar(&blob))
.ok()
.and_then(|b| serde_json::from_slice::<StagedWrite>(&b).ok());
out.push((blob, meta));
}
out
}
/// Retire a recovered write. `stage_write` has already moved the blob out,
/// so this is the sidecar left behind — and the blob too if the caller could
/// not use it and said so by leaving it in place.
pub fn discard_recovered(&self, blob: &Path) {
let _ = std::fs::remove_file(Self::scratch_sidecar(blob));
let _ = std::fs::remove_file(blob);
}
/// Move a released scratch file into the staging directory with a
/// [`StagedWrite`] sidecar, and return where it landed.
///
/// This is what makes a write survive its upload: the caller is releasing a
/// write handle and would otherwise delete the file, so until the bytes are
/// on the remote, staging holds the only copy. Every dirty handle goes
/// through here — the upload is a queued op performed later, and a staged
/// file is also what a human can recover from if the queue never drains.
///
/// Falls back to a copy when the rename crosses a filesystem boundary; a
/// failure here means we could not save the bytes at all, so it is reported
/// rather than swallowed.
pub fn stage_write(&self, meta: &StagedWrite, scratch: &Path) -> Result<PathBuf> {
let path = self.staging_path(meta);
self.stage_write_at(meta, scratch, &path)
}
/// [`stage_write`](Self::stage_write) with the destination named by the
/// caller.
///
/// The public entry point picks a timestamped name, which is right for
/// production and untestable: a test cannot arrange for publication to fail
/// at a path it cannot predict. This is the seam `preserve_write_at` is for
/// the rescue path.
fn stage_write_at(&self, meta: &StagedWrite, scratch: &Path, path: &Path) -> Result<PathBuf> {
use std::io::Write as _;
let path = path.to_path_buf();
let copied = if let Err(e) = std::fs::rename(scratch, &path) {
if e.kind() != std::io::ErrorKind::CrossesDevices {
return Err(e.into());
}
std::fs::copy(scratch, &path)?;
true
} else {
false
};
std::fs::File::open(&path)?.sync_all()?;
// Sidecar last: a staged file without one is still recoverable bytes,
// while a sidecar without a file would describe nothing.
let side = path.with_extension("json");
let side_tmp = path.with_extension("json.tmp");
let mut side_file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&side_tmp)?;
side_file.write_all(&serde_json::to_vec_pretty(meta)?)?;
side_file.sync_all()?;
drop(side_file);
std::fs::rename(&side_tmp, &side)?;
std::fs::File::open(&self.staging_dir)?.sync_all()?;
if copied {
std::fs::remove_file(scratch)?;
}
Ok(path)
}
/// Preserve accepted bytes which cannot be queued.
///
/// Scratch and staging normally share a filesystem, so the normal path is
/// an atomic rename. If that is impossible, the source first receives a
/// durable recovery marker and remains in place until a copied destination,
/// its sidecar, and the staging directory are all synced.
pub fn preserve_write(
&self,
meta: &StagedWrite,
scratch: &Path,
) -> std::result::Result<PathBuf, PreserveWriteError> {
let path = self.staging_path(meta);
self.preserve_write_at(meta, scratch, &path)
}
fn preserve_write_at(
&self,
meta: &StagedWrite,
scratch: &Path,
path: &Path,
) -> std::result::Result<PathBuf, PreserveWriteError> {
use std::io::Write as _;
let retained = |surviving_path: &Path, error: Error| {
let adjacent = Self::scratch_sidecar(surviving_path);
let source = Self::scratch_sidecar(scratch);
let marker_path = [adjacent, source].into_iter().find(|path| path.is_file());
PreserveWriteError {
surviving_path: surviving_path.to_path_buf(),
marker_path,
error,
}
};
// If rename cannot happen, this marker is what lets the next open move
// the still-authoritative source into recovery instead of deleting it.
let marker_result = self.mark_scratch_durable(scratch, meta);
let side = path.with_extension("json");
let side_tmp = path.with_extension("json.tmp");
let publish_sidecar = || -> Result<()> {
let mut side_file = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&side_tmp)?;
side_file.write_all(&serde_json::to_vec_pretty(meta)?)?;
side_file.sync_all()?;
drop(side_file);
std::fs::rename(&side_tmp, &side)?;
std::fs::File::open(&self.staging_dir)?.sync_all()?;
Ok(())
};
match std::fs::rename(scratch, path) {
Ok(()) => {
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
if std::fs::rename(path, scratch).is_ok() {
if !Self::scratch_sidecar(scratch).is_file() {
let _ = self.mark_scratch_durable(scratch, meta);
}
let _ =
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
return Err(retained(scratch, error.into()));
}
return Err(retained(path, error.into()));
}
let scratch_side = Self::scratch_sidecar(scratch);
let side_result = if marker_result.is_ok() {
std::fs::rename(&scratch_side, &side)
.map_err(Error::from)
.and_then(|()| {
std::fs::File::open(&self.staging_dir)?
.sync_all()
.map_err(Error::from)
})
} else {
publish_sidecar()
};
if let Err(error) = side_result {
let _ = std::fs::remove_file(&side_tmp);
// Publication did not complete. Put the blob back beside
// the source marker so startup recovery still has a
// reconstructable pair, rather than an unexplained staging
// blob and a marker whose source vanished.
if std::fs::rename(path, scratch).is_ok() {
if !Self::scratch_sidecar(scratch).is_file() {
let _ = self.mark_scratch_durable(scratch, meta);
}
let _ =
std::fs::File::open(&self.scratch_dir).and_then(|dir| dir.sync_all());
return Err(retained(scratch, error));
}
return Err(retained(path, error));
}
Ok(path.to_path_buf())
}
Err(rename_error) => {
let source_marked =
marker_result.is_ok() || Self::scratch_sidecar(scratch).exists();
if let Err(marker_error) = marker_result {
tracing::warn!(
error = %marker_error,
path = %scratch.display(),
"could not mark rescue source durable before copy fallback"
);
}
if let Err(error) = std::fs::copy(scratch, path) {
if !source_marked {
let recovery = self.recovery_dir.join(
path.file_name()
.unwrap_or_else(|| std::ffi::OsStr::new("denied-write")),
);
if std::fs::rename(scratch, &recovery).is_ok() {
let _ = std::fs::File::open(&self.recovery_dir)
.and_then(|dir| dir.sync_all());
// These bytes are unaccompanied, but re-arm the
// scan anyway: the flag exists to skip a directory
// nothing has written to, and something just did.
self.recovery_pending.store(true, Ordering::Relaxed);
return Err(retained(
&recovery,
Error::Other(format!(
"rename failed ({rename_error}); copy fallback failed ({error})"
)),
));
}
}
return Err(retained(
scratch,
Error::Other(format!(
"rename failed ({rename_error}); copy fallback failed ({error})"
)),
));
}
if let Err(error) = std::fs::File::open(path).and_then(|file| file.sync_all()) {
let surviving = if source_marked { scratch } else { path };
return Err(retained(surviving, error.into()));
}
if let Err(error) = publish_sidecar() {
let _ = std::fs::remove_file(&side_tmp);
let surviving = if source_marked { scratch } else { path };
return Err(retained(surviving, error));
}
// Deliberately last: fallback never removes its marked source
// until destination publication is complete.
if let Err(error) = std::fs::remove_file(scratch) {
return Err(retained(path, error.into()));
}
self.clear_scratch_durable(scratch);
Ok(path.to_path_buf())
}
}
}
/// Drop a staged blob and its sidecar, once its bytes are safely on the
/// remote (or the op that owned them has been superseded). Best effort:
/// leftovers cost disk, while failing here would strand a drained op.
pub fn discard_staged(&self, path: &Path) {
let _ = std::fs::remove_file(path);
let _ = std::fs::remove_file(path.with_extension("json"));
}
/// Remove any cached blob + meta for `uid`, including its thumbnails (best
/// effort; absence is fine). Thumbnails are tied to the revision, so a
/// content eviction must drop them too.
pub fn evict(&self, uid: &NodeUid) {
let _ = std::fs::remove_file(self.blob_path(uid));
let _ = std::fs::remove_file(self.meta_path(uid));
for ttype in [1, 2] {
let _ = std::fs::remove_file(self.thumb_blob(uid, ttype));
let _ = std::fs::remove_file(self.thumb_meta(uid, ttype));
}
// Drop every cached block (and its meta/tmp) for this uid, plus the
// recorded block geometry — it describes the revision these blocks came
// from, and keeping it past them would have the next read plan against a
// table for a file it no longer holds. The block dir is still scanned
// here because eviction targets one uid's files by name prefix, not by
// LRU order; the index rows are removed in one shot by
// `cache_remove_all` below.
let _ = std::fs::remove_file(self.geometry_meta(uid));
let prefix = format!("{}.b", Self::key(uid));
if let Ok(rd) = std::fs::read_dir(&self.block_dir) {
for entry in rd.flatten() {
if entry
.file_name()
.to_str()
.is_some_and(|n| n.starts_with(&prefix))
{
let _ = std::fs::remove_file(entry.path());
}
}
}
// Forget the blob and all of this uid's block rows in the LRU index.
let _ = self.db.cache_remove_all(&Self::key(uid));
// Rows just left the index; the running totals must follow.
self.reseed_totals();
}
/// Cache-index key for the `ttype` thumbnail of `uid` — the thumbnail file's
/// name, so eviction can remove the file by joining it onto `thumb_dir`.
fn thumb_key(&self, uid: &NodeUid, ttype: i32) -> String {
format!("{}.t{ttype}", Self::key(uid))
}
fn thumb_blob(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
self.thumb_dir.join(self.thumb_key(uid, ttype))
}
fn thumb_meta(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
self.thumb_dir
.join(format!("{}.t{ttype}.meta", Self::key(uid)))
}
/// Owner-protected transient directory used to stage decrypted camera RAWs
/// for metadata helpers. Callers still remove individual files promptly.
pub fn raw_thumbnail_staging_dir(&self) -> &Path {
&self.raw_thumb_dir
}
/// Serve a cached thumbnail of `ttype` for `uid`, or `None` on miss/stale.
/// Validated by node `mtime` alone: a new revision (which is what changes the
/// thumbnail) bumps the mtime, so a stale thumbnail is detected. Thumbnails
/// are small, so the whole blob is read at once.
pub fn read_thumbnail(&self, uid: &NodeUid, ttype: i32, mtime: i64) -> Option<Vec<u8>> {
let recorded: i64 =
serde_json::from_slice(&std::fs::read(self.thumb_meta(uid, ttype)).ok()?).ok()?;
if recorded != mtime {
return None;
}
let bytes = std::fs::read(self.thumb_blob(uid, ttype)).ok()?;
// Record the access for LRU, as the blob and block readers do. Without
// it the pool evicts in insertion order, so scrolling back up a gallery
// would drop exactly the tiles being looked at.
self.touch(self.thumb_key(uid, ttype));
Some(bytes)
}
/// On-disk path of the cached content blob for `uid`, validated against
/// `(mtime, size)`; `None` on miss/stale. Lets a front-end open the blob
/// directly instead of streaming its bytes back over the control socket.
pub fn cached_content_path(&self, uid: &NodeUid, mtime: i64, size: u64) -> Option<PathBuf> {
self.valid_blob(uid, mtime, size)
}
/// On-disk path where `uid`'s content blob lives once stored (the file may
/// not exist yet, or may be stale — pair with [`store`](Self::store)).
pub fn content_path(&self, uid: &NodeUid) -> PathBuf {
self.blob_path(uid)
}
/// On-disk path of the cached `ttype` thumbnail for `uid`, fresh against
/// `tag` (the validity tag last passed to [`store_thumbnail`](Self::store_thumbnail));
/// `None` on miss/stale. For Drive files the tag is the node mtime; for
/// photos it is the capture time.
pub fn cached_thumbnail_path(&self, uid: &NodeUid, ttype: i32, tag: i64) -> Option<PathBuf> {
let recorded: i64 =
serde_json::from_slice(&std::fs::read(self.thumb_meta(uid, ttype)).ok()?).ok()?;
if recorded != tag {
return None;
}
let blob = self.thumb_blob(uid, ttype);
if !blob.exists() {
return None;
}
self.touch(self.thumb_key(uid, ttype));
Some(blob)
}
/// On-disk path where `uid`'s `ttype` thumbnail lives once stored.
pub fn thumbnail_path(&self, uid: &NodeUid, ttype: i32) -> PathBuf {
self.thumb_blob(uid, ttype)
}
/// Cache `bytes` as the `ttype` thumbnail for `uid`, tagged with `mtime`.
/// Blob written to a temp file then renamed; the meta tag is written last so
/// a crash mid-store fails validation rather than serving a torn thumbnail.
///
/// The temp name carries `ttype`, and must. `Path::with_extension` would
/// *replace* the `.t{ttype}` suffix rather than append to it, giving every
/// thumbnail type of one node the same staging file — so a type-1 and a
/// type-2 store racing (the gallery caches type 1 from a control-socket
/// thread while `getxattr` caches type 2 on the FUSE dispatch loop) would
/// publish one type's bytes under the other's name. See
/// `concurrent_thumbnail_types_do_not_share_a_temp_file`.
pub fn store_thumbnail(
&self,
uid: &NodeUid,
ttype: i32,
mtime: i64,
bytes: &[u8],
) -> Result<()> {
let blob = self.thumb_blob(uid, ttype);
let tmp = self
.thumb_dir
.join(format!("{}.t{ttype}.tmp", Self::key(uid)));
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, &blob)?;
std::fs::write(self.thumb_meta(uid, ttype), serde_json::to_vec(&mtime)?)?;
self.db.cache_touch(
&self.thumb_key(uid, ttype),
KIND_THUMB,
bytes.len() as u64,
now_millis(),
)?;
self.thumb_bytes
.fetch_add(bytes.len() as u64, Ordering::Relaxed);
self.enforce_thumb_budget();
Ok(())
}
/// Evict least-recently-used thumbnails until the pool fits its share of the
/// budget. Every thumbnail is evictable, including those of pinned nodes: a
/// thumbnail is derived data that the gallery re-fetches or regenerates, and
/// exempting them would mean a large pinned library could hold the pool
/// permanently over its ceiling — the exact unbounded growth C6 was about.
fn enforce_thumb_budget(&self) {
self.enforce_pool(KIND_THUMB, &self.thumb_dir, None);
}
/// Evict least-recently-used *unpinned* blobs until total blob bytes fit the
/// configured `max_bytes` cap. No-op when the cap is disabled (`0`) or the
/// cache already fits. Pinned blobs are skipped, so a cache held entirely by
/// pins can legitimately stay over budget.
fn enforce_budget(&self) {
let cap = self.pool_cap(KIND_BLOB);
if cap == 0 || self.blob_bytes.load(Ordering::Relaxed) <= cap {
// The common case, settled without a query — and in particular
// without the recursive pin CTE below, which used to run on every
// single store.
return;
}
// Pinned blobs (by cache key) are exempt from eviction. Resolves direct
// pins plus every descendant of a recursively-pinned folder, hashed into
// cache keys to match on-disk filenames.
let pinned: HashSet<String> = self
.db
.pinned_uids()
.unwrap_or_default()
.iter()
.map(|uid| Self::key_str(uid))
.collect();
let content_dir = self.content_dir.clone();
self.enforce_pool(KIND_BLOB, &content_dir, Some(&pinned));
}
/// Whether `uid` is pinned — directly, or because an ancestor folder is
/// pinned recursively (resolved in the DB against the node tree). Independent
/// of whether its blob is currently cached. Best effort: a DB error reads as
/// "not pinned" rather than failing the calling FUSE op.
pub fn is_pinned(&self, uid: &NodeUid) -> bool {
self.db.is_pinned(&uid.to_string()).unwrap_or(false)
}
/// Record `uid` as pinned under `path`. `recursive` marks a folder pin whose
/// whole subtree is kept. The caller is responsible for having cached the
/// content (a file via [`store`](Self::store); a folder's descendants by the
/// daemon walking the subtree).
pub fn add_pin(&self, uid: &NodeUid, path: &Path, recursive: bool) -> Result<()> {
self.db
.pin_add(&uid.to_string(), &path.display().to_string(), recursive)
}
/// Drop `uid` from the pin registry and evict its blob. No-op if not pinned.
/// A recursively-pinned folder's descendants are evicted by the daemon
/// (`Core::unpin`), which knows the node tree.
pub fn remove_pin(&self, uid: &NodeUid) -> Result<()> {
self.db.pin_remove(&uid.to_string())?;
self.evict(uid);
Ok(())
}
/// All pins (files and recursive folders), ordered by uid.
pub fn list_pins(&self) -> Vec<Pin> {
self.db
.pin_list()
.unwrap_or_default()
.into_iter()
.map(|row| Pin {
uid: row.uid,
path: row.path,
recursive: row.recursive,
is_dir: row.is_dir,
cached: false,
})
.collect()
}
/// Total bytes the cache holds on disk — blobs, blocks and thumbnails
/// together — which is what the configured budget caps and therefore the
/// only honest thing to show next to it.
///
/// This used to scan `content_dir` alone, which excluded both the block dir
/// and the thumb dir: the Settings page could report a few hundred megabytes
/// while the cache held several gigabytes. Now it reads the same running
/// totals the budget enforcers use, so the displayed number and the enforced
/// number cannot disagree — and it costs three atomic loads instead of a
/// directory scan.
///
/// Excludes the `.meta` sidecars (a few dozen bytes each) and the scratch
/// and staging dirs, which hold unuploaded user writes rather than cache and
/// are deliberately not evictable.
pub fn usage(&self) -> u64 {
[KIND_BLOB, KIND_BLOCK, KIND_THUMB]
.iter()
.map(|k| self.pool(k).load(Ordering::Relaxed))
.sum()
}
/// Bytes and file count held in `staging/`, with the age of the oldest
/// entry in seconds.
///
/// Staging is not part of [`usage`](Self::usage) and is not subject to the
/// budget — it holds the *only* copy of writes that have not been uploaded,
/// so nothing here may ever be evicted to reclaim space. That is precisely
/// why it has to be reportable: a parked transient create (three abandoned
/// 4 GiB downloads, say) otherwise occupies disk with nothing in any UI
/// accounting for it.
///
/// A `read_dir` per call, which is fine for the callers it has: a status
/// request, polled every couple of seconds against a directory that is
/// empty whenever the queue is drained.
pub fn staging_usage(&self) -> StagingUsage {
let Ok(entries) = std::fs::read_dir(&self.staging_dir) else {
return StagingUsage::default();
};
let mut usage = StagingUsage::default();
let now = std::time::SystemTime::now();
for entry in entries.flatten() {
// Sidecars describe the blob beside them; counting both would double
// the file count and report metadata as user bytes.
if entry.path().extension().is_some_and(|e| e == "json") {
continue;
}
let Ok(meta) = entry.metadata() else { continue };
usage.files += 1;
usage.bytes += meta.len();
if let Ok(age) = meta.modified().and_then(|m| {
now.duration_since(m)
.map_err(|_| std::io::Error::other("modified in the future"))
}) {
usage.oldest_secs = usage.oldest_secs.max(age.as_secs());
}
}
usage
}
/// Configured soft byte cap (`0` = unlimited), for display alongside
/// [`usage`](Self::usage). This is the whole-cache figure; internally it is
/// divided between the pools (see [`SPLIT_BLOB`]), but the user set one
/// number and gets that number.
pub fn budget(&self) -> u64 {
self.cap()
}
/// The current soft byte cap. A plain atomic load; the value can change
/// under [`set_budget`](Self::set_budget) while the daemon runs.
fn cap(&self) -> u64 {
self.max_bytes.load(Ordering::Relaxed)
}
/// Retune the soft byte cap (`0` = unlimited) at runtime and immediately
/// enforce it: blobs and blocks are LRU-evicted back under the new cap (a
/// lower cap frees space now; a higher one is a no-op until the next store).
/// Called by the daemon when the Settings page changes the cache budget.
pub fn set_budget(&self, bytes: u64) {
self.max_bytes.store(bytes, Ordering::Relaxed);
self.enforce_budget();
self.enforce_block_budget();
self.enforce_thumb_budget();
}
/// Delete every *unpinned* cached blob plus all on-demand block chunks,
/// keeping pinned files intact. Pinned files keep their whole-file blobs
/// (and are never served from blocks), so dropping all blocks is safe.
/// Returns the number of bytes freed, for a user-facing confirmation.
pub fn clear_unpinned(&self) -> u64 {
// Pinned blobs are exempt; resolve them to cache keys to match filenames.
let pinned: HashSet<String> = self
.db
.pinned_uids()
.unwrap_or_default()
.iter()
.map(|uid| Self::key_str(uid))
.collect();
let mut freed = 0u64;
// Unpinned whole-file blobs.
if let Ok(entries) = self.db.cache_entries_by_kind(KIND_BLOB) {
for (key, size) in entries {
if pinned.contains(&key) {
continue;
}
let _ = std::fs::remove_file(self.content_dir.join(&key));
let _ = std::fs::remove_file(self.content_dir.join(format!("{key}.meta")));
let _ = self.db.cache_remove(&key);
freed = freed.saturating_add(size);
}
}
// Every on-demand block (transient partial reads, re-fetched on demand)
// and every thumbnail (derived, re-fetched or regenerated on demand).
// Both pools are dropped wholesale rather than filtered by pin: neither
// is what "keep this available offline" means, and leaving them behind
// would make "clear cache" free visibly less than it reported.
for (kind, dir) in [(KIND_BLOCK, &self.block_dir), (KIND_THUMB, &self.thumb_dir)] {
if let Ok(entries) = self.db.cache_entries_by_kind(kind) {
for (key, size) in entries {
let _ = std::fs::remove_file(dir.join(&key));
let _ = std::fs::remove_file(dir.join(format!("{key}.meta")));
let _ = self.db.cache_remove(&key);
freed = freed.saturating_add(size);
}
}
}
self.reseed_totals();
freed
}
}
#[cfg(test)]
mod tests {
use super::*;
use proton_drive_rs::proton_sdk::ids::{LinkId, VolumeId};
fn uid(link: &str) -> NodeUid {
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
}
#[test]
fn an_empty_write_covers_the_file_and_a_gap_does_not() {
assert!(
StagedWrite::covers(&[], 0),
"an empty file has nothing to cover"
);
assert!(StagedWrite::covers(&[(0, 7)], 7));
assert!(!StagedWrite::covers(&[(0, 3), (4, 7)], 7));
assert!(!StagedWrite::covers(&[], 7));
}
/// A unique temp directory removed on drop; avoids a dev-dependency.
struct TempDir(PathBuf);
impl TempDir {
fn new() -> Self {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"pdfs-cache-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
std::fs::create_dir_all(&p).unwrap();
TempDir(p)
}
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn cache() -> (ContentCache, TempDir) {
cache_capped(0)
}
/// A cache whose `kind` pool holds exactly `bytes`.
///
/// The configured budget is a whole-cache figure divided between the pools
/// (C7), while a test that exercises eviction cares about one pool at a
/// time. So the test states the pool size it means and this inverts the
/// split, rather than every such test hard-coding a number that silently
/// changes meaning if the split is ever retuned.
fn cache_with_pool_cap(kind: &str, bytes: u64) -> (ContentCache, TempDir) {
let share = match kind {
KIND_BLOCK => SPLIT_BLOCK,
KIND_THUMB => SPLIT_THUMB,
_ => SPLIT_BLOB,
};
// Round up, so the pool holds at least `bytes` rather than one short.
let (c, d) = cache_capped((bytes * 100).div_ceil(share));
assert!(
c.pool_cap(kind) >= bytes,
"helper must give the {kind} pool at least {bytes} bytes"
);
(c, d)
}
fn cache_capped(max_bytes: u64) -> (ContentCache, TempDir) {
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let c = ContentCache::open(
dir.path().join("content"),
dir.path().join("pins.json"),
max_bytes,
db,
)
.unwrap();
(c, dir)
}
#[test]
fn store_then_read_range() {
let (c, _d) = cache();
let u = uid("a");
let data = b"hello world";
c.store(&u, 100, data.len() as u64, data).unwrap();
assert!(c.is_cached(&u, 100, data.len() as u64));
assert_eq!(
c.read_range(&u, 100, data.len() as u64, 6, 5).unwrap(),
b"world"
);
// Offset past EOF yields an empty slice, not a miss.
assert_eq!(
c.read_range(&u, 100, data.len() as u64, 100, 5).unwrap(),
b""
);
}
/// The drain promotes a staged upload into the cache by path and then
/// unlinks the staging name. The cached copy has to survive that, and the
/// promote has to charge the LRU index like [`ContentCache::store`] does.
#[test]
fn store_file_adopts_blob_and_survives_source_unlink() {
let (c, d) = cache();
let u = uid("a");
let data = b"staged contents";
let src = d.path().join("staged-blob");
std::fs::write(&src, data).unwrap();
c.store_file(&u, 100, data.len() as u64, &src).unwrap();
std::fs::remove_file(&src).unwrap();
assert!(c.is_cached(&u, 100, data.len() as u64));
assert_eq!(
c.read_range(&u, 100, data.len() as u64, 7, 8).unwrap(),
b"contents"
);
assert_eq!(c.usage(), data.len() as u64);
}
/// A cross-filesystem staging dir cannot be hardlinked from; the copy
/// blob and its metadata must both be replaced wholesale — a stale `.meta`
/// left beside new content would validate a read of the wrong bytes. The
/// link goes to a temp name and is renamed over the blob for exactly this,
/// so re-storing over an existing entry is the case worth pinning.
#[test]
fn store_file_replaces_an_existing_blob() {
let (c, d) = cache();
let u = uid("a");
c.store(&u, 100, 3, b"old").unwrap();
let src = d.path().join("new-blob");
std::fs::write(&src, b"newer").unwrap();
c.store_file(&u, 200, 5, &src).unwrap();
assert!(!c.is_cached(&u, 100, 3), "stale meta must not validate");
assert_eq!(c.read_range(&u, 200, 5, 0, 5).unwrap(), b"newer");
}
/// A landed create and the revision after it can adopt the same staged
/// blob. The link to the temp name is then a second name of the cached
/// blob, and a rename between two names of one file does nothing: the
/// temp name stayed until the next start swept it (`docs/BUGS.md` B187).
#[test]
fn store_file_twice_from_the_same_blob_leaves_no_temp_file() {
let (c, d) = cache();
let u = uid("a");
let src = d.path().join("staged-blob");
std::fs::write(&src, b"staged").unwrap();
c.store_file(&u, 100, 6, &src).unwrap();
c.store_file(&u, 100, 6, &src).unwrap();
assert!(!c.blob_path(&u).with_extension("tmp").exists());
assert_eq!(c.read_range(&u, 100, 6, 0, 6).unwrap(), b"staged");
}
#[test]
fn reconcile_rebuilds_index_from_disk() {
let dir = TempDir::new();
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
// First session: unlimited cap, store three blobs onto disk.
let db1 = Arc::new(Db::open_in_memory().unwrap());
{
let c = ContentCache::open(content.clone(), pins.clone(), 0, db1).unwrap();
c.store(&uid("a"), 1, 4, b"aaaa").unwrap();
c.store(&uid("b"), 1, 4, b"bbbb").unwrap();
c.store(&uid("c"), 1, 4, b"cccc").unwrap();
}
// Second session with a *fresh* index (as if predating P4, or after a
// crash) but the same on-disk cache: reconcile repopulates from disk.
let db2 = Arc::new(Db::open_in_memory().unwrap());
assert!(db2.cache_entries_by_kind("blob").unwrap().is_empty());
let c = ContentCache::open(content, pins, 8, db2.clone()).unwrap();
assert_eq!(db2.cache_entries_by_kind("blob").unwrap().len(), 3);
// With the index rebuilt, a further store enforces the budget against the
// reconciled entries instead of leaking them.
c.store(&uid("d"), 1, 4, b"dddd").unwrap();
let total: u64 = db2
.cache_entries_by_kind("blob")
.unwrap()
.iter()
.map(|(_, s)| s)
.sum();
assert!(total <= 8, "budget enforced after reconcile, total={total}");
}
#[test]
fn stale_metadata_is_a_miss() {
let (c, _d) = cache();
let u = uid("a");
c.store(&u, 100, 3, b"abc").unwrap();
// A newer mtime (new revision) invalidates the cached blob.
assert!(!c.is_cached(&u, 101, 3));
assert!(c.read_range(&u, 101, 3, 0, 3).is_none());
// A size mismatch also invalidates it.
assert!(c.read_range(&u, 100, 4, 0, 3).is_none());
}
#[test]
fn legacy_pins_json_imported_then_removed() {
let dir = TempDir::new();
let pins = dir.path().join("pins.json");
// Hand-write a pre-P5 pins.json (file pins only, no `recursive` field).
std::fs::write(
&pins,
r#"{"pins":{"vol~a":{"uid":"vol~a","path":"docs/a.txt"}}}"#,
)
.unwrap();
let db = Arc::new(Db::open_in_memory().unwrap());
let c = ContentCache::open(dir.path().join("content"), pins.clone(), 0, db).unwrap();
// Imported into the DB as a non-recursive pin...
assert!(c.is_pinned(&uid("a")));
let listed = c.list_pins();
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].path, "docs/a.txt");
assert!(!listed[0].recursive);
// ...and the legacy file is gone so it never re-imports.
assert!(!pins.exists());
}
#[test]
fn pin_lifecycle_evicts_on_unpin() {
let (c, _d) = cache();
let u = uid("a");
c.store(&u, 100, 3, b"abc").unwrap();
c.add_pin(&u, Path::new("docs/a.txt"), false).unwrap();
assert!(c.is_pinned(&u));
assert_eq!(c.list_pins().len(), 1);
assert_eq!(c.list_pins()[0].path, "docs/a.txt");
c.remove_pin(&u).unwrap();
assert!(!c.is_pinned(&u));
assert!(c.list_pins().is_empty());
// Unpin evicts the cached content.
assert!(!c.is_cached(&u, 100, 3));
}
/// The under-budget path — which is every store until the cache fills — must
/// not touch the database at all. It is on the cold-read hot path: one call
/// per cached 4 MiB block, under the connection lock every FUSE metadata
/// call also needs.
#[test]
fn budget_check_is_free_when_under_budget() {
// Cap far above what we store, so every store stays under budget.
let (c, _d) = cache_capped(1 << 30);
let payload = vec![0u8; 4096];
for i in 0..8u64 {
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
// Plant an index row far over the cap behind the running total's back,
// and older than every real block. Any budget check that consults the
// index would see the pool over budget, evict, and reseed the total.
c.db.cache_touch("planted", KIND_BLOCK, 1 << 40, 0).unwrap();
c.store_block(&uid("a"), 1, 1 << 30, blk(1 << 30, 8), &payload)
.unwrap();
// Not a timing ratio: that measured the CI runner's load and flaked.
assert_eq!(c.block_bytes.load(Ordering::Relaxed), 9 * 4096);
let entries = c.db.cache_entries_by_kind(KIND_BLOCK).unwrap();
assert_eq!(entries.len(), 10, "under-budget store evicted: {entries:?}");
}
/// The running totals are a fast path, not a second source of truth: after
/// an eviction pass they are realigned with the index, so repeated
/// over-budget stores cannot drift them.
#[test]
fn running_totals_stay_aligned_with_the_index() {
let (c, _d) = cache_with_pool_cap(KIND_BLOCK, 8);
for i in 0..12u64 {
c.store_block(&uid("a"), 1, 100, blk(100, i), b"aaaa")
.unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
}
let indexed: u64 =
c.db.cache_entries_by_kind("block")
.unwrap()
.iter()
.map(|(_, s)| s)
.sum();
assert_eq!(
c.block_bytes.load(Ordering::Relaxed),
indexed,
"running total matches the index after repeated eviction"
);
assert!(indexed <= 8, "and the cap is actually held: {indexed}");
}
#[test]
fn budget_evicts_lru_unpinned() {
// Blob pool fits two 4-byte blobs but not three.
let (c, _d) = cache_with_pool_cap(KIND_BLOB, 8);
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store(&a, 1, 4, b"aaaa").unwrap();
// Distinct mtimes so LRU order is deterministic (oldest = a).
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&b, 1, 4, b"bbbb").unwrap();
// Touch `a` so `b` becomes the least-recently-used.
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(c.read_range(&a, 1, 4, 0, 4).is_some());
// Third store pushes over budget → evicts LRU unpinned (`b`).
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&d, 1, 4, b"dddd").unwrap();
assert!(c.is_cached(&a, 1, 4), "recently-read blob survives");
assert!(!c.is_cached(&b, 1, 4), "LRU blob evicted");
assert!(c.is_cached(&d, 1, 4), "newest blob survives");
}
#[test]
fn budget_never_evicts_pinned() {
let (c, _d) = cache_with_pool_cap(KIND_BLOB, 8);
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store(&a, 1, 4, b"aaaa").unwrap();
c.add_pin(&a, Path::new("a"), false).unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&b, 1, 4, b"bbbb").unwrap();
// Over budget: `a` is pinned (oldest) so `b` must be the victim.
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&d, 1, 4, b"dddd").unwrap();
assert!(c.is_cached(&a, 1, 4), "pinned blob never evicted");
assert!(!c.is_cached(&b, 1, 4), "unpinned LRU blob evicted");
assert!(c.is_cached(&d, 1, 4));
}
#[test]
fn clear_unpinned_keeps_pinned() {
let (c, _d) = cache();
let (a, b) = (uid("a"), uid("b"));
c.store(&a, 1, 4, b"aaaa").unwrap();
c.add_pin(&a, Path::new("a"), false).unwrap();
c.store(&b, 1, 4, b"bbbb").unwrap();
// A stray on-demand block of the unpinned file is purged too.
c.store_block(&b, 1, 8, blk(8, 0), b"bbbb").unwrap();
let freed = c.clear_unpinned();
assert_eq!(freed, 8, "one 4-byte blob + one 4-byte block freed");
assert!(c.is_cached(&a, 1, 4), "pinned blob survives purge");
assert!(!c.is_cached(&b, 1, 4), "unpinned blob purged");
assert!(
c.cached_block(&b, 1, 8, blk(8, 0)).is_none(),
"block purged"
);
}
#[test]
fn set_budget_evicts_immediately() {
// Start unlimited so three blobs all fit, then tighten the cap.
let (c, _d) = cache();
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store(&a, 1, 4, b"aaaa").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&b, 1, 4, b"bbbb").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store(&d, 1, 4, b"dddd").unwrap();
// Tightening to a budget whose blob share is 8 bytes evicts the LRU
// unpinned blob(s) now, not on the next store.
c.set_budget((8 * 100u64).div_ceil(SPLIT_BLOB));
assert!(!c.is_cached(&a, 1, 4), "oldest blob evicted on tighten");
assert!(c.is_cached(&b, 1, 4));
assert!(c.is_cached(&d, 1, 4));
// `budget()` reports the whole-cache figure that was set, not one
// pool's share of it.
assert_eq!(c.budget(), (8 * 100u64).div_ceil(SPLIT_BLOB));
}
/// **The A2 reproduce.** Thumbnail types 1 and 2 of one node must not share
/// a staging file. They are stored from different subsystems — the Photos
/// gallery caches type 1 from a control-socket thread while `getxattr`
/// caches type 2 on the FUSE dispatch loop — so the two genuinely overlap.
///
/// The failure is silent: the loser's rename either publishes the *other*
/// type's bytes under this type's name, or vanishes with ENOENT. Both are
/// reported here, because a fix that only removed the error would leave the
/// corruption.
#[test]
fn concurrent_thumbnail_types_do_not_share_a_temp_file() {
use std::sync::Arc as StdArc;
let (c, _d) = cache();
let c = StdArc::new(c);
let u = uid("a");
// Big enough that the write is not a single atomic-looking syscall, and
// distinguishable by content and length.
let one = vec![0xA1u8; 96 * 1024];
let two = vec![0xB2u8; 64 * 1024];
let mut handles = Vec::new();
for (ttype, payload) in [(1i32, one.clone()), (2i32, two.clone())] {
let c = c.clone();
let u = u.clone();
handles.push(std::thread::spawn(move || {
let mut errors = 0usize;
for _ in 0..400 {
if c.store_thumbnail(&u, ttype, 100, &payload).is_err() {
errors += 1;
}
}
errors
}));
}
let mut store_errors = 0usize;
for h in handles {
store_errors += h.join().expect("store thread panicked");
}
// Whatever is on disk for each type must be that type's bytes.
if let Some(got) = c.read_thumbnail(&u, 1, 100) {
assert_eq!(got, one, "type 1 served type 2's bytes");
}
if let Some(got) = c.read_thumbnail(&u, 2, 100) {
assert_eq!(got, two, "type 2 served type 1's bytes");
}
assert_eq!(
store_errors, 0,
"a store lost its staging file to the other type"
);
}
#[test]
fn thumbnail_store_read_and_evict() {
let (c, _d) = cache();
let u = uid("a");
c.store_thumbnail(&u, 1, 100, b"thumb1").unwrap();
c.store_thumbnail(&u, 2, 100, b"preview2").unwrap();
assert_eq!(c.read_thumbnail(&u, 1, 100).unwrap(), b"thumb1");
assert_eq!(c.read_thumbnail(&u, 2, 100).unwrap(), b"preview2");
// A newer revision (mtime bump) invalidates the cached thumbnail.
assert!(c.read_thumbnail(&u, 1, 101).is_none());
// Content eviction drops thumbnails too.
c.evict(&u);
assert!(c.read_thumbnail(&u, 1, 100).is_none());
assert!(c.read_thumbnail(&u, 2, 100).is_none());
}
/// Total bytes of every real file under `root`, recursively — what `du`
/// would report, and what `usage()` claims to be reporting.
fn bytes_on_disk(root: &Path) -> u64 {
let Ok(rd) = std::fs::read_dir(root) else {
return 0;
};
let mut total = 0;
for entry in rd.flatten() {
let Ok(meta) = entry.metadata() else { continue };
if meta.is_dir() {
total += bytes_on_disk(&entry.path());
} else if meta.is_file() {
total += meta.len();
}
}
total
}
/// C6/C7. Two claims the cache makes and does not keep.
///
/// The configured budget is the whole promise of the Settings page: it is
/// the only thing standing between an on-demand filesystem and a full disk.
/// A thumbnail per photo, on a library of tens of thousands, is not a
/// rounding error.
#[test]
fn every_pool_is_budgeted_and_visible() {
const CAP: u64 = 256 * 1024;
let (c, dir) = cache_capped(CAP);
let payload = vec![0u8; 8 * 1024];
// A gallery scroll: one thumbnail per photo, far past the cap.
for i in 0..200u64 {
c.store_thumbnail(&uid(&format!("photo{i}")), 1, 1, &payload)
.unwrap();
}
// Plus enough blob and block traffic to fill both other pools.
for i in 0..100u64 {
c.store(&uid(&format!("file{i}")), 1, payload.len() as u64, &payload)
.unwrap();
c.store_block(&uid("streamed"), 1, 1 << 30, blk(1 << 30, i), &payload)
.unwrap();
}
let on_disk = bytes_on_disk(dir.path());
let reported = c.usage();
// 1. The cap is a cap on the cache, not on one pool of it.
assert!(
on_disk <= CAP + CAP / 8,
"cache holds {on_disk} bytes against a {CAP}-byte budget"
);
// 2. And the number shown to the user is the number on disk. Meta
// sidecars are small and untracked, hence the tolerance.
let drift = on_disk.abs_diff(reported);
assert!(
drift <= on_disk / 8,
"usage() reports {reported} against {on_disk} on disk"
);
}
/// The thumbnail pool must evict by *use*, not by insertion order. A gallery
/// scrolled down and back up re-reads the tiles it already has; under FIFO
/// those are exactly the ones dropped, so every scroll back would re-fetch.
#[test]
fn thumbnails_evict_least_recently_used() {
// Pool fits two 4-byte thumbnails but not three.
let (c, _d) = cache_with_pool_cap(KIND_THUMB, 8);
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store_thumbnail(&a, 1, 1, b"aaaa").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store_thumbnail(&b, 1, 1, b"bbbb").unwrap();
// Look at `a` again, making `b` the least-recently-used.
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(c.read_thumbnail(&a, 1, 1).is_some());
std::thread::sleep(std::time::Duration::from_millis(10));
c.store_thumbnail(&d, 1, 1, b"dddd").unwrap();
assert!(c.read_thumbnail(&a, 1, 1).is_some(), "recently read, kept");
assert!(c.read_thumbnail(&b, 1, 1).is_none(), "least recently used");
assert!(c.read_thumbnail(&d, 1, 1).is_some(), "just stored");
}
/// Evicting a node drops its thumbnails from the index as well as from disk.
/// A row left behind would keep counting bytes that are gone, and the
/// running total would drift above the truth until the next restart.
#[test]
fn evicting_a_node_clears_its_thumbnail_accounting() {
let (c, _d) = cache();
let u = uid("a");
c.store(&u, 1, 4, b"aaaa").unwrap();
c.store_thumbnail(&u, 1, 1, b"thumb1").unwrap();
c.store_thumbnail(&u, 2, 1, b"preview2").unwrap();
assert!(c.thumb_bytes.load(Ordering::Relaxed) > 0);
c.evict(&u);
assert_eq!(c.thumb_bytes.load(Ordering::Relaxed), 0);
assert!(c.db.cache_entries_by_kind(KIND_THUMB).unwrap().is_empty());
assert_eq!(c.usage(), 0, "nothing cached, nothing reported");
}
#[test]
fn evict_removes_blob() {
let (c, _d) = cache();
let u = uid("a");
c.store(&u, 1, 3, b"abc").unwrap();
c.evict(&u);
assert!(!c.is_cached(&u, 1, 3));
}
#[test]
fn block_store_then_read() {
let (c, _d) = cache();
let u = uid("a");
// A 10-byte file is one block of 10 bytes: the length a reader expects
// is derived from the file size, so the fixture has to be geometrically
// real or the torn-write check rejects it.
c.store_block(&u, 100, 10, blk(10, 0), b"block-zero")
.unwrap();
assert_eq!(
c.cached_block(&u, 100, 10, blk(10, 0)).unwrap(),
b"block-zero"
);
// A different index is a separate cache entry, absent here.
assert!(c.cached_block(&u, 100, 10, blk(10, 1)).is_none());
// A new revision (mtime/size bump) invalidates the block.
assert!(c.cached_block(&u, 101, 10, blk(10, 0)).is_none());
assert!(c.cached_block(&u, 100, 5000, blk(5000, 0)).is_none());
}
#[test]
fn a_cached_blob_is_copied_out_whole() {
let (c, d) = cache();
let u = uid("blob");
c.store(&u, 7, 5, b"hello").unwrap();
let dst = d.path().join("out");
assert!(c.copy_cached_to(&u, 7, 5, &dst));
assert_eq!(std::fs::read(&dst).unwrap(), b"hello");
// Another revision is not this one.
assert!(!c.copy_cached_to(&u, 8, 5, &d.path().join("stale")));
assert!(!d.path().join("stale").exists());
}
#[test]
fn cached_blocks_are_copied_out_in_order() {
let (c, d) = cache();
let u = uid("blocks");
let size = BLOCK_SIZE + 3;
let first = vec![1_u8; BLOCK_SIZE as usize];
c.store_block(&u, 7, size, blk(size, 0), &first).unwrap();
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
let dst = d.path().join("out");
assert!(c.copy_cached_to(&u, 7, size, &dst));
let bytes = std::fs::read(&dst).unwrap();
assert_eq!(bytes.len() as u64, size);
assert_eq!(&bytes[..BLOCK_SIZE as usize], first.as_slice());
assert_eq!(&bytes[BLOCK_SIZE as usize..], b"end");
}
#[test]
fn a_file_with_a_block_missing_is_not_copied_out() {
let (c, d) = cache();
let u = uid("partial");
let size = BLOCK_SIZE + 3;
c.store_block(&u, 7, size, blk(size, 1), b"end").unwrap();
let dst = d.path().join("out");
assert!(!c.copy_cached_to(&u, 7, size, &dst));
assert!(!dst.exists(), "a partial copy must not be left behind");
}
/// The [`BlockSpan`] the uniform geometry gives block `idx` of a file of
/// `size` bytes — the shape these tests were written against, and the one
/// this client's own uploads still have.
fn blk(size: u64, idx: u64) -> BlockSpan {
BlockSpan {
idx,
start: idx.saturating_mul(BLOCK_SIZE),
len: block_len(size, idx),
}
}
/// The geometry the whole read path validates against, here and over the
/// network (`docs/BUGS.md` B84): full blocks, then whatever is left.
#[test]
fn block_len_is_full_blocks_then_the_remainder() {
let size = BLOCK_SIZE * 2 + 17;
assert_eq!(block_len(size, 0), BLOCK_SIZE);
assert_eq!(block_len(size, 1), BLOCK_SIZE);
assert_eq!(block_len(size, 2), 17);
// Past the end there is no block, not a short one.
assert_eq!(block_len(size, 3), 0);
// An exact multiple has no short tail at all.
assert_eq!(block_len(BLOCK_SIZE, 0), BLOCK_SIZE);
assert_eq!(block_len(BLOCK_SIZE, 1), 0);
}
/// The uniform geometry has to reproduce the constant-block arithmetic it
/// replaces exactly, or every file this client uploaded changes shape.
#[test]
fn the_uniform_geometry_is_the_old_block_arithmetic() {
let size = BLOCK_SIZE * 2 + 17;
let g = BlockGeometry::uniform(size);
assert_eq!(g.size(), size);
assert_eq!(g.blocks(), 3);
for idx in 0..3 {
let span = g.span(idx).unwrap();
assert_eq!(span.start, idx * BLOCK_SIZE);
assert_eq!(span.len, block_len(size, idx));
}
assert!(g.span(3).is_none(), "past the end there is no block");
// An empty file has no blocks at all, and no range covers anything.
let empty = BlockGeometry::uniform(0);
assert_eq!(empty.blocks(), 0);
assert!(empty.spans(0, 10).is_empty());
}
/// The B85 case: block sizes that are neither 4 MiB nor uniform. Offsets
/// have to come from the prefix sum, not from a constant.
#[test]
fn a_non_uniform_geometry_places_blocks_by_prefix_sum() {
let sizes = [5_239_804_u64, 5_670_228, 5_233_034];
let g = BlockGeometry::from_sizes(&sizes);
assert_eq!(g.size(), sizes.iter().sum::<u64>());
assert_eq!(g.blocks(), 3);
assert_eq!(g.span(0).unwrap().start, 0);
assert_eq!(g.span(1).unwrap().start, 5_239_804);
assert_eq!(g.span(2).unwrap().start, 5_239_804 + 5_670_228);
assert_eq!(g.span(2).unwrap().end(), g.size());
// A one-byte read in the middle of block 1 asks for block 1 alone —
// under the 4 MiB assumption the same offset falls in block 2.
let at = 6_000_000;
assert_eq!(g.index_at(at), Some(1));
assert_eq!(BlockGeometry::uniform(g.size()).index_at(at), Some(1));
let spans = g.spans(at, at + 1);
assert_eq!(spans.len(), 1);
assert_eq!(spans[0].idx, 1);
// A range crossing a boundary asks for both, and only both.
let crossing = g.spans(5_239_803, 5_239_805);
assert_eq!(
crossing.iter().map(|s| s.idx).collect::<Vec<_>>(),
vec![0, 1]
);
// At and past the end there is nothing to read.
assert_eq!(g.index_at(g.size()), None);
assert!(g.spans(g.size(), g.size() + 100).is_empty());
assert!(g.spans(10, 10).is_empty(), "an empty range wants no blocks");
// The single-block case from the same log: one block holding the whole
// file, so there is no block 1 to ask for.
let whole = BlockGeometry::from_sizes(&[4_939_506]);
assert_eq!(whole.blocks(), 1);
assert!(whole.span(1).is_none());
assert_eq!(whole.spans(0, 4_939_506).len(), 1);
}
/// A cached block is addressed by the range it covers, not by its index
/// alone. Re-planning a file onto its real geometry must therefore miss the
/// blocks the uniform fallback cached, rather than serve their bytes at the
/// wrong offset (`docs/BUGS.md` B85).
#[test]
fn a_cached_block_is_not_served_to_a_different_geometry() {
let (c, _d) = cache();
let u = uid("geom");
let size = BLOCK_SIZE * 2;
let uniform = BlockGeometry::uniform(size);
let first = uniform.span(0).unwrap();
let payload = vec![7u8; first.len as usize];
c.store_block(&u, 1, size, first, &payload).unwrap();
assert!(c.has_block(&u, 1, size, first), "cached where it was put");
// The same file, re-planned: block 0 is now a different length, and
// block 1 starts somewhere the old plan never had a block.
let real = BlockGeometry::from_sizes(&[BLOCK_SIZE + 1, BLOCK_SIZE - 1]);
assert_eq!(real.size(), size);
assert!(
!c.has_block(&u, 1, size, real.span(0).unwrap()),
"same index, different length: not the block that was cached"
);
assert!(
c.cached_block(&u, 1, size, real.span(1).unwrap()).is_none(),
"same index, different start: not the block that was cached"
);
}
/// The geometry sidecar is what lets a later read plan without opening a
/// reader. It is tied to the revision, and refused if it does not describe
/// a file of the size being read.
#[test]
fn a_recorded_geometry_is_returned_only_for_the_revision_it_describes() {
let (c, _d) = cache();
let u = uid("geom");
let sizes = [5_239_804_u64, 5_670_228];
let size: u64 = sizes.iter().sum();
assert!(
c.block_geometry(&u, 1, size).is_none(),
"nothing is known before the first read"
);
c.store_block_geometry(&u, 1, size, &sizes);
assert_eq!(
c.block_geometry(&u, 1, size),
Some(BlockGeometry::from_sizes(&sizes))
);
assert!(
c.block_geometry(&u, 2, size).is_none(),
"a new revision bumps the mtime, and this table described the old one"
);
assert!(c.block_geometry(&u, 1, size + 1).is_none());
// A table that does not add up to the file is refused outright rather
// than used to place blocks that would land wrongly.
c.store_block_geometry(&u, 1, size, &[1, 2, 3]);
assert!(c.block_geometry(&u, 1, size).is_none());
// Evicting the file's content takes its geometry with it, so the next
// read does not plan against a table for bytes the cache no longer has.
c.store_block_geometry(&u, 1, size, &sizes);
c.evict(&u);
assert!(c.block_geometry(&u, 1, size).is_none());
}
/// A block of the wrong length cannot escape the cache even though the
/// store does not check: the read validates it and reports a miss, so the
/// next read refetches rather than serving a hole (`docs/BUGS.md` B84).
#[test]
fn a_block_of_the_wrong_length_reads_as_a_miss() {
let (c, _d) = cache();
let u = uid("a");
// A 10-byte file's only block is 10 bytes; 5 is not a short tail.
c.store_block(&u, 100, 10, blk(10, 0), b"block").unwrap();
assert!(c.cached_block(&u, 100, 10, blk(10, 0)).is_none());
assert!(!c.has_block(&u, 100, 10, blk(10, 0)));
}
/// The block cache is written without an fsync, so a crash can leave a blob
/// shorter than the sidecar beside it claims. Serving that as content would
/// hand the reader a hole; it has to read as a miss.
#[test]
fn a_truncated_block_reads_as_a_miss() {
let (c, _d) = cache();
let u = uid("a");
c.store_block(&u, 100, 10, blk(10, 0), b"block-zero")
.unwrap();
std::fs::write(c.block_blob(&u, 0), b"block").unwrap();
assert!(c.cached_block(&u, 100, 10, blk(10, 0)).is_none());
}
#[test]
fn evict_drops_blocks() {
let (c, _d) = cache();
let u = uid("a");
// Both blocks are stored at their real geometry (a 4-byte file is one
// block), so this fails if `evict` misses them rather than because the
// fixture was unreadable to begin with.
c.store_block(&u, 1, 4, blk(4, 0), b"aaaa").unwrap();
assert!(c.cached_block(&u, 1, 4, blk(4, 0)).is_some());
c.evict(&u);
assert!(c.cached_block(&u, 1, 4, blk(4, 0)).is_none());
}
#[test]
fn block_budget_evicts_lru() {
// Block pool fits two 4-byte blocks but not three. One block per file,
// so each fixture is a geometrically real single-block file.
let (c, _d) = cache_with_pool_cap(KIND_BLOCK, 8);
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store_block(&a, 1, 4, blk(4, 0), b"aaaa").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store_block(&b, 1, 4, blk(4, 0), b"bbbb").unwrap();
// Touch a's block so b's is the least-recently-used.
std::thread::sleep(std::time::Duration::from_millis(10));
assert!(c.cached_block(&a, 1, 4, blk(4, 0)).is_some());
std::thread::sleep(std::time::Duration::from_millis(10));
c.store_block(&d, 1, 4, blk(4, 0), b"cccc").unwrap();
assert!(
c.cached_block(&a, 1, 4, blk(4, 0)).is_some(),
"recently-read survives"
);
assert!(
c.cached_block(&b, 1, 4, blk(4, 0)).is_none(),
"LRU block evicted"
);
assert!(
c.cached_block(&d, 1, 4, blk(4, 0)).is_some(),
"newest survives"
);
}
/// A failed upload must never cost the user their bytes:
/// the scratch file moves to staging intact, and staging survives a reopen
/// the way scratch deliberately does not.
#[test]
fn failed_write_is_staged_and_survives_reopen() {
use std::io::Write as _;
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let content = dir.path().join("content");
let c = ContentCache::open(content.clone(), dir.path().join("pins.json"), 0, db.clone())
.unwrap();
let (mut f, scratch) = c.create_scratch().unwrap();
f.write_all(b"unsent work").unwrap();
drop(f);
// A partial overwrite: only the first 11 bytes are real, so the staged
// file must not be mistaken for uploadable whole-file content.
let meta = StagedWrite {
uid: uid("a").to_string(),
len: 40,
base_size: 40,
base_mtime: 100,
authored: vec![(0, 11)],
complete: false,
based_on: Some(Baseline {
mtime: 100,
size: 40,
hash: None,
revision_id: None,
}),
};
let staged = c.stage_write(&meta, &scratch).unwrap();
assert!(!scratch.exists(), "scratch is moved, not copied");
assert_eq!(std::fs::read(&staged).unwrap(), b"unsent work");
let sidecar: StagedWrite =
serde_json::from_slice(&std::fs::read(staged.with_extension("json")).unwrap()).unwrap();
assert_eq!(sidecar.authored, vec![(0, 11)]);
assert!(
!sidecar.complete,
"gaps are zeros, not content: uploading this as-is would corrupt the file"
);
// Reopening wipes scratch (worthless leftovers) but must keep staging.
drop(c);
let _c2 = ContentCache::open(content, dir.path().join("pins.json"), 0, db).unwrap();
assert_eq!(
std::fs::read(&staged).unwrap(),
b"unsent work",
"staged bytes outlive the daemon that failed to upload them"
);
}
fn preservation_meta() -> StagedWrite {
StagedWrite {
uid: uid("preserved").to_string(),
len: 14,
base_size: 0,
base_mtime: 100,
authored: vec![(0, 14)],
complete: true,
based_on: None,
}
}
#[test]
fn orphan_preservation_publishes_exact_bytes_then_removes_source() {
use std::io::Write as _;
let (c, _dir) = cache();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"accepted bytes").unwrap();
file.sync_all().unwrap();
drop(file);
let preserved = c.preserve_write(&preservation_meta(), &scratch).unwrap();
assert!(!scratch.exists());
assert_eq!(std::fs::read(&preserved).unwrap(), b"accepted bytes");
let sidecar: StagedWrite =
serde_json::from_slice(&std::fs::read(preserved.with_extension("json")).unwrap())
.unwrap();
let expected = preservation_meta();
assert_eq!(sidecar.uid, expected.uid);
assert_eq!(sidecar.len, expected.len);
assert_eq!(sidecar.authored, expected.authored);
assert_eq!(sidecar.complete, expected.complete);
}
#[test]
fn orphan_preservation_sidecar_failure_reports_surviving_blob_across_reopen() {
use std::io::Write as _;
let (c, dir) = cache();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"only surviving copy").unwrap();
file.sync_all().unwrap();
drop(file);
let destination = c.staging_dir.join("forced-preservation-failure");
let side = destination.with_extension("json");
std::fs::create_dir(&side).unwrap();
let error = c
.preserve_write_at(&preservation_meta(), &scratch, &destination)
.unwrap_err();
assert_eq!(error.surviving_path, scratch);
assert!(
scratch.exists(),
"failed sidecar publication must roll the blob back beside its marker"
);
assert_eq!(
std::fs::read(&error.surviving_path).unwrap(),
b"only surviving copy"
);
assert_eq!(
error.marker_path,
Some(ContentCache::scratch_sidecar(&scratch))
);
assert!(
side.is_dir(),
"the induced sidecar failure remains observable"
);
let content = dir.path().join("content");
let pins = dir.path().join("pins-reopen.json");
drop(c);
let reopened =
ContentCache::open(content, pins, 0, Arc::new(Db::open_in_memory().unwrap())).unwrap();
let recovered = reopened.recovered_writes();
assert_eq!(recovered.len(), 1);
assert_eq!(
std::fs::read(&recovered[0].0).unwrap(),
b"only surviving copy"
);
assert_eq!(recovered[0].1.uid, preservation_meta().uid);
drop(reopened);
}
/// `clone_or_copy` has three strategies and only the last one is portable,
/// so what matters is that the destination is byte-identical whichever the
/// filesystem under `/tmp` happens to take — including when it already held
/// longer content, which a reflink or a short `copy_file_range` must not
/// leave a tail of.
#[test]
fn clone_or_copy_reproduces_the_source_over_any_existing_destination() {
let dir = TempDir::new();
let src = dir.path().join("src");
let dst = dir.path().join("dst");
std::fs::write(&src, b"the new content").unwrap();
std::fs::write(&dst, vec![b'x'; 4096]).unwrap();
let len = ContentCache::clone_or_copy(&src, &dst).unwrap();
assert_eq!(len, 15);
assert_eq!(std::fs::read(&dst).unwrap(), b"the new content");
}
/// An empty staged blob is a real case — a file truncated to zero and
/// closed — and `FICLONE` of a zero-length source is a documented `EINVAL`.
#[test]
fn clone_or_copy_handles_an_empty_source() {
let dir = TempDir::new();
let src = dir.path().join("empty");
let dst = dir.path().join("dst");
std::fs::write(&src, b"").unwrap();
std::fs::write(&dst, b"stale").unwrap();
assert_eq!(ContentCache::clone_or_copy(&src, &dst).unwrap(), 0);
assert!(std::fs::read(&dst).unwrap().is_empty());
}
/// A2: `fsync(2)` promises the bytes survive a crash, but queueing happens
/// at `release`. A scratch file marked durable must therefore outlive the
/// open that clears the scratch directory — and an unmarked one must not,
/// since that is a crashed run's rubbish.
#[test]
fn fsynced_scratch_survives_reopen_and_unmarked_scratch_does_not() {
use std::io::Write as _;
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
let (mut synced, synced_path) = c.create_scratch().unwrap();
synced.write_all(b"fsynced bytes").unwrap();
synced.sync_all().unwrap();
let meta = StagedWrite {
uid: uid("a").to_string(),
len: 13,
base_size: 0,
base_mtime: 100,
authored: vec![(0, 13)],
complete: true,
based_on: None,
};
c.mark_scratch_durable(&synced_path, &meta).unwrap();
// A second handle that was written but never fsynced: no promise was
// made about it, and it is indistinguishable from a torn buffer.
let (mut loose, loose_path) = c.create_scratch().unwrap();
loose.write_all(b"never fsynced").unwrap();
// Crash: no release, no staging, just a restart.
drop(c);
let c2 = ContentCache::open(content, pins, 0, db).unwrap();
assert!(!synced_path.exists(), "scratch dir is still cleared");
assert!(!loose_path.exists());
let recovered = c2.recovered_writes();
assert_eq!(recovered.len(), 1, "only the fsynced write is recoverable");
let (blob, got) = &recovered[0];
assert_eq!(std::fs::read(blob).unwrap(), b"fsynced bytes");
assert_eq!(got.uid, uid("a").to_string());
assert_eq!(got.authored, vec![(0, 13)]);
// Staging it is what a restart does with it; the sidecar goes after.
let staged = c2.stage_write(got, blob).unwrap();
c2.discard_recovered(blob);
assert_eq!(std::fs::read(&staged).unwrap(), b"fsynced bytes");
assert!(c2.recovered_writes().is_empty(), "recovery does not repeat");
}
#[test]
fn recovery_keeps_an_orphan_blob_when_its_sidecar_is_missing() {
let (c, dir) = cache();
let orphan = dir.path().join("content/recovery/orphaned-user-bytes");
std::fs::write(&orphan, b"retain me").unwrap();
assert!(c.recovered_writes().is_empty());
assert_eq!(
std::fs::read(&orphan).unwrap(),
b"retain me",
"metadata loss must not trigger deletion of unexplained content"
);
}
#[test]
fn failed_scratch_rescue_does_not_delete_the_durable_source() {
use std::io::Write as _;
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"durable source").unwrap();
file.sync_all().unwrap();
let meta = StagedWrite {
uid: uid("rescue").to_string(),
len: 14,
base_size: 0,
base_mtime: 0,
authored: vec![(0, 14)],
complete: true,
based_on: None,
};
c.mark_scratch_durable(&scratch, &meta).unwrap();
let blocking_destination = content.join("recovery").join(scratch.file_name().unwrap());
std::fs::create_dir(&blocking_destination).unwrap();
drop(file);
drop(c);
let _c2 = ContentCache::open(content, pins, 0, db).unwrap();
assert_eq!(std::fs::read(&scratch).unwrap(), b"durable source");
assert!(ContentCache::scratch_sidecar(&scratch).exists());
}
/// Releasing a write hands the bytes to staging, so the sidecar an earlier
/// `fsync` left must not offer recovery a second, stale copy of it.
#[test]
fn cleared_durability_marker_stops_recovery() {
use std::io::Write as _;
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
let (mut f, path) = c.create_scratch().unwrap();
f.write_all(b"released").unwrap();
let meta = StagedWrite {
uid: uid("a").to_string(),
len: 8,
base_size: 0,
base_mtime: 100,
authored: vec![(0, 8)],
complete: true,
based_on: None,
};
c.mark_scratch_durable(&path, &meta).unwrap();
c.clear_scratch_durable(&path);
drop(c);
let c2 = ContentCache::open(content, pins, 0, db).unwrap();
assert!(c2.recovered_writes().is_empty());
}
/// A directory on a *different* filesystem from the cache, so the
/// cross-device fallbacks can be driven for real rather than reasoned
/// about. `None` when this machine offers no second filesystem to use, in
/// which case the callers skip rather than fail — the fallback is still
/// covered by the same-device paths, and a green suite that quietly means
/// "not checked" is worse than a skip that says so.
fn other_device_dir(label: &str) -> Option<PathBuf> {
use std::os::unix::fs::MetadataExt as _;
let here = std::fs::metadata(std::env::temp_dir()).ok()?.dev();
let candidate = Path::new("/dev/shm");
if std::fs::metadata(candidate).ok()?.dev() == here {
return None;
}
let dir = candidate.join(format!("pdfs-xdev-{label}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).ok()?;
Some(dir)
}
// ---- B50: an orphaned write's only copy is never the one deleted -------
/// A staging directory that cannot be written to at all. Rename fails, the
/// copy fallback fails, and there is nowhere else to put the bytes — so the
/// scratch file, which is the only copy, has to still be there and still be
/// the bytes the user wrote.
#[test]
fn orphan_preservation_keeps_the_scratch_copy_when_staging_is_unwritable() {
use std::io::Write as _;
use std::os::unix::fs::PermissionsExt as _;
let (c, _dir) = cache();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"the only copy there is").unwrap();
file.sync_all().unwrap();
drop(file);
std::fs::set_permissions(&c.staging_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
let error = c
.preserve_write(&preservation_meta(), &scratch)
.unwrap_err();
std::fs::set_permissions(&c.staging_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
assert_eq!(error.surviving_path, scratch);
assert_eq!(
std::fs::read(&scratch).unwrap(),
b"the only copy there is",
"byte-identical, and still where the error says it is"
);
assert!(
error.marker_path.is_some(),
"and carrying the marker that makes it recoverable"
);
}
/// The same failure, seen from the next startup: the retained scratch file
/// is not just present, it is *discoverable* — recovery finds it with its
/// metadata, which is what makes it a preserved write rather than debris.
#[test]
fn a_retained_orphan_write_is_discoverable_after_a_restart() {
use std::io::Write as _;
use std::os::unix::fs::PermissionsExt as _;
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"accepted bytes").unwrap();
file.sync_all().unwrap();
drop(file);
std::fs::set_permissions(&c.staging_dir, std::fs::Permissions::from_mode(0o500)).unwrap();
assert!(c.preserve_write(&preservation_meta(), &scratch).is_err());
std::fs::set_permissions(&c.staging_dir, std::fs::Permissions::from_mode(0o700)).unwrap();
drop(c);
let reopened = ContentCache::open(content, pins, 0, db).unwrap();
let recovered = reopened.recovered_writes();
assert_eq!(recovered.len(), 1);
assert_eq!(std::fs::read(&recovered[0].0).unwrap(), b"accepted bytes");
assert_eq!(recovered[0].1.uid, preservation_meta().uid);
assert_eq!(recovered[0].1.authored, preservation_meta().authored);
}
/// The copy fallback, driven across a real device boundary. Its whole point
/// is ordering: the destination and its sidecar are published and synced
/// *first*, and only then is the source removed.
#[test]
fn orphan_preservation_across_a_device_boundary_publishes_before_removing() {
use std::io::Write as _;
let Some(elsewhere) = other_device_dir("preserve") else {
eprintln!("skipped: no second filesystem available to cross");
return;
};
let (c, _dir) = cache();
let scratch = elsewhere.join("scratch-blob");
{
let mut file = std::fs::File::create(&scratch).unwrap();
file.write_all(b"accepted bytes").unwrap();
file.sync_all().unwrap();
}
let preserved = c.preserve_write(&preservation_meta(), &scratch).unwrap();
assert_eq!(std::fs::read(&preserved).unwrap(), b"accepted bytes");
assert!(
preserved.with_extension("json").is_file(),
"the sidecar is published before the source is dropped"
);
assert!(!scratch.exists(), "and only then is the source removed");
assert!(
!ContentCache::scratch_sidecar(&scratch).exists(),
"the source's durability marker goes with it"
);
let _ = std::fs::remove_dir_all(&elsewhere);
}
/// And when publication across that boundary fails partway, the marked
/// source stays put. This is the case B50 was: the old code removed it.
#[test]
fn a_failed_cross_device_preservation_keeps_the_marked_source() {
use std::io::Write as _;
let Some(elsewhere) = other_device_dir("preserve-fail") else {
eprintln!("skipped: no second filesystem available to cross");
return;
};
let (c, _dir) = cache();
let scratch = elsewhere.join("scratch-blob");
{
let mut file = std::fs::File::create(&scratch).unwrap();
file.write_all(b"only surviving copy").unwrap();
file.sync_all().unwrap();
}
let destination = c.staging_dir.join("cross-device-failure");
std::fs::create_dir(destination.with_extension("json")).unwrap();
let error = c
.preserve_write_at(&preservation_meta(), &scratch, &destination)
.unwrap_err();
assert_eq!(error.surviving_path, scratch);
assert_eq!(
std::fs::read(&scratch).unwrap(),
b"only surviving copy",
"the source outlives a publication that did not finish"
);
assert_eq!(
error.marker_path,
Some(ContentCache::scratch_sidecar(&scratch)),
"and is marked, so the next startup rescues it"
);
let _ = std::fs::remove_dir_all(&elsewhere);
}
// ---- B51: the scratch sidecar is a durable, self-validating record -----
/// A sidecar is published by rename, so a crash leaves either the whole
/// record or none of it — never a half-written one under the real name.
/// The temporary it is renamed from must also not be mistaken for a record.
#[test]
fn a_half_written_scratch_sidecar_is_never_read_as_a_record() {
use std::io::Write as _;
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"authored bytes").unwrap();
file.sync_all().unwrap();
drop(file);
// A crash between writing the temporary and renaming it: exactly what
// is on disk if the process dies inside `mark_scratch_durable`.
let side = ContentCache::scratch_sidecar(&scratch);
std::fs::write(side.with_extension("json.tmp"), b"{\"uid\":\"prot").unwrap();
assert!(!side.exists(), "no record was ever published");
drop(c);
let reopened = ContentCache::open(content, pins, 0, db).unwrap();
assert!(
reopened.recovered_writes().is_empty(),
"an unpublished temporary describes no write"
);
}
/// A sidecar that survived but does not parse — a torn tail, a truncated
/// file — must not take its blob down with it. The bytes are the user's
/// work; the metadata only says what to do with them.
#[test]
fn a_corrupt_scratch_sidecar_still_rescues_its_bytes() {
use std::io::Write as _;
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"user work").unwrap();
file.sync_all().unwrap();
drop(file);
let meta = StagedWrite {
uid: uid("corrupt").to_string(),
len: 9,
base_size: 0,
base_mtime: 0,
authored: vec![(0, 9)],
complete: true,
based_on: None,
};
c.mark_scratch_durable(&scratch, &meta).unwrap();
// Truncate the published sidecar to a valid JSON prefix.
let side = ContentCache::scratch_sidecar(&scratch);
let full = std::fs::read(&side).unwrap();
std::fs::write(&side, &full[..full.len() / 2]).unwrap();
drop(c);
let reopened = ContentCache::open(content.clone(), pins, 0, db).unwrap();
assert!(
reopened.recovered_writes().is_empty(),
"an unparseable record cannot be replayed"
);
let rescued: Vec<PathBuf> = std::fs::read_dir(content.join("recovery"))
.unwrap()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_none_or(|e| e != "json"))
.collect();
assert_eq!(rescued.len(), 1, "the blob is still moved to recovery");
assert_eq!(std::fs::read(&rescued[0]).unwrap(), b"user work");
}
/// `mark_scratch_durable` is called repeatedly as a file is fsynced. Each
/// call has to leave one complete, parseable record and no debris.
#[test]
fn remarking_a_scratch_file_replaces_its_record_atomically() {
use std::io::Write as _;
let (c, _dir) = cache();
let (mut file, scratch) = c.create_scratch().unwrap();
file.write_all(b"first").unwrap();
file.sync_all().unwrap();
drop(file);
let mut meta = StagedWrite {
uid: uid("remark").to_string(),
len: 5,
base_size: 0,
base_mtime: 0,
authored: vec![(0, 5)],
complete: true,
based_on: None,
};
c.mark_scratch_durable(&scratch, &meta).unwrap();
meta.len = 11;
meta.authored = vec![(0, 11)];
c.mark_scratch_durable(&scratch, &meta).unwrap();
let side = ContentCache::scratch_sidecar(&scratch);
let got: StagedWrite = serde_json::from_slice(&std::fs::read(&side).unwrap()).unwrap();
assert_eq!(got.len, 11);
assert_eq!(got.authored, vec![(0, 11)]);
assert!(
!side.with_extension("json.tmp").exists(),
"the temporary is renamed, not left behind"
);
}
// ---- B52: staged-write publication is crash-safe -----------------------
/// The cross-filesystem half of `stage_write`. The source is copied, not
/// renamed, and must not be removed until the destination and its sidecar
/// are both on disk.
#[test]
fn staging_across_a_device_boundary_removes_the_source_last() {
use std::io::Write as _;
let Some(elsewhere) = other_device_dir("stage") else {
eprintln!("skipped: no second filesystem available to cross");
return;
};
let (c, _dir) = cache();
let scratch = elsewhere.join("scratch-blob");
{
let mut file = std::fs::File::create(&scratch).unwrap();
file.write_all(b"staged across devices").unwrap();
file.sync_all().unwrap();
}
let meta = preservation_meta();
let staged = c.stage_write(&meta, &scratch).unwrap();
assert_eq!(std::fs::read(&staged).unwrap(), b"staged across devices");
let sidecar: StagedWrite =
serde_json::from_slice(&std::fs::read(staged.with_extension("json")).unwrap()).unwrap();
assert_eq!(sidecar.uid, meta.uid);
assert!(
!scratch.exists(),
"the source goes last, and only on success"
);
let _ = std::fs::remove_dir_all(&elsewhere);
}
/// And when the sidecar cannot be published, the copied source stays: a
/// staged blob nothing describes is not a substitute for the bytes.
#[test]
fn a_failed_staging_sidecar_keeps_the_cross_device_source() {
use std::io::Write as _;
let Some(elsewhere) = other_device_dir("stage-fail") else {
eprintln!("skipped: no second filesystem available to cross");
return;
};
let (c, _dir) = cache();
let scratch = elsewhere.join("scratch-blob");
{
let mut file = std::fs::File::create(&scratch).unwrap();
file.write_all(b"still the source").unwrap();
file.sync_all().unwrap();
}
// Block the sidecar's temporary name with a directory, so the copy
// lands and only publication fails.
let meta = preservation_meta();
let destination = c.staging_dir.join("blocked-sidecar");
std::fs::create_dir(destination.with_extension("json.tmp")).unwrap();
assert!(c.stage_write_at(&meta, &scratch, &destination).is_err());
assert_eq!(
std::fs::read(&scratch).unwrap(),
b"still the source",
"an unpublished staging blob does not authorise deleting the source"
);
let _ = std::fs::remove_dir_all(&elsewhere);
}
/// A staged blob whose sidecar never landed is still user data. Startup
/// must leave both it and the leftover temporary alone rather than tidying
/// the bytes away.
#[test]
fn an_unaccompanied_staged_blob_survives_a_restart() {
let dir = TempDir::new();
let db = Arc::new(Db::open_in_memory().unwrap());
let content = dir.path().join("content");
let pins = dir.path().join("pins.json");
let c = ContentCache::open(content.clone(), pins.clone(), 0, db.clone()).unwrap();
let orphan = c.staging_dir.join("unaccompanied-bytes");
std::fs::write(&orphan, b"nobody describes me").unwrap();
let leftover = c.staging_dir.join("interrupted.json.tmp");
std::fs::write(&leftover, b"{\"uid\":\"prot").unwrap();
drop(c);
let _reopened = ContentCache::open(content, pins, 0, db).unwrap();
assert_eq!(
std::fs::read(&orphan).unwrap(),
b"nobody describes me",
"staging is never cleared on startup"
);
assert!(leftover.exists());
}
#[test]
fn scratch_file_is_writable_and_isolated() {
use std::io::{Read as _, Seek as _, SeekFrom, Write as _};
let (c, _d) = cache();
let (mut f1, p1) = c.create_scratch().unwrap();
let (_f2, p2) = c.create_scratch().unwrap();
assert_ne!(p1, p2, "each scratch file is unique");
f1.write_all(b"scratch").unwrap();
f1.seek(SeekFrom::Start(0)).unwrap();
let mut s = String::new();
f1.read_to_string(&mut s).unwrap();
assert_eq!(s, "scratch");
}
}