mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 08:56:59 +01:00
fix(fs) performance, locks
This commit is contained in:
1 parent
b37ab78451
commit
e396c6b4d6
18 files changed
+1000
-343
No files matched your search
+228
-61
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;",
|
||||
),
|
||||
];
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user