fix(fs) performance, locks

This commit is contained in:
nvrl committed 2026-08-15 18:49:07 +02:00
1 parent b37ab78451
commit e396c6b4d6
18 files changed
+1000 -343

No files matched your search

+228 -61
View File
@@ -16,7 +16,7 @@
//! [`AppDirs::cache_dir`]: crate::config::AppDirs::cache_dir
//! [`Node`]: proton_drive_rs::Node
use std::collections::{BTreeMap, HashSet};
use std::collections::{BTreeMap, HashMap, HashSet};
use std::fmt;
use std::io::{Read, Seek, SeekFrom};
use std::path::{Path, PathBuf};
@@ -27,6 +27,9 @@ 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};
@@ -74,6 +77,18 @@ fn now_millis() -> i64 {
/// `download_range` fetch with no straddling.
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.
fn block_len(size: u64, idx: u64) -> u64 {
let start = idx.saturating_mul(BLOCK_SIZE);
size.saturating_sub(start).min(BLOCK_SIZE)
}
/// 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
@@ -263,6 +278,15 @@ pub struct ContentCache {
/// store/read/evict updates it, and the budget enforcers query it instead of
/// scanning the cache directories (plan.md P4).
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>>,
}
impl ContentCache {
@@ -325,6 +349,7 @@ impl ContentCache {
block_bytes: AtomicU64::new(0),
thumb_bytes: AtomicU64::new(0),
db,
touches: Mutex::new(HashMap::new()),
};
cache.reconcile()?;
// After reconcile, so the totals describe the index that now describes
@@ -473,7 +498,7 @@ impl ContentCache {
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.
let _ = self.db.cache_accessed(&Self::key(uid), now_millis());
self.touch(Self::key(uid));
let mut f = std::fs::File::open(&blob).ok()?;
if offset >= size {
return Some(Vec::new());
@@ -575,10 +600,15 @@ impl ContentCache {
return None;
}
let mut f = std::fs::File::open(self.block_blob(uid, idx)).ok()?;
// LRU touch in the cache index (best effort).
let _ = self
.db
.cache_accessed(&self.block_key(uid, idx), now_millis());
// 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() != block_len(size, idx) {
return None;
}
// LRU touch, buffered rather than written (see `touch`).
self.touch(self.block_key(uid, idx));
let mut buf = Vec::new();
f.read_to_end(&mut buf).ok()?;
Some(buf)
@@ -603,7 +633,10 @@ impl ContentCache {
let mut f = std::fs::File::create(&tmp)?;
use std::io::Write;
f.write_all(bytes)?;
f.sync_all()?;
// 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(
@@ -622,6 +655,40 @@ impl ContentCache {
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] {
@@ -689,7 +756,10 @@ impl ContentCache {
return; // the common case: one atomic load, no query
}
// Over budget by the running count — now do the accurate work.
// 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,
@@ -727,6 +797,70 @@ impl ContentCache {
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)> {
@@ -806,8 +940,9 @@ impl ContentCache {
let _ = std::fs::remove_file(Self::scratch_sidecar(scratch));
}
/// Move every scratch file that carries a readable sidecar into `recovery`,
/// before the caller empties the scratch directory.
/// 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
@@ -828,43 +963,28 @@ impl ContentCache {
// Not marked durable, or durability marker was explicitly cleared on release
continue;
}
// Parsed if valid; if sidecar exists but is corrupted, build synthetic StagedWrite
// 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();
if !has_valid_sidecar {
if let Ok(meta) = std::fs::metadata(&blob)
&& meta.len() > 0
{
let blob_name = blob
.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let synthetic = StagedWrite {
uid: format!("recovered~{blob_name}"),
len: meta.len(),
base_size: meta.len(),
base_mtime: 0,
authored: vec![(0, meta.len())],
complete: true,
based_on: None,
};
if let Ok(data) = serde_json::to_vec(&synthetic) {
let _ = std::fs::write(&side, data);
} else {
continue;
}
} else {
continue;
}
}
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;
};
@@ -1210,9 +1330,7 @@ impl ContentCache {
// 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.
let _ = self
.db
.cache_accessed(&self.thumb_key(uid, ttype), now_millis());
self.touch(self.thumb_key(uid, ttype));
Some(bytes)
}
@@ -1243,9 +1361,7 @@ impl ContentCache {
if !blob.exists() {
return None;
}
let _ = self
.db
.cache_accessed(&self.thumb_key(uid, ttype), now_millis());
self.touch(self.thumb_key(uid, ttype));
Some(blob)
}
@@ -2004,45 +2120,63 @@ mod tests {
fn block_store_then_read() {
let (c, _d) = cache();
let u = uid("a");
c.store_block(&u, 100, 4096, 0, b"block-zero").unwrap();
assert_eq!(c.cached_block(&u, 100, 4096, 0).unwrap(), b"block-zero");
// 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, 0, b"block-zero").unwrap();
assert_eq!(c.cached_block(&u, 100, 10, 0).unwrap(), b"block-zero");
// A different index is a separate cache entry, absent here.
assert!(c.cached_block(&u, 100, 4096, 1).is_none());
assert!(c.cached_block(&u, 100, 10, 1).is_none());
// A new revision (mtime/size bump) invalidates the block.
assert!(c.cached_block(&u, 101, 4096, 0).is_none());
assert!(c.cached_block(&u, 101, 10, 0).is_none());
assert!(c.cached_block(&u, 100, 5000, 0).is_none());
}
/// 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, 0, b"block-zero").unwrap();
std::fs::write(c.block_blob(&u, 0), b"block").unwrap();
assert!(c.cached_block(&u, 100, 10, 0).is_none());
}
#[test]
fn evict_drops_blocks() {
let (c, _d) = cache();
let u = uid("a");
c.store_block(&u, 1, 8, 0, b"aaaa").unwrap();
c.store_block(&u, 1, 8, 1, b"bbbb").unwrap();
// 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, 0, b"aaaa").unwrap();
assert!(c.cached_block(&u, 1, 4, 0).is_some());
c.evict(&u);
assert!(c.cached_block(&u, 1, 8, 0).is_none());
assert!(c.cached_block(&u, 1, 8, 1).is_none());
assert!(c.cached_block(&u, 1, 4, 0).is_none());
}
#[test]
fn block_budget_evicts_lru() {
// Block pool fits two 4-byte blocks but not three.
// 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 u = uid("a");
c.store_block(&u, 1, 12, 0, b"aaaa").unwrap();
let (a, b, d) = (uid("a"), uid("b"), uid("d"));
c.store_block(&a, 1, 4, 0, b"aaaa").unwrap();
std::thread::sleep(std::time::Duration::from_millis(10));
c.store_block(&u, 1, 12, 1, b"bbbb").unwrap();
// Touch block 0 so block 1 is the least-recently-used.
c.store_block(&b, 1, 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(&u, 1, 12, 0).is_some());
assert!(c.cached_block(&a, 1, 4, 0).is_some());
std::thread::sleep(std::time::Duration::from_millis(10));
c.store_block(&u, 1, 12, 2, b"cccc").unwrap();
c.store_block(&d, 1, 4, 0, b"cccc").unwrap();
assert!(
c.cached_block(&u, 1, 12, 0).is_some(),
c.cached_block(&a, 1, 4, 0).is_some(),
"recently-read survives"
);
assert!(c.cached_block(&u, 1, 12, 1).is_none(), "LRU block evicted");
assert!(c.cached_block(&u, 1, 12, 2).is_some(), "newest survives");
assert!(c.cached_block(&b, 1, 4, 0).is_none(), "LRU block evicted");
assert!(c.cached_block(&d, 1, 4, 0).is_some(), "newest survives");
}
/// A failed upload must never cost the user their bytes (offline.md Phase 2):
@@ -2185,6 +2319,39 @@ mod tests {
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,
+24
View File
@@ -42,6 +42,30 @@ impl Db {
Ok(())
}
/// Bump `last_accessed` for many keys in one transaction.
///
/// The read path touches a key per 128 KiB the kernel asks for — 32 times
/// per 4 MiB block — and each of those was its own write transaction under
/// the single connection mutex, contending with every `lookup` and
/// `readdir`. Callers accumulate touches in memory and flush them here.
pub fn cache_accessed_batch(&self, touches: &[(String, i64)]) -> Result<()> {
if touches.is_empty() {
return Ok(());
}
let mut conn = self.conn.lock();
let tx = conn.transaction()?;
{
let mut stmt = tx.prepare_cached(
"UPDATE cache_entries SET last_accessed = ?2 WHERE cache_key = ?1",
)?;
for (key, at) in touches {
stmt.execute(params![key, at])?;
}
}
tx.commit()?;
Ok(())
}
/// Drop a single cache-entry row (one evicted blob or block).
pub fn cache_remove(&self, key: &str) -> Result<()> {
let conn = self.conn.lock();
+51 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 21;
pub(super) const SCHEMA_VERSION: i64 = 22;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -113,6 +113,24 @@ impl Db {
tx.execute_batch(MIGRATION_V21)?;
}
}
if current < 22 {
// Older fixtures (and a database that never grew a table because
// its feature was never used) may be missing any of these, and an
// index on an absent table is a hard error rather than a no-op.
for (table, sql) in MIGRATION_V22 {
let present: bool = tx.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = ?1",
[table],
|row| row.get::<_, i64>(0),
)? > 0;
if present {
tx.execute_batch(sql)?;
}
}
// `activity_time` indexes `time DESC` while both activity queries
// order by `id`: it has only ever cost writes.
tx.execute_batch("DROP INDEX IF EXISTS activity_time;")?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -583,3 +601,35 @@ SELECT n.rowid, n.name, paths.path
FROM nodes n JOIN paths ON paths.rowid = n.rowid
WHERE paths.trashed = 0;
";
/// V22: indexes for the lookups that were scanning, each paired with the table
/// it needs to exist for.
///
/// Each backs a query on a hot path that had no index to use:
///
/// - `sync_entry(remote_uid)` — the reconcile resolves a remote uid to its
/// local entry once per node, per pass.
/// - `local_files(scan_gen)` — the sweep that retires the previous generation
/// after a local index scan.
/// - `nodes(parent_uid) WHERE trashed = 0` — every listing, with the partial
/// index keeping it to the live rows a listing actually wants.
///
/// Deliberately *not* here: an index on `pending_op(next_attempt_at, id)`.
/// `next_due_op` orders by `id`, which is the rowid, so it already stops at the
/// first due row; offering the planner a `next_attempt_at` index makes it scan
/// that index and then sort the matches by `id` instead —
/// `next_due_op_does_not_scale_with_queue_length` measures the regression.
const MIGRATION_V22: [(&str, &str); 3] = [
(
"sync_entry",
"CREATE INDEX IF NOT EXISTS idx_sync_entry_remote_uid ON sync_entry(remote_uid);",
),
(
"local_files",
"CREATE INDEX IF NOT EXISTS idx_local_files_scan_gen ON local_files(scan_gen);",
),
(
"nodes",
"CREATE INDEX IF NOT EXISTS idx_nodes_parent_live ON nodes(parent_uid) WHERE trashed = 0;",
),
];
+32
View File
@@ -66,6 +66,20 @@ const WAL_SIZE_LIMIT: i64 = 64 * 1024 * 1024;
/// genuinely stuck holder surfaces as an error rather than an apparent hang.
const BUSY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
/// Page cache, in KiB (applied negated, which is how SQLite reads a size in KiB
/// rather than in pages). 32 MiB against a database that is typically tens of
/// MiB: large enough to hold the working set of a listing-heavy session, small
/// enough to be unremarkable in a desktop daemon's RSS.
const CACHE_SIZE_KIB: i64 = 32 * 1024;
/// Ceiling on the memory-mapped window over the database file. 256 MiB covers
/// any realistic index while keeping the mapping bounded.
const MMAP_SIZE: i64 = 256 * 1024 * 1024;
/// WAL pages between automatic checkpoints (default 1000, i.e. ~4 MiB). Halved
/// so the synchronous checkpoint a committing thread inherits stays short.
const WAL_AUTOCHECKPOINT_PAGES: i64 = 500;
/// Handle to the unified metadata database.
///
/// Cheap to wrap in an `Arc`; clone the `Arc`, not this. All access goes through
@@ -102,6 +116,24 @@ impl Db {
// (Bare SQLite does default to 0, which is where the belief that this
// was unset came from. That default is not what we get.)
conn.busy_timeout(BUSY_TIMEOUT)?;
// Page cache. The default is 2 MiB, against a schema whose hot table
// stores a JSON blob per node — a listing of a few thousand files
// evicts itself while it is being read.
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
// Sorts and recursive CTEs materialise their intermediate results.
// Every `ORDER BY … COLLATE NOCASE` listing and every ancestor walk
// spills those to a file in `/tmp` by default; they are small and
// short-lived, so memory is both faster and less surprising.
conn.pragma_update(None, "temp_store", "MEMORY")?;
// Read the database through the page cache of the OS rather than
// `pread` per page. Bounded rather than unlimited so the daemon's RSS
// does not track the database size.
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
// Checkpointing is synchronous in whichever thread happens to commit
// the transaction that crosses the threshold — often a FUSE callback.
// A smaller autocheckpoint makes that debt smaller and more frequent
// rather than rare and multi-second.
conn.pragma_update(None, "wal_autocheckpoint", WAL_AUTOCHECKPOINT_PAGES)?;
let db = Self {
conn: Mutex::new(conn),
+18 -8
View File
@@ -4,6 +4,7 @@
use rusqlite::{OptionalExtension, params};
use super::Db;
use super::utils::MAX_PATH_DEPTH;
use crate::Result;
/// One row of [`Db::pin_list`]. `recursive` is pin *policy* — whether the whole
@@ -66,6 +67,11 @@ impl Db {
/// is pinned recursively. The ancestor check walks `parent_uid` to the root
/// via a CTE; a direct pin is honoured even when the node has no `nodes` row
/// yet (e.g. a CLI that never hydrates the node cache).
///
/// The walk is depth-capped like [`super::utils::path_of`]: a `parent_uid`
/// cycle is corrupt data the API can hand us, and `UNION ALL` over one never
/// terminates — while holding the daemon's only SQLite connection, on a path
/// that runs per cached read.
pub fn is_pinned(&self, uid: &str) -> Result<bool> {
let conn = self.conn.lock();
let direct: Option<i64> = conn
@@ -78,14 +84,18 @@ impl Db {
}
let anc: Option<i64> = conn
.query_row(
"WITH RECURSIVE anc(uid, parent_uid) AS (
SELECT uid, parent_uid FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid FROM nodes n JOIN anc ON n.uid = anc.parent_uid
)
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
WHERE a.uid != ?1 AND p.recursive = 1
LIMIT 1",
&format!(
"WITH RECURSIVE anc(uid, parent_uid, depth) AS (
SELECT uid, parent_uid, 0 FROM nodes WHERE uid = ?1
UNION ALL
SELECT n.uid, n.parent_uid, anc.depth + 1
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
WHERE anc.depth < {MAX_PATH_DEPTH}
)
SELECT 1 FROM anc a JOIN pins p ON p.uid = a.uid
WHERE a.uid != ?1 AND p.recursive = 1
LIMIT 1"
),
params![uid],
|r| r.get(0),
)
+20
View File
@@ -2628,6 +2628,26 @@ fn non_recursive_folder_pin_does_not_cover_children() {
assert!(!db.is_pinned(&du("rep")).unwrap());
}
/// A `parent_uid` cycle is corrupt data the API can hand us, and `is_pinned`'s
/// ancestor walk is `UNION ALL` — uncapped it never terminates, while holding
/// the daemon's only SQLite connection, on a path that runs per cached read.
/// The answer here matters less than the fact that there is one.
#[test]
fn is_pinned_terminates_on_a_parent_cycle() {
let db = Db::open_in_memory().unwrap();
let du = |l: &str| uid(l).to_string();
// a -> b -> a, with nothing pinned above either.
db.upsert_node(&folder("a", Some("b"), "A")).unwrap();
db.upsert_node(&folder("b", Some("a"), "B")).unwrap();
db.upsert_node(&file("leaf", "a", "leaf.txt", 1)).unwrap();
assert!(!db.is_pinned(&du("leaf")).unwrap());
// And a pin *inside* the cycle is still found rather than walked past.
db.pin_add(&du("a"), "A", true).unwrap();
assert!(db.is_pinned(&du("leaf")).unwrap());
}
#[test]
fn schema_objects_exist() {
let db = Db::open_in_memory().unwrap();
+1 -1
View File
@@ -35,7 +35,7 @@ pub(super) fn like_escape(s: &str) -> String {
/// nowhere near this; a parent *cycle* is unbounded, and this query is on the
/// path of every search result, including the `LIKE` lane that reads `nodes`
/// directly and so never passed through the index's cycle check.
const MAX_PATH_DEPTH: usize = 256;
pub(super) const MAX_PATH_DEPTH: usize = 256;
/// Resolve a node's mountpoint-relative path by walking `parent_uid` to the
/// root via a recursive CTE. The root (the node with no parent) is excluded, so
+21
View File
@@ -45,6 +45,27 @@ pub enum Error {
Other(String),
}
impl Error {
/// Whether this is the local disk being full (`ENOSPC`), as opposed to any
/// other I/O failure.
///
/// Worth distinguishing because it is the one storage failure the user can
/// act on, and because the caller's response differs: a full disk is met by
/// evicting cached blobs and answering `ENOSPC`, where `EIO` would read as a
/// broken file. `std::io::ErrorKind::StorageFull` is still unstable, so this
/// compares the raw code.
pub fn is_disk_full(&self) -> bool {
match self {
Error::Io(e) => e.raw_os_error() == Some(libc::ENOSPC),
// SQLite reports it as its own code rather than surfacing the errno.
Error::Db(rusqlite::Error::SqliteFailure(e, _)) => {
e.code == rusqlite::ErrorCode::DiskFull
}
_ => false,
}
}
}
pub type Result<T> = std::result::Result<T, Error>;
/// A failure on its way to a front-end: prose for the user, plus the