mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-06 17:12:10 +01:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35418eea3f | ||
|
|
e48c4bb4b2 | ||
|
|
7ad9231858 | ||
|
|
e396c6b4d6 | ||
|
|
b37ab78451 | ||
|
|
6e3d2c335e |
No files matched your search
Generated
+4
-4
@@ -3518,7 +3518,7 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-cli"
|
||||
version = "1.5.0"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"clap 4.6.4",
|
||||
@@ -3533,7 +3533,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-core"
|
||||
version = "1.5.0"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"directories",
|
||||
@@ -3554,7 +3554,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-fuse"
|
||||
version = "1.5.0"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"fuser",
|
||||
"futures",
|
||||
@@ -3571,7 +3571,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdfs-gui"
|
||||
version = "1.5.0"
|
||||
version = "1.7.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-channel",
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ resolver = "2"
|
||||
members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.5.0"
|
||||
version = "1.7.0"
|
||||
edition = "2024"
|
||||
license = "MIT"
|
||||
authors = ["Nils Pukropp <contact@narl.io>"]
|
||||
|
||||
@@ -1336,6 +1336,11 @@ fn cmd_status() -> Result<()> {
|
||||
online,
|
||||
pending_uploads,
|
||||
pending_changes,
|
||||
parked_uploads,
|
||||
failing_ops,
|
||||
failing_error,
|
||||
staged_bytes,
|
||||
staged_oldest_secs,
|
||||
..
|
||||
}) => {
|
||||
let state = if online { "" } else { ", offline" };
|
||||
@@ -1344,6 +1349,25 @@ fn cmd_status() -> Result<()> {
|
||||
None => String::new(),
|
||||
};
|
||||
println!("Mounted at {mountpoint} ({pinned} pinned{state}{queued})");
|
||||
// Everything below is a queue that is not making progress on its
|
||||
// own. Printed only when it exists, because in the ordinary case
|
||||
// there is nothing to say.
|
||||
if staged_bytes > 0 {
|
||||
println!(
|
||||
"Staged {} not yet uploaded (oldest {})",
|
||||
human_bytes(staged_bytes),
|
||||
human_age(staged_oldest_secs),
|
||||
);
|
||||
}
|
||||
if parked_uploads > 0 {
|
||||
println!("Parked {parked_uploads} upload(s) waiting for a final filename");
|
||||
}
|
||||
if failing_ops > 0 {
|
||||
println!(
|
||||
"Stuck {failing_ops} queued operation(s) keep failing: {}",
|
||||
failing_error.as_deref().unwrap_or("no error recorded"),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(other) => println!("Mount: unexpected response {other:?}"),
|
||||
Err(_) => println!("Mount: not running."),
|
||||
@@ -1886,6 +1910,18 @@ fn path_arg(path: &Path) -> Result<String> {
|
||||
.ok_or_else(|| anyhow!("path is not valid UTF-8"))
|
||||
}
|
||||
|
||||
/// Format an age in seconds for a report line, coarsely — the point of the
|
||||
/// number is whether it is minutes or weeks.
|
||||
fn human_age(secs: u64) -> String {
|
||||
const UNITS: [(&str, u64); 4] = [("d", 86_400), ("h", 3_600), ("m", 60), ("s", 1)];
|
||||
for (unit, scale) in UNITS {
|
||||
if secs >= scale {
|
||||
return format!("{}{unit}", secs / scale);
|
||||
}
|
||||
}
|
||||
"just now".to_string()
|
||||
}
|
||||
|
||||
/// Format a byte count for a report line.
|
||||
fn human_bytes(bytes: u64) -> String {
|
||||
const UNITS: [(&str, f64); 4] = [
|
||||
@@ -1925,6 +1961,32 @@ fn cmd_cache(action: CacheCmd) -> Result<()> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask for a deep integrity check and wait for it to finish.
|
||||
///
|
||||
/// The daemon answers the request immediately and runs the check on a thread —
|
||||
/// it holds the single database connection for as long as it takes, which would
|
||||
/// otherwise stall the mount — so a caller that needs the verdict polls for it.
|
||||
/// `pdfs doctor` is that caller: it is a diagnostic the user is watching, so
|
||||
/// waiting is right, and the bound is generous enough for a large database
|
||||
/// without hanging forever if the daemon never finishes.
|
||||
fn await_integrity_check() -> Result<CtlResponse> {
|
||||
const POLL: std::time::Duration = std::time::Duration::from_secs(1);
|
||||
const GIVE_UP_AFTER: std::time::Duration = std::time::Duration::from_secs(120);
|
||||
|
||||
let started = std::time::Instant::now();
|
||||
loop {
|
||||
let response = control_request(CtlRequest::CacheInspect { deep: true })?;
|
||||
match &response {
|
||||
CtlResponse::CacheReport {
|
||||
integrity_running: true,
|
||||
integrity_checked: false,
|
||||
..
|
||||
} if started.elapsed() < GIVE_UP_AFTER => std::thread::sleep(POLL),
|
||||
_ => return Ok(response),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
||||
let response = control_request(CtlRequest::CacheInspect { deep })?;
|
||||
if emit_json(&response)? {
|
||||
@@ -1940,6 +2002,7 @@ fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
||||
cache_budget,
|
||||
integrity_problems,
|
||||
integrity_checked,
|
||||
integrity_running,
|
||||
} => {
|
||||
println!(
|
||||
"Database schema v{schema_version}, {}",
|
||||
@@ -1972,6 +2035,11 @@ fn cmd_cache_inspect(deep: bool) -> Result<()> {
|
||||
println!(" {problem}");
|
||||
}
|
||||
}
|
||||
} else if integrity_running {
|
||||
// The daemon runs the check on a thread rather than holding its
|
||||
// only database connection for the length of it, so the answer
|
||||
// belongs to a later request.
|
||||
println!("\nIntegrity checking in the background — run this again for the result");
|
||||
} else {
|
||||
println!("\nIntegrity not checked (pass --deep)");
|
||||
}
|
||||
@@ -2209,14 +2277,24 @@ fn cmd_diagnose() -> Result<()> {
|
||||
format!("{pending} waiting to upload")
|
||||
},
|
||||
);
|
||||
match control_request(CtlRequest::CacheInspect { deep: true }) {
|
||||
match await_integrity_check() {
|
||||
Ok(CtlResponse::CacheReport {
|
||||
integrity_problems, ..
|
||||
integrity_problems,
|
||||
integrity_checked: true,
|
||||
..
|
||||
}) if integrity_problems.is_empty() => report.finding(
|
||||
DiagnoseLevel::Ok,
|
||||
" database integrity",
|
||||
"SQLite integrity_check passed",
|
||||
),
|
||||
Ok(CtlResponse::CacheReport {
|
||||
integrity_checked: false,
|
||||
..
|
||||
}) => report.finding(
|
||||
DiagnoseLevel::Warn,
|
||||
" database integrity",
|
||||
"still checking — run `pdfs cache inspect --deep` for the result",
|
||||
),
|
||||
Ok(CtlResponse::CacheReport {
|
||||
integrity_problems, ..
|
||||
}) => report.finding(
|
||||
|
||||
+364
-70
@@ -16,17 +16,20 @@
|
||||
//! [`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};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
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};
|
||||
@@ -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
|
||||
@@ -102,6 +117,16 @@ pub struct Pin {
|
||||
/// 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>,
|
||||
}
|
||||
|
||||
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||
@@ -121,6 +146,19 @@ struct PinFile {
|
||||
/// 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.
|
||||
@@ -253,6 +291,23 @@ 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>>,
|
||||
/// 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 {
|
||||
@@ -315,6 +370,9 @@ impl ContentCache {
|
||||
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
|
||||
@@ -463,7 +521,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());
|
||||
@@ -553,22 +611,38 @@ impl ContentCache {
|
||||
.join(format!("{}.b{idx}.meta", Self::key(uid)))
|
||||
}
|
||||
|
||||
/// Whether block `idx` 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, idx: u64) -> bool {
|
||||
self.block_tag(uid, idx) == Some(Meta { mtime, size })
|
||||
&& std::fs::metadata(self.block_blob(uid, idx))
|
||||
.is_ok_and(|m| m.len() == block_len(size, idx))
|
||||
}
|
||||
|
||||
/// The `(mtime, size)` a cached block claims to belong to, from its sidecar.
|
||||
fn block_tag(&self, uid: &NodeUid, idx: u64) -> Option<Meta> {
|
||||
serde_json::from_slice(&std::fs::read(self.block_meta(uid, idx)).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Serve cached block `idx` (a [`BLOCK_SIZE`]-aligned chunk) of `uid`, or
|
||||
/// `None` on miss/stale. Validated against `(mtime, size)` like a whole-file
|
||||
/// blob, so a new revision (which bumps the mtime) is detected. Bumps the
|
||||
/// block's mtime for LRU, best effort.
|
||||
pub fn cached_block(&self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Vec<u8>> {
|
||||
let want = Meta { mtime, size };
|
||||
let meta: Meta =
|
||||
serde_json::from_slice(&std::fs::read(self.block_meta(uid, idx)).ok()?).ok()?;
|
||||
if meta != want {
|
||||
if self.block_tag(uid, idx)? != (Meta { mtime, size }) {
|
||||
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)
|
||||
@@ -593,7 +667,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(
|
||||
@@ -612,6 +689,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] {
|
||||
@@ -679,7 +790,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,
|
||||
@@ -717,6 +831,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)> {
|
||||
@@ -796,8 +974,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
|
||||
@@ -818,43 +997,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;
|
||||
};
|
||||
@@ -909,7 +1073,14 @@ impl ContentCache {
|
||||
/// 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();
|
||||
};
|
||||
@@ -926,6 +1097,39 @@ impl ContentCache {
|
||||
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
|
||||
}
|
||||
|
||||
@@ -1096,6 +1300,10 @@ impl ContentCache {
|
||||
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!(
|
||||
@@ -1200,9 +1408,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)
|
||||
}
|
||||
|
||||
@@ -1233,9 +1439,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)
|
||||
}
|
||||
|
||||
@@ -1348,10 +1552,11 @@ impl ContentCache {
|
||||
.pin_list()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(uid, path, recursive)| Pin {
|
||||
uid,
|
||||
path,
|
||||
recursive,
|
||||
.map(|row| Pin {
|
||||
uid: row.uid,
|
||||
path: row.path,
|
||||
recursive: row.recursive,
|
||||
is_dir: row.is_dir,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1377,6 +1582,44 @@ impl ContentCache {
|
||||
.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
|
||||
@@ -1993,45 +2236,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):
|
||||
@@ -2174,6 +2435,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,
|
||||
|
||||
@@ -1193,6 +1193,28 @@ pub enum Response {
|
||||
/// calling a queued `mkdir` an upload is a lie.
|
||||
#[serde(default)]
|
||||
pending_changes: u64,
|
||||
/// Queued writes held back because the file still wears a transient
|
||||
/// name (a browser's `*.crdownload`, an editor's `*.swp`). They are not
|
||||
/// waiting on the network and will not drain until the finalising
|
||||
/// rename, so they are counted apart from `pending_uploads`.
|
||||
#[serde(default)]
|
||||
parked_uploads: u64,
|
||||
/// Queued ops that have failed enough times to be considered stuck.
|
||||
/// They keep retrying forever — correctly, since their staged bytes are
|
||||
/// the only copy — which is why they have to be visible.
|
||||
#[serde(default)]
|
||||
failing_ops: u64,
|
||||
/// The most recent error from a stuck op, when there is one.
|
||||
#[serde(default)]
|
||||
failing_error: Option<String>,
|
||||
/// Bytes held in `staging/`: writes accepted from the kernel whose only
|
||||
/// copy is on local disk. Never evictable, never counted against the
|
||||
/// cache budget — hence reported separately.
|
||||
#[serde(default)]
|
||||
staged_bytes: u64,
|
||||
/// Age of the oldest staged write, in seconds.
|
||||
#[serde(default)]
|
||||
staged_oldest_secs: u64,
|
||||
},
|
||||
/// A human-readable success message.
|
||||
Ok { message: String },
|
||||
@@ -1217,6 +1239,12 @@ pub enum Response {
|
||||
/// Whether the integrity check actually ran, so an empty problem list
|
||||
/// is not mistaken for a clean bill of health it never gave.
|
||||
integrity_checked: bool,
|
||||
/// Whether a deep check is running right now. It reads every page of
|
||||
/// the database under the daemon's only connection, so it is answered
|
||||
/// asynchronously: this says "ask again shortly", which is a different
|
||||
/// thing to say than either of the two above.
|
||||
#[serde(default)]
|
||||
integrity_running: bool,
|
||||
},
|
||||
/// The pin registry.
|
||||
Pins { pins: Vec<Pin> },
|
||||
|
||||
@@ -33,7 +33,7 @@ impl Db {
|
||||
/// whose stored `kind` no longer parses (written by an older build) are
|
||||
/// skipped rather than failing the whole read.
|
||||
pub fn activity_list(&self, limit: usize) -> Result<Vec<ActivityEntry>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT time, kind, target, detail, ok FROM activity
|
||||
ORDER BY id DESC LIMIT ?1",
|
||||
|
||||
@@ -76,7 +76,7 @@ impl Db {
|
||||
|
||||
/// The persisted album listing, in stored order.
|
||||
pub fn albums_list(&self) -> Result<Vec<StoredAlbum>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, photo_count, cover_uid, last_activity, shared
|
||||
FROM albums ORDER BY seq",
|
||||
@@ -98,7 +98,7 @@ impl Db {
|
||||
|
||||
/// Number of albums in the persisted listing.
|
||||
pub fn albums_count(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM albums", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
@@ -164,7 +164,7 @@ impl Db {
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||
WHERE album_uid = ?1 ORDER BY seq LIMIT ?2 OFFSET ?3",
|
||||
@@ -190,7 +190,7 @@ impl Db {
|
||||
|
||||
/// How many photos of `album_uid` are persisted.
|
||||
pub fn album_photos_count(&self, album_uid: &str) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM album_photos WHERE album_uid = ?1",
|
||||
[album_uid],
|
||||
@@ -207,7 +207,7 @@ impl Db {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind FROM album_photos
|
||||
|
||||
@@ -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();
|
||||
@@ -75,7 +99,7 @@ impl Db {
|
||||
/// because `enforce_block_budget` runs on every cached block — once per
|
||||
/// 4 MiB of every cold read — while holding the shared connection.
|
||||
pub fn cache_total_bytes(&self, kind: &str) -> Result<u64> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let total: i64 = conn.query_row(
|
||||
"SELECT COALESCE(SUM(size_bytes), 0) FROM cache_entries WHERE kind = ?1",
|
||||
params![kind],
|
||||
@@ -95,7 +119,7 @@ impl Db {
|
||||
kind: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare_cached(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC LIMIT ?2",
|
||||
@@ -115,7 +139,7 @@ impl Db {
|
||||
/// (`clear_unpinned`); incremental eviction wants
|
||||
/// [`cache_eviction_candidates`](Self::cache_eviction_candidates).
|
||||
pub fn cache_entries_by_kind(&self, kind: &str) -> Result<Vec<(String, u64)>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT cache_key, size_bytes FROM cache_entries
|
||||
WHERE kind = ?1 ORDER BY last_accessed ASC",
|
||||
|
||||
@@ -15,7 +15,7 @@ pub struct StoredDevice {
|
||||
|
||||
impl Db {
|
||||
pub fn device_get(&self) -> Result<Option<StoredDevice>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT uid, share_id, root_uid, name, created FROM device LIMIT 1",
|
||||
[],
|
||||
|
||||
@@ -41,32 +41,43 @@ impl Db {
|
||||
Ok(next)
|
||||
}
|
||||
|
||||
/// Write one batch of walked entries under scan generation `generation`. The
|
||||
/// FTS index is *not* touched here — it is rebuilt once in
|
||||
/// [`local_finish_scan`](Self::local_finish_scan).
|
||||
/// Write one batch of walked entries under scan generation `generation`,
|
||||
/// keeping the FTS index in step with them.
|
||||
///
|
||||
/// The index is maintained incrementally rather than rebuilt at the end of
|
||||
/// the scan, because a rebuild reads and re-tokenises every row in the table
|
||||
/// — trigrams over a few hundred thousand paths — inside one transaction on
|
||||
/// the single shared connection, which stalls the whole mount for as long as
|
||||
/// it takes. Incremental maintenance is possible here for a reason specific
|
||||
/// to this table: `local_fts` covers `name` and `path`, `path` is the
|
||||
/// table's key, and `name` is derived from it, so an existing row's indexed
|
||||
/// content can never change. Only insertions and deletions touch the index;
|
||||
/// a re-scan that finds the same files does no index work at all.
|
||||
///
|
||||
/// Hence the update-then-insert shape below rather than an upsert: the
|
||||
/// distinction between "already indexed" and "new row" is exactly what
|
||||
/// decides whether the index needs a write.
|
||||
pub fn local_upsert_batch(&self, generation: i64, entries: &[LocalEntry]) -> Result<()> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
{
|
||||
let mut stmt = tx.prepare_cached(
|
||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
is_dir = excluded.is_dir,
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
scan_gen = excluded.scan_gen",
|
||||
let mut update = tx.prepare_cached(
|
||||
"UPDATE local_files
|
||||
SET name = ?2, is_dir = ?3, size = ?4, mtime = ?5, scan_gen = ?6
|
||||
WHERE path = ?1",
|
||||
)?;
|
||||
let mut insert = tx.prepare_cached(
|
||||
"INSERT INTO local_files (path, name, is_dir, size, mtime, scan_gen)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
||||
)?;
|
||||
let mut index =
|
||||
tx.prepare_cached("INSERT INTO local_fts (rowid, name, path) VALUES (?1, ?2, ?3)")?;
|
||||
for e in entries {
|
||||
stmt.execute(params![
|
||||
e.path,
|
||||
e.name,
|
||||
e.is_dir as i64,
|
||||
e.size,
|
||||
e.mtime,
|
||||
generation
|
||||
])?;
|
||||
let row = params![e.path, e.name, e.is_dir as i64, e.size, e.mtime, generation];
|
||||
if update.execute(row)? == 0 {
|
||||
insert.execute(row)?;
|
||||
index.execute(params![tx.last_insert_rowid(), e.name, e.path])?;
|
||||
}
|
||||
}
|
||||
}
|
||||
tx.commit()?;
|
||||
@@ -74,16 +85,25 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Close scan generation `generation`: drop every row an older scan wrote
|
||||
/// (those paths are gone from disk), rebuild the FTS index over what remains,
|
||||
/// and stamp the completion time. Returns the number of indexed entries.
|
||||
/// (those paths are gone from disk), drop their FTS entries, and stamp the
|
||||
/// completion time. Returns the number of indexed entries.
|
||||
///
|
||||
/// The FTS deletes name the old values explicitly, which is how an
|
||||
/// external-content FTS5 table is told to retract a row — see
|
||||
/// [`local_upsert_batch`](Self::local_upsert_batch) for why this is done
|
||||
/// per-row instead of by rebuilding the index.
|
||||
pub fn local_finish_scan(&self, generation: i64, finished_at: i64) -> Result<i64> {
|
||||
let mut conn = self.conn.lock();
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"INSERT INTO local_fts (local_fts, rowid, name, path)
|
||||
SELECT 'delete', id, name, path FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
tx.execute(
|
||||
"DELETE FROM local_files WHERE scan_gen != ?1",
|
||||
params![generation],
|
||||
)?;
|
||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('local_indexed_at', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -98,7 +118,7 @@ impl Db {
|
||||
/// has never been built. The daemon uses this to decide whether a fresh mount
|
||||
/// needs an immediate rescan or can serve the existing index.
|
||||
pub fn local_indexed_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'local_indexed_at'",
|
||||
@@ -118,7 +138,7 @@ impl Db {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt;
|
||||
let rows = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
@@ -157,7 +177,7 @@ impl Db {
|
||||
if query.is_empty() || limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let terms = candidate_trigrams(query);
|
||||
let lane_limit = limit.div_ceil(2);
|
||||
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
|
||||
@@ -182,16 +202,36 @@ impl Db {
|
||||
if !terms.is_empty()
|
||||
&& let Some(first) = query.chars().next()
|
||||
{
|
||||
let prefix = format!("{}%", like_escape(&first.to_string()));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||
)?;
|
||||
let fallback = stmt
|
||||
.query_map(params![prefix, lane_limit as i64], local_hit)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?;
|
||||
for hit in fallback {
|
||||
// Trigram rank says nothing about how well a name *starts* with the
|
||||
// query, so on a home directory full of dependency trees the pool
|
||||
// fills with vendored files before the obvious answer is reached
|
||||
// (measured: 73% of the 500 best-ranked candidates for "test" came
|
||||
// from one Go module cache). Give whole-query prefix matches their
|
||||
// own lane, newest first, and keep the single-character lane —
|
||||
// which recovers a typo that destroyed every trigram — behind it.
|
||||
let mut lanes = vec![(
|
||||
format!("{}%", like_escape(query)),
|
||||
"ORDER BY mtime DESC LIMIT ?2",
|
||||
)];
|
||||
lanes.push((
|
||||
format!("{}%", like_escape(&first.to_string())),
|
||||
"ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||
));
|
||||
let mut extra = Vec::new();
|
||||
for (pattern, order) in lanes {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT path, name, is_dir, size, mtime FROM local_files
|
||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' {order}"
|
||||
))?;
|
||||
extra.extend(
|
||||
stmt.query_map(params![pattern, lane_limit as i64], local_hit)?
|
||||
.collect::<std::result::Result<Vec<_>, _>>()?,
|
||||
);
|
||||
}
|
||||
// Prefix hits go in front: `rows` is truncated to `limit` below, and
|
||||
// the lane exists precisely because trigram rank was burying them.
|
||||
let trigram = std::mem::take(&mut rows);
|
||||
for hit in extra.into_iter().chain(trigram) {
|
||||
if !rows.iter().any(|existing| existing.path == hit.path) {
|
||||
rows.push(hit);
|
||||
}
|
||||
|
||||
@@ -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 = 20;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 25;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -101,6 +101,66 @@ impl Db {
|
||||
if current < 20 {
|
||||
tx.execute_batch(MIGRATION_V20)?;
|
||||
}
|
||||
if current < 21 {
|
||||
// A V17-and-older fixture may carry only the tables its own test
|
||||
// cares about; there is nothing to reindex without `nodes`.
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes {
|
||||
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;")?;
|
||||
}
|
||||
if current < 23 {
|
||||
// One last full rebuild of the local index, because from here on the
|
||||
// scan maintains it incrementally (`local_upsert_batch`) and so can
|
||||
// no longer repair a `local_fts` that drifted from `local_files`.
|
||||
// Whatever it costs, it costs once, at startup, before the mount
|
||||
// serves anything.
|
||||
let present: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'local_fts'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if present {
|
||||
tx.execute_batch("INSERT INTO local_fts(local_fts) VALUES('rebuild');")?;
|
||||
}
|
||||
}
|
||||
if current < 24 {
|
||||
tx.execute_batch(MIGRATION_V24)?;
|
||||
}
|
||||
if current < 25 {
|
||||
// Same guard as V21: an old fixture may not have `nodes` at all,
|
||||
// and there is no path to materialise without it.
|
||||
let has_nodes: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'nodes'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_nodes {
|
||||
tx.execute_batch(MIGRATION_V25)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -527,3 +587,141 @@ const MIGRATION_V20: &str = "
|
||||
ALTER TABLE photos ADD COLUMN favorite INTEGER NOT NULL DEFAULT 0;
|
||||
CREATE INDEX IF NOT EXISTS idx_photos_favorite ON photos(favorite, seq);
|
||||
";
|
||||
|
||||
/// V21: rebuild the node search index after fixing what counts as indexable.
|
||||
///
|
||||
/// The v16 backfill rooted its path walk at any node whose parent is null *or*
|
||||
/// not cached, but the write path's `node_is_indexable_tx` demanded a
|
||||
/// null-parent ancestor. Only the My Files root has one — a device folder's
|
||||
/// root is stored in `device`/`sync_folder`, never in `nodes` — so every node
|
||||
/// under a device folder was dropped from `nodes_fts` the first time it was
|
||||
/// upserted after the backfill, and was unfindable from the prompt (2,705 of
|
||||
/// 9,252 nodes on the account this was diagnosed against).
|
||||
///
|
||||
/// The rule now matches the backfill, so this replays it: same recursive walk
|
||||
/// as v16, over the current `nodes`.
|
||||
/// Two details the v16 backfill did not carry, both cheap here: a node under a
|
||||
/// trashed folder stays out (the write path has always excluded it, and
|
||||
/// `tombstone_subtree_tx` only marks a subtree it was told about), and the walk
|
||||
/// is depth-capped so a parent cycle in a corrupted database cannot spin the
|
||||
/// migration forever.
|
||||
const MIGRATION_V21: &str = "
|
||||
DROP TABLE IF EXISTS nodes_fts;
|
||||
CREATE VIRTUAL TABLE nodes_fts USING fts5(
|
||||
name, path, tokenize='trigram'
|
||||
);
|
||||
INSERT INTO nodes_fts (rowid, name, path)
|
||||
WITH RECURSIVE paths(rowid, uid, path, trashed, depth) AS (
|
||||
-- Only a true root (no parent at all) contributes nothing to the path: its
|
||||
-- name is the mount. A row whose parent is merely uncached is an ordinary
|
||||
-- folder and keeps its name, which is what `path_of` does at runtime.
|
||||
SELECT n.rowid, n.uid,
|
||||
CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, n.trashed, 0
|
||||
FROM nodes n
|
||||
WHERE n.parent_uid IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
|
||||
UNION ALL
|
||||
SELECT n.rowid, n.uid,
|
||||
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||
MAX(paths.trashed, n.trashed), paths.depth + 1
|
||||
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||
WHERE paths.depth < 256
|
||||
)
|
||||
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;",
|
||||
),
|
||||
];
|
||||
|
||||
/// V24: the revisions this daemon sealed itself, so restarting does not reopen
|
||||
/// the conflict hole B70 layer B closed.
|
||||
///
|
||||
/// `Core::own_sealed_revs` held these in memory: a queued write whose baseline
|
||||
/// names an earlier revision is a conflict *unless* the remote sits at a
|
||||
/// revision we sealed, in which case it is one writer stalling and resuming and
|
||||
/// must supersede rather than fork a `(sync-conflict)` copy. Losing the map at
|
||||
/// shutdown meant the first drain after a restart could not tell the two apart.
|
||||
///
|
||||
/// `sealed_at` is what bounds the table: entries older than the window in which
|
||||
/// a stall→resume is plausible are pruned on write.
|
||||
const MIGRATION_V24: &str = "
|
||||
CREATE TABLE IF NOT EXISTS own_sealed_rev (
|
||||
uid TEXT PRIMARY KEY,
|
||||
revision_id TEXT NOT NULL,
|
||||
sealed_at INTEGER NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// V25: `nodes.path` — a node's mountpoint-relative path, stored rather than
|
||||
/// walked.
|
||||
///
|
||||
/// `path_of` was a recursive CTE per node, and search ran it once per candidate
|
||||
/// (up to a thousand per keystroke) on top of the one the write path already ran
|
||||
/// per descendant of every folder upsert. The value only changes when a node's
|
||||
/// name or parent changes, which is exactly when the write path is already
|
||||
/// walking the subtree — so it belongs in a column.
|
||||
///
|
||||
/// The backfill is the same walk V21 used for `nodes_fts`, minus the trashed
|
||||
/// filter: a trashed node stays out of the index but still has a path. Roots get
|
||||
/// `''` (their name is the mount), and a node whose parent is merely uncached
|
||||
/// keeps its own name — both matching what `path_of` returned at runtime.
|
||||
///
|
||||
/// Materialised into a temp table rather than written by a correlated subquery,
|
||||
/// so the recursive walk runs once instead of once per row.
|
||||
const MIGRATION_V25: &str = "
|
||||
ALTER TABLE nodes ADD COLUMN path TEXT;
|
||||
|
||||
CREATE TEMP TABLE node_paths (uid TEXT PRIMARY KEY, path TEXT NOT NULL);
|
||||
|
||||
INSERT INTO node_paths (uid, path)
|
||||
WITH RECURSIVE paths(uid, path, depth) AS (
|
||||
SELECT n.uid, CASE WHEN n.parent_uid IS NULL THEN '' ELSE n.name END, 0
|
||||
FROM nodes n
|
||||
WHERE n.parent_uid IS NULL
|
||||
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
|
||||
UNION ALL
|
||||
SELECT n.uid,
|
||||
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END,
|
||||
paths.depth + 1
|
||||
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
|
||||
WHERE paths.depth < 256
|
||||
)
|
||||
SELECT uid, path FROM paths;
|
||||
|
||||
UPDATE nodes
|
||||
SET path = (SELECT p.path FROM node_paths p WHERE p.uid = nodes.uid)
|
||||
WHERE EXISTS (SELECT 1 FROM node_paths p WHERE p.uid = nodes.uid);
|
||||
|
||||
DROP TABLE node_paths;
|
||||
";
|
||||
@@ -2,9 +2,17 @@
|
||||
//! inode bookkeeping, full-text search, content-cache LRU tracking, and pins.
|
||||
//!
|
||||
//! Only the daemon (`pdfs-fuse`) opens this for writes; the GUI and CLI reach
|
||||
//! the same data through the control socket. The connection is wrapped in a
|
||||
//! `Mutex` because the FUSE callbacks are synchronous and already serialize
|
||||
//! behind the `State` lock, so a connection pool would be overkill.
|
||||
//! the same data through the control socket. There is one write connection
|
||||
//! behind a `Mutex` — SQLite allows exactly one writer, and the single-instance
|
||||
//! `flock` makes sure it is ours — and a small pool of read-only connections
|
||||
//! behind [`Db::read`], which every `SELECT`-only method uses.
|
||||
//!
|
||||
//! The pool is what makes WAL worth having. The original justification for a
|
||||
//! single connection was that "FUSE callbacks already serialize behind the
|
||||
//! `State` lock"; that stopped being true when reads moved onto an 11-thread
|
||||
//! worker pool, and a lookup then queued behind whatever listing happened to be
|
||||
//! committing. Under WAL a reader never blocks the writer and vice versa, so the
|
||||
//! only thing left serialising them was this process's own mutex.
|
||||
//!
|
||||
//! This module is the P0 foundation: it opens the database, enables WAL, and
|
||||
//! applies the forward-only schema migrations. Write-through of nodes, the
|
||||
@@ -18,8 +26,10 @@
|
||||
use parking_lot::Mutex;
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::Result;
|
||||
use std::path::Path;
|
||||
use crate::{Error, Result};
|
||||
use std::fs::File;
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
mod activity;
|
||||
mod albums;
|
||||
@@ -50,6 +60,7 @@ pub use ops::{
|
||||
PendingCounts, PendingOp, RenameMeta, op_supersedes,
|
||||
};
|
||||
pub use photos::{StoredPhoto, THUMB_HAVE, THUMB_NONE, THUMB_UNKNOWN, TimelineRow};
|
||||
pub use pins::PinRow;
|
||||
pub use sync::{StoredSyncEntry, StoredSyncFolder};
|
||||
pub use trash::StoredTrash;
|
||||
|
||||
@@ -65,18 +76,198 @@ 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
|
||||
/// the inner `Mutex<Connection>`.
|
||||
pub struct Db {
|
||||
conn: Mutex<Connection>,
|
||||
/// Read-only connections for `SELECT`-only methods. `None` for an in-memory
|
||||
/// database, where a second connection would open a different, empty one —
|
||||
/// those fall back to the write connection.
|
||||
readers: Option<ReadPool>,
|
||||
/// The single-writer lock, held open for as long as this handle lives.
|
||||
///
|
||||
/// Never read; dropping the `File` is what releases the `flock`, so it has
|
||||
/// to be owned here rather than by `open`'s stack frame. `None` for
|
||||
/// in-memory databases, which nothing else can reach.
|
||||
_single_writer: Option<File>,
|
||||
}
|
||||
|
||||
/// Idle read-only connections, opened on demand and kept for reuse.
|
||||
///
|
||||
/// Not a bounded pool: the number in flight is bounded by the number of threads
|
||||
/// that can be inside a read at once (the FUSE worker lanes plus the control
|
||||
/// handlers), and blocking one of those on a permit would reintroduce exactly
|
||||
/// the queueing this exists to remove. Only the *idle* set is capped, so a burst
|
||||
/// does not leave a connection per thread parked forever.
|
||||
struct ReadPool {
|
||||
path: PathBuf,
|
||||
idle: Mutex<Vec<Connection>>,
|
||||
}
|
||||
|
||||
/// How many idle read connections to keep. Enough that the steady state never
|
||||
/// reopens, small enough to be unremarkable.
|
||||
const MAX_IDLE_READERS: usize = 4;
|
||||
|
||||
impl ReadPool {
|
||||
fn take(&self) -> Option<Connection> {
|
||||
if let Some(conn) = self.idle.lock().pop() {
|
||||
return Some(conn);
|
||||
}
|
||||
match open_read_only(&self.path) {
|
||||
Ok(conn) => Some(conn),
|
||||
Err(error) => {
|
||||
// Falling back to the write connection is correct, just slower,
|
||||
// so this is a warning and not a failed read.
|
||||
tracing::warn!(%error, "could not open a read-only connection");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn put(&self, conn: Connection) {
|
||||
let mut idle = self.idle.lock();
|
||||
if idle.len() < MAX_IDLE_READERS {
|
||||
idle.push(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A connection to read through: one borrowed from [`ReadPool`], or the write
|
||||
/// connection when there is no pool (in-memory) or opening one failed.
|
||||
///
|
||||
/// Derefs to `Connection`, so a method converts by swapping `self.conn.lock()`
|
||||
/// for `self.read()` and nothing else.
|
||||
pub(super) struct Reader<'a> {
|
||||
pooled: Option<Connection>,
|
||||
writer: Option<parking_lot::MutexGuard<'a, Connection>>,
|
||||
pool: Option<&'a ReadPool>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Reader<'_> {
|
||||
type Target = Connection;
|
||||
|
||||
fn deref(&self) -> &Connection {
|
||||
self.pooled
|
||||
.as_ref()
|
||||
.or(self.writer.as_deref())
|
||||
.expect("a Reader always holds one of the two")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Reader<'_> {
|
||||
fn drop(&mut self) {
|
||||
if let (Some(conn), Some(pool)) = (self.pooled.take(), self.pool) {
|
||||
pool.put(conn);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open a second connection to the same file for reads only.
|
||||
///
|
||||
/// `SQLITE_OPEN_READ_ONLY` plus `query_only` is belt and braces: the flag stops
|
||||
/// a routing mistake at the file, the pragma stops it at the statement. The rest
|
||||
/// mirror [`Db::open_configured`] — a read connection has its own page cache,
|
||||
/// its own temp store and its own busy timeout, and gets nothing from the
|
||||
/// writer's.
|
||||
///
|
||||
/// `journal_mode` is deliberately absent: it is a property of the database file,
|
||||
/// which the writer already set to WAL, and a read-only connection cannot change
|
||||
/// it anyway.
|
||||
fn open_read_only(path: &Path) -> Result<Connection> {
|
||||
use rusqlite::OpenFlags;
|
||||
let conn = Connection::open_with_flags(
|
||||
path,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
|
||||
)?;
|
||||
conn.busy_timeout(BUSY_TIMEOUT)?;
|
||||
conn.pragma_update(None, "cache_size", -CACHE_SIZE_KIB)?;
|
||||
conn.pragma_update(None, "temp_store", "MEMORY")?;
|
||||
conn.pragma_update(None, "mmap_size", MMAP_SIZE)?;
|
||||
conn.pragma_update(None, "query_only", true)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
impl Db {
|
||||
/// Open (creating if absent) the database at `path`, enable WAL, and bring
|
||||
/// the schema up to [`SCHEMA_VERSION`].
|
||||
///
|
||||
/// Takes the single-instance lock first (see
|
||||
/// [`acquire_single_writer_lock`]), and rebuilds from empty if what is on
|
||||
/// disk turns out not to be a readable database (see [`is_corrupt`]).
|
||||
pub fn open(path: &Path) -> Result<Self> {
|
||||
let single_writer = acquire_single_writer_lock(path)?;
|
||||
let conn = match Self::open_configured(path) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) if is_corrupt(&e) => {
|
||||
// Every byte in here is derived from the account or from the
|
||||
// content cache directory, so the recovery is to start over —
|
||||
// loudly, and keeping the damaged file for a post-mortem rather
|
||||
// than deleting evidence.
|
||||
tracing::error!(
|
||||
db = %path.display(),
|
||||
error = %e,
|
||||
"cache database is corrupt — moving it aside and rebuilding \
|
||||
(metadata will be re-fetched; no user data is stored here)"
|
||||
);
|
||||
quarantine_corrupt_db(path)?;
|
||||
Self::open_configured(path)?
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
// No pool for `:memory:`. A second connection to that name is a
|
||||
// second, empty database rather than another view of this one, so
|
||||
// every routed read would find a schema that does not exist.
|
||||
readers: (!is_in_memory(path)).then(|| ReadPool {
|
||||
path: path.to_path_buf(),
|
||||
idle: Mutex::new(Vec::new()),
|
||||
}),
|
||||
_single_writer: single_writer,
|
||||
})
|
||||
}
|
||||
|
||||
/// A connection for a `SELECT`-only method.
|
||||
///
|
||||
/// Reads see the last committed state, which is the same thing they saw
|
||||
/// through the write connection: every method here commits before it
|
||||
/// returns, and nothing holds a transaction open across calls.
|
||||
pub(super) fn read(&self) -> Reader<'_> {
|
||||
if let Some(pool) = &self.readers
|
||||
&& let Some(conn) = pool.take()
|
||||
{
|
||||
return Reader {
|
||||
pooled: Some(conn),
|
||||
writer: None,
|
||||
pool: Some(pool),
|
||||
};
|
||||
}
|
||||
Reader {
|
||||
pooled: None,
|
||||
writer: Some(self.conn.lock()),
|
||||
pool: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the file, apply every PRAGMA, and migrate. Split out of
|
||||
/// [`open`](Self::open) so the corruption path can retry it verbatim.
|
||||
fn open_configured(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
// WAL: readers never block the single writer. NORMAL sync is the
|
||||
// standard durability/throughput tradeoff for WAL.
|
||||
@@ -101,12 +292,39 @@ 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),
|
||||
readers: None,
|
||||
_single_writer: None,
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
Ok(db.into_conn())
|
||||
}
|
||||
|
||||
/// Unwrap the connection back out of a temporary handle. Only
|
||||
/// [`open_configured`](Self::open_configured) uses it, to run `migrate`
|
||||
/// (which is written against `&self`) before the real handle exists.
|
||||
fn into_conn(self) -> Connection {
|
||||
self.conn.into_inner()
|
||||
}
|
||||
|
||||
/// Open an in-memory database. For tests.
|
||||
@@ -119,6 +337,8 @@ impl Db {
|
||||
conn.pragma_update(None, "foreign_keys", "ON")?;
|
||||
let db = Self {
|
||||
conn: Mutex::new(conn),
|
||||
readers: None,
|
||||
_single_writer: None,
|
||||
};
|
||||
db.migrate()?;
|
||||
Ok(db)
|
||||
@@ -132,5 +352,102 @@ impl Db {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names the in-memory database rather than a file. Both
|
||||
/// spellings SQLite accepts are checked, because neither has a lock file or a
|
||||
/// corruption story.
|
||||
fn is_in_memory(path: &Path) -> bool {
|
||||
let p = path.as_os_str();
|
||||
p == ":memory:" || p.is_empty()
|
||||
}
|
||||
|
||||
/// Take the single-instance write lock for the database at `path`.
|
||||
///
|
||||
/// The daemon is the only writer by design — the front-ends go through the
|
||||
/// control socket — but nothing stopped a hand-run `pdfs mount` from starting
|
||||
/// alongside the systemd unit, at which point two processes own the same inode
|
||||
/// space, the same content cache, and the same drain queue. SQLite's own
|
||||
/// locking makes each *statement* safe and does nothing about that.
|
||||
///
|
||||
/// `flock` on a sibling `.lock` file rather than on the database itself:
|
||||
/// SQLite uses POSIX record locks, so the two never interact, and a lock file
|
||||
/// left behind carries no state — the lock lives in the kernel and dies with
|
||||
/// the process, however it exits.
|
||||
fn acquire_single_writer_lock(path: &Path) -> Result<Option<File>> {
|
||||
if is_in_memory(path) {
|
||||
return Ok(None);
|
||||
}
|
||||
let lock_path = lock_path_for(path);
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.truncate(false)
|
||||
.write(true)
|
||||
.open(&lock_path)?;
|
||||
// SAFETY: `file` owns the descriptor and outlives the call.
|
||||
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
||||
if rc != 0 {
|
||||
let e = std::io::Error::last_os_error();
|
||||
if e.raw_os_error() == Some(libc::EWOULDBLOCK) {
|
||||
return Err(Error::Other(format!(
|
||||
"another Proton Drive daemon is already using {} — \
|
||||
stop it first (`systemctl --user stop proton-drive.service`)",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
return Err(Error::Io(e));
|
||||
}
|
||||
Ok(Some(file))
|
||||
}
|
||||
|
||||
fn lock_path_for(path: &Path) -> PathBuf {
|
||||
let mut name = path.as_os_str().to_owned();
|
||||
name.push(".lock");
|
||||
PathBuf::from(name)
|
||||
}
|
||||
|
||||
/// Whether `e` says the file is not a usable database, as opposed to any other
|
||||
/// failure. These are the only two conditions rebuilding fixes; a disk error, a
|
||||
/// permission problem or a newer-than-supported schema must all still fail.
|
||||
fn is_corrupt(e: &Error) -> bool {
|
||||
matches!(
|
||||
e,
|
||||
Error::Db(rusqlite::Error::SqliteFailure(inner, _))
|
||||
if inner.code == rusqlite::ErrorCode::DatabaseCorrupt
|
||||
|| inner.code == rusqlite::ErrorCode::NotADatabase
|
||||
)
|
||||
}
|
||||
|
||||
/// Move a damaged database (and its WAL sidecars) out of the way so the next
|
||||
/// open starts from empty.
|
||||
///
|
||||
/// Renamed rather than deleted: this is a cache, but it is also the only
|
||||
/// evidence of what went wrong, and it costs one file the user can remove.
|
||||
fn quarantine_corrupt_db(path: &Path) -> Result<()> {
|
||||
let stamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
for suffix in ["", "-wal", "-shm"] {
|
||||
let mut from = path.as_os_str().to_owned();
|
||||
from.push(suffix);
|
||||
let from = PathBuf::from(from);
|
||||
if !from.exists() {
|
||||
continue;
|
||||
}
|
||||
let mut to = path.as_os_str().to_owned();
|
||||
to.push(format!(".corrupt-{stamp}{suffix}"));
|
||||
let to = PathBuf::from(to);
|
||||
// A failure to rename the sidecars is survivable — SQLite discards a
|
||||
// WAL whose database header no longer matches — but failing to move the
|
||||
// database itself is not, because the retry would hit the same file.
|
||||
match std::fs::rename(&from, &to) {
|
||||
Ok(()) => tracing::warn!(from = %from.display(), to = %to.display(), "quarantined"),
|
||||
Err(e) if suffix.is_empty() => return Err(Error::Io(e)),
|
||||
Err(e) => tracing::warn!(file = %from.display(), error = %e, "could not quarantine"),
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -74,7 +74,7 @@ impl Db {
|
||||
|
||||
/// Every local location, with device fields joined from `sync_folder`.
|
||||
pub fn mount_list(&self) -> Result<Vec<MountSpec>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT
|
||||
m.id,
|
||||
|
||||
@@ -10,7 +10,9 @@ use crate::{Access, Result};
|
||||
use proton_drive_rs::proton_sdk::ids::{LinkId, NodeUid, VolumeId};
|
||||
use proton_drive_rs::{Node, NodeKind};
|
||||
|
||||
use super::utils::{TRIGRAM_MIN, collect_pairs, like_escape, pair, path_of};
|
||||
use super::utils::{
|
||||
HitRow, TRIGRAM_MIN, collect_hits, hit_row, join_path, like_escape, path_of, walk_path_of,
|
||||
};
|
||||
|
||||
pub struct StoredNode {
|
||||
pub node: Node,
|
||||
@@ -59,7 +61,7 @@ impl Db {
|
||||
/// flag. Synthetic shared listings use this to serve the last completed
|
||||
/// snapshot while offline, including after a live event expired its TTL.
|
||||
pub fn visible_children(&self, parent: &NodeUid) -> Result<Vec<Node>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json FROM nodes
|
||||
WHERE parent_uid = ?1 AND trashed = 0 AND node_json IS NOT NULL
|
||||
@@ -285,7 +287,7 @@ impl Db {
|
||||
/// Check if a folder node has any non-trashed children in the database.
|
||||
pub fn has_children(&self, parent_uid: &NodeUid) -> Result<bool> {
|
||||
let uid_str = parent_uid.to_string();
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM nodes WHERE parent_uid = ?1 AND trashed = 0",
|
||||
params![uid_str],
|
||||
@@ -294,6 +296,25 @@ impl Db {
|
||||
Ok(count > 0)
|
||||
}
|
||||
|
||||
/// A node's mountpoint-relative path, or `None` when it is not cached.
|
||||
///
|
||||
/// One indexed read of the stored `path`. Callers that need to relate many
|
||||
/// nodes to a handful of roots — search decorating each hit with its
|
||||
/// mountpoint — resolve the roots once through this and do the rest as
|
||||
/// string arithmetic, instead of a `path_relative_to` walk per pair.
|
||||
pub fn node_path(&self, uid: &str) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
let exists: Option<i64> = conn
|
||||
.query_row("SELECT 1 FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
if exists.is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(path_of(&conn, uid)?))
|
||||
}
|
||||
|
||||
/// Resolve `uid` relative to an ancestor node.
|
||||
///
|
||||
/// This is used when a remote subtree has its own local sync mount: the
|
||||
@@ -302,7 +323,7 @@ impl Db {
|
||||
/// chain keeps callers from accidentally joining a Drive-wide path onto the
|
||||
/// wrong mountpoint. The ancestor itself resolves to the empty path.
|
||||
pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||
@@ -338,15 +359,15 @@ impl Db {
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let rows: Vec<(String, String)> = if query.chars().count() < TRIGRAM_MIN {
|
||||
let conn = self.read();
|
||||
let rows: Vec<HitRow> = if query.chars().count() < TRIGRAM_MIN {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid FROM nodes
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name LIMIT ?2",
|
||||
)?;
|
||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||
} else {
|
||||
// Escape double quotes and quote each term, then combine with AND so
|
||||
// all terms must match but can appear in any order or position.
|
||||
@@ -356,18 +377,21 @@ impl Db {
|
||||
.collect::<Vec<_>>()
|
||||
.join(" AND ");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json, n.uid
|
||||
"SELECT n.node_json, n.uid, n.path
|
||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||
ORDER BY f.rank LIMIT ?2",
|
||||
)?;
|
||||
collect_pairs(stmt.query_map(params![phrase, limit as i64], pair)?)?
|
||||
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
||||
};
|
||||
|
||||
let mut hits = Vec::with_capacity(rows.len());
|
||||
for (json, uid) in rows {
|
||||
for (json, uid, path) in rows {
|
||||
let node: Node = serde_json::from_str(&json)?;
|
||||
let path = path_of(&conn, &uid)?;
|
||||
let path = match path {
|
||||
Some(path) => path,
|
||||
None => walk_path_of(&conn, &uid)?,
|
||||
};
|
||||
hits.push(SearchHit { node, path });
|
||||
}
|
||||
Ok(hits)
|
||||
@@ -382,54 +406,65 @@ impl Db {
|
||||
if query.is_empty() || limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let terms = candidate_trigrams(query);
|
||||
let lane_limit = limit.div_ceil(2);
|
||||
let mut rows: Vec<(String, String)> = if terms.is_empty() {
|
||||
let mut rows: Vec<HitRow> = if terms.is_empty() {
|
||||
let pat = format!("%{}%", like_escape(query));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid FROM nodes
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name LIMIT ?2",
|
||||
)?;
|
||||
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||
} else {
|
||||
let expression = terms.join(" OR ");
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT n.node_json, n.uid
|
||||
"SELECT n.node_json, n.uid, n.path
|
||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||
ORDER BY f.rank LIMIT ?2",
|
||||
)?;
|
||||
collect_pairs(stmt.query_map(params![expression, lane_limit as i64], pair)?)?
|
||||
collect_hits(stmt.query_map(params![expression, lane_limit as i64], hit_row)?)?
|
||||
};
|
||||
// A short substitution can destroy every trigram (`vedio` vs `video`).
|
||||
// Feed the scorer an additional indexed same-initial lane so it can
|
||||
// recover those candidates without scanning the full node table.
|
||||
// Trigram rank does not know that a name *starts* with the query, and a
|
||||
// short substitution can destroy every trigram outright (`vedio` vs
|
||||
// `video`). Two indexed lanes cover both without scanning the node
|
||||
// table: whole-query prefix first, then same-initial.
|
||||
if !terms.is_empty()
|
||||
&& let Some(first) = query.chars().next()
|
||||
{
|
||||
let prefix = format!("{}%", like_escape(&first.to_string()));
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid FROM nodes
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
|
||||
AND trashed = 0 AND node_json IS NOT NULL
|
||||
ORDER BY name COLLATE NOCASE LIMIT ?2",
|
||||
)?;
|
||||
let fallback =
|
||||
collect_pairs(stmt.query_map(params![prefix, lane_limit as i64], pair)?)?;
|
||||
for row in fallback {
|
||||
if !rows.iter().any(|(_, uid)| uid == &row.1) {
|
||||
let mut extra = Vec::new();
|
||||
for pattern in [
|
||||
format!("{}%", like_escape(query)),
|
||||
format!("{}%", like_escape(&first.to_string())),
|
||||
] {
|
||||
extra.extend(collect_hits(
|
||||
stmt.query_map(params![pattern, lane_limit as i64], hit_row)?,
|
||||
)?);
|
||||
}
|
||||
let trigram = std::mem::take(&mut rows);
|
||||
for row in extra.into_iter().chain(trigram) {
|
||||
if !rows.iter().any(|(_, uid, _)| uid == &row.1) {
|
||||
rows.push(row);
|
||||
}
|
||||
}
|
||||
rows.truncate(limit);
|
||||
}
|
||||
rows.into_iter()
|
||||
.map(|(json, uid)| {
|
||||
.map(|(json, uid, path)| {
|
||||
Ok(SearchHit {
|
||||
node: serde_json::from_str(&json)?,
|
||||
path: path_of(&conn, &uid)?,
|
||||
path: match path {
|
||||
Some(path) => path,
|
||||
None => walk_path_of(&conn, &uid)?,
|
||||
},
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -449,7 +484,7 @@ impl Db {
|
||||
|
||||
/// Load every persisted node for cold-start hydration of the `State` maps.
|
||||
pub fn load_all(&self) -> Result<Vec<StoredNode>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT node_json, listed FROM nodes WHERE node_json IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
@@ -470,7 +505,7 @@ impl Db {
|
||||
/// when the API is unreachable, so the mount can still serve the cached tree
|
||||
/// (offline.md Phase 1).
|
||||
pub fn node_by_uid(&self, uid: &str) -> Result<Option<Node>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let json: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT node_json FROM nodes WHERE uid = ?1 AND node_json IS NOT NULL",
|
||||
@@ -488,7 +523,7 @@ impl Db {
|
||||
/// The daemon resumes from this on restart instead of reseeding to the
|
||||
/// server head, so changes made while unmounted are still applied (P2).
|
||||
pub fn children_if_listed(&self, parent: &NodeUid) -> Result<Option<Vec<Node>>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let listed: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT listed FROM nodes WHERE uid = ?1",
|
||||
@@ -521,13 +556,57 @@ impl Db {
|
||||
// in sync on every store/read/evict, so it is authoritative for eviction.
|
||||
}
|
||||
|
||||
/// What a node's row looked like before this upsert, for the fields that decide
|
||||
/// whether the subtree below it has to be touched at all.
|
||||
struct PriorRow {
|
||||
parent_uid: Option<String>,
|
||||
name: String,
|
||||
path: Option<String>,
|
||||
trashed: bool,
|
||||
}
|
||||
|
||||
fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
let json = serde_json::to_string(node)?;
|
||||
let uid = node.uid.to_string();
|
||||
let parent_uid = node.parent_uid.as_ref().map(|u| u.to_string());
|
||||
|
||||
let prior: Option<PriorRow> = tx
|
||||
.query_row(
|
||||
"SELECT parent_uid, name, path, trashed FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| {
|
||||
Ok(PriorRow {
|
||||
parent_uid: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
path: row.get(2)?,
|
||||
trashed: row.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()?;
|
||||
|
||||
// A node's path is its parent's path plus its own name — one indexed lookup,
|
||||
// where resolving it from scratch is a recursive walk to the root. A node
|
||||
// whose parent is not cached (a device folder's root lives in `device`, never
|
||||
// in `nodes`) starts a path of its own, which is what the walk did too.
|
||||
let path = match &parent_uid {
|
||||
None => String::new(),
|
||||
Some(parent) => {
|
||||
let parent_path: Option<Option<String>> = tx
|
||||
.query_row(
|
||||
"SELECT path FROM nodes WHERE uid = ?1",
|
||||
params![parent],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional()?;
|
||||
join_path(parent_path.flatten().as_deref().unwrap_or(""), &node.name)
|
||||
}
|
||||
};
|
||||
|
||||
tx.execute(
|
||||
"INSERT INTO nodes
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
(uid, parent_uid, name, is_dir, size, mtime, trashed, node_json, path)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
parent_uid = excluded.parent_uid,
|
||||
name = excluded.name,
|
||||
@@ -535,59 +614,116 @@ fn upsert_node_tx(tx: &Transaction<'_>, node: &Node) -> Result<()> {
|
||||
size = excluded.size,
|
||||
mtime = excluded.mtime,
|
||||
trashed = excluded.trashed,
|
||||
node_json = excluded.node_json",
|
||||
node_json = excluded.node_json,
|
||||
path = excluded.path",
|
||||
params![
|
||||
uid,
|
||||
node.parent_uid.as_ref().map(|u| u.to_string()),
|
||||
parent_uid,
|
||||
node.name,
|
||||
node.is_folder() as i64,
|
||||
node_size(node),
|
||||
node.modification_time,
|
||||
node.trashed as i64,
|
||||
json,
|
||||
path,
|
||||
],
|
||||
)?;
|
||||
// FTS5 has no UPSERT. Folder moves also change every descendant path, so
|
||||
// refresh the whole affected subtree while keeping rowid deletes indexed.
|
||||
let rowid: i64 = tx.query_row(
|
||||
"SELECT rowid FROM nodes WHERE uid = ?1",
|
||||
params![uid],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let mut affected = vec![rowid];
|
||||
if node.is_folder() {
|
||||
let mut stmt = tx.prepare(
|
||||
"WITH RECURSIVE descendants(rowid, uid) AS (
|
||||
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.rowid, n.uid FROM nodes n
|
||||
JOIN descendants d ON n.parent_uid = d.uid
|
||||
) SELECT rowid FROM descendants",
|
||||
)?;
|
||||
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
|
||||
affected.push(descendant?);
|
||||
}
|
||||
}
|
||||
for affected_rowid in affected {
|
||||
|
||||
// FTS5 has no UPSERT, so the node's own row is always replaced.
|
||||
let indexable = node_is_indexable_tx(tx, &uid)?;
|
||||
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
|
||||
if indexable {
|
||||
tx.execute(
|
||||
"DELETE FROM nodes_fts WHERE rowid = ?1",
|
||||
params![affected_rowid],
|
||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||
params![rowid, node.name, path],
|
||||
)?;
|
||||
let indexed: Option<(String, String)> = tx
|
||||
.query_row(
|
||||
"SELECT name, uid FROM nodes WHERE rowid = ?1",
|
||||
params![affected_rowid],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)
|
||||
.optional()?;
|
||||
if let Some((name, indexed_uid)) = indexed
|
||||
&& node_is_indexable_tx(tx, &indexed_uid)?
|
||||
{
|
||||
let path = path_of(tx, &indexed_uid)?;
|
||||
tx.execute(
|
||||
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
|
||||
params![affected_rowid, name, path],
|
||||
)?;
|
||||
}
|
||||
|
||||
// Everything below only matters when the subtree's paths or reachability
|
||||
// actually moved. Re-listing a folder upserts it unchanged, and paying for a
|
||||
// full descendant walk on each of those was most of what a large tree spent
|
||||
// its time on.
|
||||
let subtree_moved = prior.is_none_or(|prior| {
|
||||
prior.parent_uid != parent_uid
|
||||
|| prior.name != node.name
|
||||
|| prior.path.as_deref() != Some(path.as_str())
|
||||
|| prior.trashed != node.trashed
|
||||
});
|
||||
if node.is_folder() && subtree_moved {
|
||||
reindex_subtree_tx(tx, &uid, &path, indexable)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Rewrite every descendant's stored path and search-index row after `folder`
|
||||
/// moved, was renamed, or changed reachability.
|
||||
///
|
||||
/// One recursive walk carries both the new path and whether anything on the way
|
||||
/// down is trashed, so each descendant costs two trivial statements instead of a
|
||||
/// `path_of` walk plus an ancestor walk of its own. `folder_indexable` is the
|
||||
/// answer `node_is_indexable_tx` gave for the folder itself: what is above the
|
||||
/// subtree is the same for every node in it.
|
||||
///
|
||||
/// The depth cap is a liveness guard, not a policy: `UNION ALL` over a parent
|
||||
/// cycle (a's parent is b, b's parent is a — corrupt data, but the API can hand
|
||||
/// it to us) never terminates, and this runs inside the write transaction that
|
||||
/// holds the daemon's only SQLite connection. Real Drive trees are nowhere near
|
||||
/// this deep.
|
||||
fn reindex_subtree_tx(
|
||||
tx: &Transaction<'_>,
|
||||
folder: &str,
|
||||
folder_path: &str,
|
||||
folder_indexable: bool,
|
||||
) -> Result<()> {
|
||||
let descendants: Vec<(i64, String, bool)> = {
|
||||
let mut stmt = tx.prepare(
|
||||
"WITH RECURSIVE sub(rowid, uid, name, path, blocked, depth) AS (
|
||||
SELECT n.rowid, n.uid, n.name,
|
||||
CASE WHEN ?2 = '' THEN n.name ELSE ?2 || '/' || n.name END,
|
||||
n.trashed, 0
|
||||
FROM nodes n WHERE n.parent_uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.rowid, n.uid, n.name,
|
||||
sub.path || '/' || n.name,
|
||||
MAX(sub.blocked, n.trashed), sub.depth + 1
|
||||
FROM nodes n JOIN sub ON n.parent_uid = sub.uid
|
||||
WHERE sub.depth < 256
|
||||
)
|
||||
SELECT rowid, path, blocked FROM sub",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder, folder_path], |row| {
|
||||
Ok((
|
||||
row.get::<_, i64>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, i64>(2)? != 0,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
out
|
||||
};
|
||||
if descendants.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut set_path = tx.prepare("UPDATE nodes SET path = ?2 WHERE rowid = ?1")?;
|
||||
let mut unindex = tx.prepare("DELETE FROM nodes_fts WHERE rowid = ?1")?;
|
||||
let mut index = tx.prepare(
|
||||
"INSERT INTO nodes_fts (rowid, name, path)
|
||||
SELECT rowid, name, ?2 FROM nodes WHERE rowid = ?1",
|
||||
)?;
|
||||
for (rowid, path, blocked) in descendants {
|
||||
set_path.execute(params![rowid, path])?;
|
||||
unindex.execute(params![rowid])?;
|
||||
if folder_indexable && !blocked {
|
||||
index.execute(params![rowid, path])?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -603,13 +739,27 @@ fn direct_child_uids_tx(tx: &Transaction<'_>, parent: &str) -> Result<Vec<String
|
||||
Ok(uids)
|
||||
}
|
||||
|
||||
/// Whether a node belongs in the search index: it exists, and nothing on the
|
||||
/// way up to the top of its cached chain is trashed.
|
||||
///
|
||||
/// The chain does *not* have to reach a `parent_uid IS NULL` row. Only the My
|
||||
/// Files root is stored that way; a device folder's root lives in `device` /
|
||||
/// `sync_folder` and never gets a `nodes` row, so requiring a null-parent
|
||||
/// ancestor silently excluded every device-folder subtree from search — 29% of
|
||||
/// the nodes on the account this was found on. A subtree whose topmost cached
|
||||
/// row has an uncached parent is ordinary, reachable data.
|
||||
///
|
||||
/// What is still excluded is a *cycle*: the walk's own guard stops it, but
|
||||
/// [`path_of`] has no such guard, so an indexed cycle would hang a search. A
|
||||
/// clean chain ends at a row whose parent is null or simply not cached; a cycle
|
||||
/// ends at one whose parent is a row we have already visited.
|
||||
fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||
let indexable: i64 = tx.query_row(
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, path) AS (
|
||||
SELECT uid, parent_uid, trashed, char(31) || uid || char(31)
|
||||
"WITH RECURSIVE ancestors(uid, parent_uid, trashed, depth, path) AS (
|
||||
SELECT uid, parent_uid, trashed, 0, char(31) || uid || char(31)
|
||||
FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.trashed,
|
||||
SELECT n.uid, n.parent_uid, n.trashed, a.depth + 1,
|
||||
a.path || n.uid || char(31)
|
||||
FROM ancestors a JOIN nodes n ON n.uid = a.parent_uid
|
||||
WHERE instr(a.path, char(31) || n.uid || char(31)) = 0
|
||||
@@ -617,7 +767,12 @@ fn node_is_indexable_tx(tx: &Transaction<'_>, uid: &str) -> Result<bool> {
|
||||
SELECT CASE
|
||||
WHEN COUNT(*) = 0 THEN 0
|
||||
WHEN MAX(trashed) != 0 THEN 0
|
||||
WHEN MAX(CASE WHEN parent_uid IS NULL THEN 1 ELSE 0 END) = 0 THEN 0
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM ancestors a
|
||||
WHERE a.depth = (SELECT MAX(depth) FROM ancestors)
|
||||
AND a.parent_uid IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM nodes p WHERE p.uid = a.parent_uid)
|
||||
) THEN 0
|
||||
ELSE 1
|
||||
END
|
||||
FROM ancestors",
|
||||
|
||||
+127
-10
@@ -119,15 +119,32 @@ pub struct PendingOp {
|
||||
}
|
||||
|
||||
/// How much work the queue owes the server, by whether it carries bytes.
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PendingCounts {
|
||||
/// Queued `create`/`revision` ops: files whose content is not on the remote.
|
||||
pub uploads: i64,
|
||||
/// Queued `mkdir`/`rename`/`trash` ops: metadata the remote has not been
|
||||
/// told about.
|
||||
pub changes: i64,
|
||||
/// Ops parked at [`PARK_UNTIL`]: a transient file's bytes, held back until
|
||||
/// the rename that gives it its finished name. They are queued but will
|
||||
/// never drain on their own, so counting them as "waiting to upload" would
|
||||
/// have the front-end promise progress that is not coming.
|
||||
pub parked: i64,
|
||||
/// Ops that have failed at least [`FAILING_ATTEMPTS`] times. A permanently
|
||||
/// failing op never wedges the queue — the backoff sees to that — which is
|
||||
/// exactly why it needs surfacing: it retries forever, invisibly.
|
||||
pub failing: i64,
|
||||
/// The most recent error text from a failing op, for the front-end to show
|
||||
/// instead of the bare count.
|
||||
pub last_error: Option<String>,
|
||||
}
|
||||
|
||||
/// Failed attempts after which an op is reported as failing rather than merely
|
||||
/// retrying. Six is where the backoff has reached its ceiling, so an op past it
|
||||
/// is retrying at the slowest rate it ever will and has been for minutes.
|
||||
pub const FAILING_ATTEMPTS: i64 = 6;
|
||||
|
||||
/// The outcome of folding freshly written bytes into a queued create.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AttachedBlob {
|
||||
@@ -140,7 +157,7 @@ pub struct AttachedBlob {
|
||||
impl Db {
|
||||
/// Whether a specific desired-state operation is still queued for a node.
|
||||
pub fn has_pending_op(&self, uid: &str, kind: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1 FROM pending_op WHERE uid = ?1 AND kind = ?2
|
||||
@@ -153,7 +170,7 @@ impl Db {
|
||||
|
||||
/// Read an existing operation's metadata before a superseding enqueue.
|
||||
pub fn pending_op_meta(&self, uid: &str, kind: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT meta_json FROM pending_op WHERE uid = ?1 AND kind = ?2",
|
||||
params![uid, kind],
|
||||
@@ -352,7 +369,7 @@ impl Db {
|
||||
|
||||
/// Check if a queued create or mkdir op exists for `uid`.
|
||||
pub fn has_create_op(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1 AND kind IN (?2, ?3)",
|
||||
params![uid, OP_CREATE, OP_MKDIR],
|
||||
@@ -368,7 +385,7 @@ impl Db {
|
||||
/// bytes are still owed an upload, and dropping it would discard the staged
|
||||
/// blob that holds them (`docs/BUGS.md` B71).
|
||||
pub fn has_any_op(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let count: i64 = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE uid = ?1",
|
||||
params![uid],
|
||||
@@ -464,7 +481,7 @@ impl Db {
|
||||
/// sort, and no separate index to maintain. The common case (something is
|
||||
/// due) exits on the first row.
|
||||
pub fn next_due_op(&self, now: i64) -> Result<Option<PendingOp>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare_cached(&format!(
|
||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||
attempts, last_error, next_attempt_at
|
||||
@@ -500,7 +517,7 @@ impl Db {
|
||||
/// debounced or backed-off op becomes eligible rather than waiting the full
|
||||
/// idle-poll interval.
|
||||
pub fn earliest_due_at(&self) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let ts: Option<i64> = conn
|
||||
.query_row(
|
||||
&format!(
|
||||
@@ -520,7 +537,7 @@ impl Db {
|
||||
/// Every queued op, oldest first. For status read-outs and the CLI — the
|
||||
/// drain wants [`next_due_op`](Self::next_due_op) instead.
|
||||
pub fn pending_ops(&self) -> Result<Vec<PendingOp>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, kind, uid, parent_uid, name, blob_path, meta_json, created_at,
|
||||
attempts, last_error, next_attempt_at
|
||||
@@ -553,7 +570,7 @@ impl Db {
|
||||
/// mount owes the server, but it carries no bytes and reporting it as an
|
||||
/// upload is simply untrue.
|
||||
pub fn pending_op_counts(&self) -> Result<PendingCounts> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let uploads = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE kind IN (?1, ?2)",
|
||||
params![OP_REVISION, OP_CREATE],
|
||||
@@ -564,7 +581,33 @@ impl Db {
|
||||
params![OP_REVISION, OP_CREATE],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(PendingCounts { uploads, changes })
|
||||
let parked = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE next_attempt_at >= ?1",
|
||||
params![PARK_UNTIL],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let failing = conn.query_row(
|
||||
"SELECT COUNT(*) FROM pending_op WHERE attempts >= ?1",
|
||||
params![FAILING_ATTEMPTS],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
let last_error = conn
|
||||
.query_row(
|
||||
"SELECT last_error FROM pending_op
|
||||
WHERE attempts >= ?1 AND last_error IS NOT NULL
|
||||
ORDER BY attempts DESC LIMIT 1",
|
||||
params![FAILING_ATTEMPTS],
|
||||
|r| r.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten();
|
||||
Ok(PendingCounts {
|
||||
uploads,
|
||||
changes,
|
||||
parked,
|
||||
failing,
|
||||
last_error,
|
||||
})
|
||||
}
|
||||
|
||||
/// Nothing bearing this volume may be handed to the API — it would 404. The
|
||||
@@ -609,4 +652,78 @@ impl Db {
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every staged blob the queue still refers to.
|
||||
///
|
||||
/// The startup staging reconcile subtracts this from what is actually in
|
||||
/// `staging/`: the difference is bytes the kernel was told were written and
|
||||
/// that nothing is going to upload.
|
||||
pub fn op_blob_paths(&self) -> Result<std::collections::HashSet<String>> {
|
||||
let conn = self.read();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT blob_path FROM pending_op WHERE blob_path IS NOT NULL")?;
|
||||
let rows = stmt.query_map([], |r| r.get::<_, String>(0))?;
|
||||
let mut out = std::collections::HashSet::new();
|
||||
for row in rows {
|
||||
out.insert(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Record that this daemon sealed `revision_id` on `uid`, and prune entries
|
||||
/// past [`OWN_SEALED_TTL_MS`].
|
||||
///
|
||||
/// One row per node, overwritten as the node advances. See
|
||||
/// [`own_sealed_rev`](Self::own_sealed_rev) for what reads it.
|
||||
pub fn set_own_sealed_rev(&self, uid: &str, revision_id: &str, now_ms: i64) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO own_sealed_rev (uid, revision_id, sealed_at)
|
||||
VALUES (?1, ?2, ?3)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
revision_id = excluded.revision_id,
|
||||
sealed_at = excluded.sealed_at",
|
||||
params![uid, revision_id, now_ms],
|
||||
)?;
|
||||
conn.execute(
|
||||
"DELETE FROM own_sealed_rev WHERE sealed_at < ?1",
|
||||
params![now_ms - OWN_SEALED_TTL_MS],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The last revision of `uid` this daemon sealed itself, if it is still
|
||||
/// within [`OWN_SEALED_TTL_MS`].
|
||||
///
|
||||
/// The drain asks before treating a moved-on remote as a conflict: a
|
||||
/// revision we sealed means one writer stalled and resumed, which
|
||||
/// supersedes rather than forking a `(sync-conflict)` copy (docs/BUGS.md
|
||||
/// B70 layer B).
|
||||
pub fn own_sealed_rev(&self, uid: &str, now_ms: i64) -> Result<Option<String>> {
|
||||
let conn = self.read();
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT revision_id FROM own_sealed_rev
|
||||
WHERE uid = ?1 AND sealed_at >= ?2",
|
||||
params![uid, now_ms - OWN_SEALED_TTL_MS],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Forget what we sealed on `uid` — it has been trashed, so there is no
|
||||
/// revision left to chain onto.
|
||||
pub fn clear_own_sealed_rev(&self, uid: &str) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute("DELETE FROM own_sealed_rev WHERE uid = ?1", params![uid])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// How long a revision this daemon sealed stays recognisable as its own.
|
||||
///
|
||||
/// Bounds a table that would otherwise grow with every write for the life of
|
||||
/// the install. A week is far longer than any stall→resume — a browser download
|
||||
/// that pauses and continues, an editor that reopens its handle — and short
|
||||
/// enough that the table tracks recent work rather than accumulating history.
|
||||
pub const OWN_SEALED_TTL_MS: i64 = 7 * 24 * 60 * 60 * 1000;
|
||||
@@ -115,7 +115,7 @@ impl Db {
|
||||
range: Option<(i64, i64)>,
|
||||
favorites: bool,
|
||||
) -> Result<Vec<StoredPhoto>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
// Built up so any combination of the optional filters is one indexed
|
||||
// query rather than a statement per case.
|
||||
let mut sql = String::from(
|
||||
@@ -174,7 +174,7 @@ impl Db {
|
||||
&self,
|
||||
kind: Option<crate::control::PhotoKind>,
|
||||
) -> Result<Vec<(i32, i32, usize)>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let (filter, binds): (&str, Vec<i64>) = match kind {
|
||||
Some(k) => (" WHERE kind = ?1", vec![k.as_i64()]),
|
||||
None => ("", Vec::new()),
|
||||
@@ -201,7 +201,7 @@ impl Db {
|
||||
/// Per-tab counts for the Photos page subtitle: `(photos, videos, raw)`.
|
||||
pub fn photos_counts(&self) -> Result<(usize, usize, usize)> {
|
||||
use crate::control::PhotoKind;
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT kind, COUNT(*) FROM photos GROUP BY kind")?;
|
||||
let rows = stmt.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, i64>(1)?)))?;
|
||||
let (mut photos, mut videos, mut raw) = (0usize, 0usize, 0usize);
|
||||
@@ -223,7 +223,7 @@ impl Db {
|
||||
if uids.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let placeholders = vec!["?"; uids.len()].join(",");
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"SELECT uid, capture_time, name, ratio, thumb_state, kind, favorite FROM photos
|
||||
@@ -273,7 +273,7 @@ impl Db {
|
||||
|
||||
/// Number of photos in the persisted timeline.
|
||||
pub fn photos_count(&self) -> Result<usize> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let n: i64 = conn.query_row("SELECT COUNT(*) FROM photos", [], |r| r.get(0))?;
|
||||
Ok(n.max(0) as usize)
|
||||
}
|
||||
|
||||
@@ -4,8 +4,21 @@
|
||||
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
|
||||
/// subtree is kept — while `is_dir` is the node's kind as the node cache knows
|
||||
/// it, or `None` for a pin whose node is not cached. Reading the former as the
|
||||
/// latter is what made a non-recursively pinned folder render as a file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PinRow {
|
||||
pub uid: String,
|
||||
pub path: String,
|
||||
pub recursive: bool,
|
||||
pub is_dir: Option<bool>,
|
||||
}
|
||||
|
||||
impl Db {
|
||||
pub fn pin_add(&self, uid: &str, path: &str, recursive: bool) -> Result<()> {
|
||||
let conn = self.conn.lock();
|
||||
@@ -25,16 +38,23 @@ impl Db {
|
||||
Ok(n > 0)
|
||||
}
|
||||
|
||||
/// Every directly-pinned entry `(uid, path, recursive)`, ordered by uid.
|
||||
pub fn pin_list(&self) -> Result<Vec<(String, String, bool)>> {
|
||||
let conn = self.conn.lock();
|
||||
let mut stmt = conn.prepare("SELECT uid, path, recursive FROM pins ORDER BY uid")?;
|
||||
/// Every directly-pinned entry, ordered by uid.
|
||||
pub fn pin_list(&self) -> Result<Vec<PinRow>> {
|
||||
let conn = self.read();
|
||||
// `recursive` is pin policy; the node's kind comes from `nodes`, and is
|
||||
// NULL for a pin whose node was never cached.
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT p.uid, p.path, p.recursive, n.is_dir
|
||||
FROM pins p LEFT JOIN nodes n ON n.uid = p.uid
|
||||
ORDER BY p.uid",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)? != 0,
|
||||
))
|
||||
Ok(PinRow {
|
||||
uid: r.get(0)?,
|
||||
path: r.get(1)?,
|
||||
recursive: r.get::<_, i64>(2)? != 0,
|
||||
is_dir: r.get::<_, Option<i64>>(3)?.map(|is_dir| is_dir != 0),
|
||||
})
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
@@ -47,8 +67,13 @@ 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 conn = self.read();
|
||||
let direct: Option<i64> = conn
|
||||
.query_row("SELECT 1 FROM pins WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
@@ -59,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),
|
||||
)
|
||||
@@ -78,7 +107,7 @@ impl Db {
|
||||
/// every descendant of a recursively-pinned folder. Used to build the
|
||||
/// eviction-exempt set (the budget enforcer hashes these into cache keys).
|
||||
pub fn pinned_uids(&self) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM pins WHERE recursive = 1
|
||||
@@ -100,7 +129,7 @@ impl Db {
|
||||
/// Every descendant uid of `folder` (all depths), via a `parent_uid` CTE.
|
||||
/// Used to evict a recursively-pinned subtree's cached blobs on unpin.
|
||||
pub fn descendants(&self, folder: &str) -> Result<Vec<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"WITH RECURSIVE sub(uid) AS (
|
||||
SELECT uid FROM nodes WHERE parent_uid = ?1
|
||||
|
||||
@@ -16,7 +16,7 @@ impl Db {
|
||||
/// remains the restart/offline authority and every explicit role change is
|
||||
/// written through before this cache is updated.
|
||||
pub fn all_share_access(&self) -> Result<HashMap<NodeUid, Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare("SELECT root_uid, access FROM share_access")?;
|
||||
let rows = stmt.query_map([], |row| {
|
||||
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
|
||||
@@ -42,7 +42,7 @@ impl Db {
|
||||
|
||||
/// Read the effective access recorded for a shared-tree root.
|
||||
pub fn share_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let value = conn
|
||||
.query_row(
|
||||
"SELECT access FROM share_access WHERE root_uid = ?1",
|
||||
@@ -96,7 +96,7 @@ impl Db {
|
||||
/// share root. `None` means the node itself is not persisted, which callers
|
||||
/// must not treat as owned: stale handles need to fail closed.
|
||||
pub fn effective_node_access(&self, uid: &NodeUid) -> Result<Option<Access>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let exists: bool = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM nodes WHERE uid = ?1)",
|
||||
[uid.to_string()],
|
||||
|
||||
@@ -8,7 +8,7 @@ use crate::Result;
|
||||
|
||||
impl Db {
|
||||
pub fn get_event_cursor(&self) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = 'event_cursor'",
|
||||
@@ -32,7 +32,7 @@ impl Db {
|
||||
|
||||
/// Read a `sync_state` value as a string.
|
||||
pub fn state_str(&self, key: &str) -> Result<Option<String>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let v = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
@@ -66,7 +66,7 @@ impl Db {
|
||||
/// dropping it would leave a file that exists nowhere but this machine with
|
||||
/// nothing left to create it. (Writes to a node that is itself still queued
|
||||
pub fn state_i64(&self, key: &str) -> Result<Option<i64>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let v: Option<String> = conn
|
||||
.query_row(
|
||||
"SELECT value FROM sync_state WHERE key = ?1",
|
||||
|
||||
@@ -57,7 +57,7 @@ impl Db {
|
||||
|
||||
/// Every synced folder, oldest first.
|
||||
pub fn sync_folder_list(&self) -> Result<Vec<StoredSyncFolder>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder ORDER BY id",
|
||||
@@ -83,7 +83,7 @@ impl Db {
|
||||
|
||||
/// Look up one synced folder by id.
|
||||
pub fn sync_folder_get(&self, id: i64) -> Result<Option<StoredSyncFolder>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT id, local_path, remote_uid, remote_share_id, mode, pending_mode, state, last_sync
|
||||
FROM sync_folder WHERE id = ?1",
|
||||
@@ -166,7 +166,7 @@ impl Db {
|
||||
|
||||
/// The whole per-file sync baseline for a folder, keyed by relative path.
|
||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
||||
FROM sync_entry WHERE folder_id = ?1",
|
||||
@@ -196,7 +196,7 @@ impl Db {
|
||||
/// `sync_folder` and descendants come from the last successful
|
||||
/// `sync_entry` baseline.
|
||||
pub fn mirror_contains_uid(&self, uid: &str) -> Result<bool> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(
|
||||
SELECT 1
|
||||
|
||||
@@ -867,6 +867,67 @@ fn shared_root_pinned_name_survives_database_reopen() {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
/// Two writers on one database is the failure mode the single-instance lock
|
||||
/// exists to prevent — a hand-run `pdfs mount` next to the systemd unit gives
|
||||
/// two daemons the same inode space, content cache, and drain queue. The second
|
||||
/// open has to fail, and it has to stop failing once the first handle is gone.
|
||||
#[test]
|
||||
fn a_second_writer_is_refused_while_the_first_holds_the_database() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-single-writer-{}-{}.db",
|
||||
std::process::id(),
|
||||
now_test_id()
|
||||
));
|
||||
let first = Db::open(&path).unwrap();
|
||||
let second = Db::open(&path);
|
||||
assert!(second.is_err(), "a second writer was allowed in");
|
||||
|
||||
drop(first);
|
||||
// The lock lives in the kernel and dies with the descriptor, so nothing has
|
||||
// to be cleaned up for the next daemon to start.
|
||||
Db::open(&path).expect("reopen after the first handle is dropped");
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||
}
|
||||
|
||||
/// `cache.db` is derived state — every row can be re-fetched or re-scanned — so
|
||||
/// a file that is not a database at all must not stop the daemon starting. It
|
||||
/// is moved aside (not deleted: it is the only evidence) and rebuilt.
|
||||
#[test]
|
||||
fn a_corrupt_database_is_moved_aside_and_rebuilt() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-corrupt-{}-{}.db",
|
||||
std::process::id(),
|
||||
now_test_id()
|
||||
));
|
||||
std::fs::write(&path, b"this is not an SQLite database").unwrap();
|
||||
|
||||
let db = Db::open(&path).unwrap();
|
||||
db.set_state_str("shared_with_me_name", "rebuilt").unwrap();
|
||||
assert_eq!(
|
||||
db.state_str("shared_with_me_name").unwrap().as_deref(),
|
||||
Some("rebuilt")
|
||||
);
|
||||
drop(db);
|
||||
|
||||
let dir = path.parent().unwrap();
|
||||
let stem = path.file_name().unwrap().to_string_lossy().to_string();
|
||||
let quarantined: Vec<_> = std::fs::read_dir(dir)
|
||||
.unwrap()
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.file_name().to_string_lossy().to_string())
|
||||
.filter(|name| name.starts_with(&format!("{stem}.corrupt-")))
|
||||
.collect();
|
||||
assert_eq!(quarantined.len(), 1, "the damaged file was not kept");
|
||||
|
||||
for name in quarantined {
|
||||
let _ = std::fs::remove_file(dir.join(name));
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(path.with_extension("db.lock"));
|
||||
}
|
||||
|
||||
fn local(path: &str, name: &str, is_dir: bool) -> LocalEntry {
|
||||
LocalEntry {
|
||||
path: path.into(),
|
||||
@@ -911,6 +972,25 @@ fn local_scan_indexes_then_prunes_stale_paths() {
|
||||
assert_eq!(db.search_local("notes", 10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// The index is maintained incrementally rather than rebuilt at the end of a
|
||||
/// scan, so the case that used to be covered for free — a file the scan sees
|
||||
/// again — needs its own test: a re-scan must neither lose the entry nor
|
||||
/// duplicate it.
|
||||
#[test]
|
||||
fn rescanning_an_unchanged_file_keeps_exactly_one_index_entry() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
for (generation, mtime) in [(1_000, 5), (2_000, 9)] {
|
||||
let g = db.local_begin_scan().unwrap();
|
||||
let mut e = local("/home/u/docs/report.pdf", "report.pdf", false);
|
||||
e.mtime = mtime;
|
||||
db.local_upsert_batch(g, &[e]).unwrap();
|
||||
assert_eq!(db.local_finish_scan(g, generation).unwrap(), 1);
|
||||
let hits = db.search_local("report", 10).unwrap();
|
||||
assert_eq!(hits.len(), 1, "generation {generation}");
|
||||
assert_eq!(hits[0].mtime, mtime);
|
||||
}
|
||||
}
|
||||
|
||||
/// Queries below the trigram minimum still match, via the `LIKE` fallback.
|
||||
#[test]
|
||||
fn local_search_short_query_like_fallback() {
|
||||
@@ -989,6 +1069,62 @@ fn search_excludes_trashed_and_respects_limit() {
|
||||
assert_eq!(db.search("alph", 1).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// A device folder's root is stored in `device`/`sync_folder`, never in
|
||||
/// `nodes`, so its children's `parent_uid` points at a row that does not exist.
|
||||
/// Requiring the ancestor walk to reach a `parent_uid IS NULL` row therefore
|
||||
/// excluded every device-folder subtree from search: 2,705 of 9,252 nodes on
|
||||
/// the account this was found on, including everything under `~/Downloads`.
|
||||
#[test]
|
||||
fn search_finds_nodes_below_an_uncached_parent() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
// "device-root" is deliberately never upserted.
|
||||
db.upsert_node(&folder("downloads", Some("device-root"), "Downloads"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("t", "downloads", "tickets.pdf", 1))
|
||||
.unwrap();
|
||||
|
||||
let hits = db.search("tickets", 10).unwrap();
|
||||
assert_eq!(hits.len(), 1, "a device folder's files must be searchable");
|
||||
assert_eq!(hits[0].path, "Downloads/tickets.pdf");
|
||||
}
|
||||
|
||||
/// The relaxed rule must not also start indexing trash: a node whose *parent*
|
||||
/// is trashed stays out even though the walk now terminates happily.
|
||||
#[test]
|
||||
fn search_excludes_a_node_under_a_trashed_ancestor() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
let mut folder_gone = folder("boxed", Some("root"), "Boxed");
|
||||
folder_gone.trashed = true;
|
||||
db.upsert_node(&folder_gone).unwrap();
|
||||
db.upsert_node(&file("f1", "boxed", "buried.txt", 1))
|
||||
.unwrap();
|
||||
|
||||
assert!(db.search("buried", 10).unwrap().is_empty());
|
||||
}
|
||||
|
||||
/// A parent cycle is corrupt data the API can still hand us. It must stay out
|
||||
/// of the index, and — because the short-query `LIKE` lane reads `nodes`
|
||||
/// directly and never consults the index — resolving one's path must terminate
|
||||
/// rather than spin inside the daemon's only SQLite connection.
|
||||
#[test]
|
||||
fn a_parent_cycle_is_unindexed_and_never_hangs_a_search() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("a", Some("b"), "Alphaville"))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("b", Some("a"), "Betamax")).unwrap();
|
||||
|
||||
// Index lane: the cycle is not indexed, so it cannot be found.
|
||||
assert!(db.search("alphaville", 10).unwrap().is_empty());
|
||||
assert!(db.search("betamax", 10).unwrap().is_empty());
|
||||
|
||||
// LIKE lane (below the trigram minimum): the rows are reachable, and what
|
||||
// matters is that resolving their paths returns at all.
|
||||
let hits = db.search("al", 10).unwrap();
|
||||
assert_eq!(hits.len(), 1);
|
||||
assert!(hits[0].path.ends_with("Alphaville"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_short_query_like_fallback() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
@@ -1373,6 +1509,7 @@ fn migration_v17_adds_share_access_to_a_v16_fixture() {
|
||||
DROP TABLE albums;
|
||||
DROP INDEX idx_photos_favorite;
|
||||
ALTER TABLE photos DROP COLUMN favorite;
|
||||
ALTER TABLE nodes DROP COLUMN path;
|
||||
UPDATE sync_state SET value = '16' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2472,14 +2609,41 @@ fn pin_add_list_remove_roundtrip() {
|
||||
db.pin_add("vol~d", "docs", true).unwrap();
|
||||
let list = db.pin_list().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0], ("vol~a".into(), "docs/a.txt".into(), false));
|
||||
assert_eq!(list[1], ("vol~d".into(), "docs".into(), true));
|
||||
// No `nodes` row for either pin, so the kind is unknown rather than guessed
|
||||
// from the recursive flag.
|
||||
assert_eq!(
|
||||
list[0],
|
||||
PinRow {
|
||||
uid: "vol~a".into(),
|
||||
path: "docs/a.txt".into(),
|
||||
recursive: false,
|
||||
is_dir: None,
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
list[1],
|
||||
PinRow {
|
||||
uid: "vol~d".into(),
|
||||
path: "docs".into(),
|
||||
recursive: true,
|
||||
is_dir: None,
|
||||
}
|
||||
);
|
||||
|
||||
// Once the node is cached, the pin reports the real kind — a folder pinned
|
||||
// non-recursively must not look like a file.
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("d", Some("root"), "docs")).unwrap();
|
||||
db.pin_add("vol~d", "docs", false).unwrap();
|
||||
let list = db.pin_list().unwrap();
|
||||
assert!(!list[1].recursive, "pin policy stays non-recursive");
|
||||
assert_eq!(list[1].is_dir, Some(true), "but the node is still a folder");
|
||||
|
||||
// Re-pin refreshes path/flag, not a duplicate row.
|
||||
db.pin_add("vol~a", "moved/a.txt", false).unwrap();
|
||||
let list = db.pin_list().unwrap();
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].1, "moved/a.txt");
|
||||
assert_eq!(list[0].path, "moved/a.txt");
|
||||
|
||||
assert!(db.pin_remove("vol~a").unwrap());
|
||||
assert!(!db.pin_remove("vol~a").unwrap());
|
||||
@@ -2545,6 +2709,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();
|
||||
@@ -3099,3 +3283,265 @@ fn clear_state_prefix_takes_only_the_prefixed_keys() {
|
||||
"the listing's own stamp is not a per-album one"
|
||||
);
|
||||
}
|
||||
|
||||
// --- `nodes.path`, and the subtree work it replaces (DB3) -------------------
|
||||
|
||||
/// Read the stored path column directly: the point of these tests is that the
|
||||
/// column itself is right, not that `path_of` can still fall back to a walk.
|
||||
fn stored_path(db: &Db, link: &str) -> Option<String> {
|
||||
use rusqlite::OptionalExtension;
|
||||
db.with_conn(|conn| {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT path FROM nodes WHERE uid = ?1",
|
||||
[uid(link).to_string()],
|
||||
|row| row.get::<_, Option<String>>(0),
|
||||
)
|
||||
.optional()?
|
||||
.flatten())
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_stores_its_path_when_it_is_written() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "docs", "report.pdf", 1))
|
||||
.unwrap();
|
||||
|
||||
// The root's name is the mount, so it contributes nothing.
|
||||
assert_eq!(stored_path(&db, "root").as_deref(), Some(""));
|
||||
assert_eq!(stored_path(&db, "docs").as_deref(), Some("Documents"));
|
||||
assert_eq!(
|
||||
stored_path(&db, "f1").as_deref(),
|
||||
Some("Documents/report.pdf")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_below_an_uncached_parent_starts_its_own_path() {
|
||||
// A device folder's root lives in `device`/`sync_folder` and never gets a
|
||||
// `nodes` row, so its children are the top of the cached chain.
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("orphan", Some("never-cached"), "Work"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "orphan", "notes.txt", 1))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored_path(&db, "orphan").as_deref(), Some("Work"));
|
||||
assert_eq!(stored_path(&db, "f1").as_deref(), Some("Work/notes.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renaming_a_folder_rewrites_every_descendant_path() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("a", Some("root"), "Before"))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("b", Some("a"), "Inner")).unwrap();
|
||||
db.upsert_node(&file("f1", "b", "deep.txt", 1)).unwrap();
|
||||
assert_eq!(
|
||||
stored_path(&db, "f1").as_deref(),
|
||||
Some("Before/Inner/deep.txt")
|
||||
);
|
||||
|
||||
db.upsert_node(&folder("a", Some("root"), "After")).unwrap();
|
||||
|
||||
assert_eq!(stored_path(&db, "a").as_deref(), Some("After"));
|
||||
assert_eq!(stored_path(&db, "b").as_deref(), Some("After/Inner"));
|
||||
assert_eq!(
|
||||
stored_path(&db, "f1").as_deref(),
|
||||
Some("After/Inner/deep.txt")
|
||||
);
|
||||
let hit = &db.search("deep.txt", 10).unwrap()[0];
|
||||
assert_eq!(
|
||||
hit.path, "After/Inner/deep.txt",
|
||||
"the search index must carry the rewritten path too"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn moving_a_folder_rewrites_every_descendant_path() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("old", Some("root"), "Old")).unwrap();
|
||||
db.upsert_node(&folder("new", Some("root"), "New")).unwrap();
|
||||
db.upsert_node(&folder("moved", Some("old"), "Moved"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "moved", "deep.txt", 1)).unwrap();
|
||||
|
||||
db.upsert_node(&folder("moved", Some("new"), "Moved"))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(stored_path(&db, "moved").as_deref(), Some("New/Moved"));
|
||||
assert_eq!(
|
||||
stored_path(&db, "f1").as_deref(),
|
||||
Some("New/Moved/deep.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trashing_a_folder_drops_its_subtree_from_the_index() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("a", Some("root"), "Holder"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "a", "hidden.txt", 1)).unwrap();
|
||||
assert_eq!(db.search("hidden", 10).unwrap().len(), 1);
|
||||
|
||||
let mut trashed = folder("a", Some("root"), "Holder");
|
||||
trashed.trashed = true;
|
||||
db.upsert_node(&trashed).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
db.search("hidden", 10).unwrap().len(),
|
||||
0,
|
||||
"a file under a trashed folder is not reachable and must not be findable"
|
||||
);
|
||||
|
||||
// …and comes back when the folder does.
|
||||
db.upsert_node(&folder("a", Some("root"), "Holder"))
|
||||
.unwrap();
|
||||
assert_eq!(db.search("hidden", 10).unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// The re-listing case, which is most of what the write path actually does: a
|
||||
/// folder upserted with the same name and parent must not touch its subtree.
|
||||
///
|
||||
/// Written as a ratio between two subtree sizes, so it fails on the scaling
|
||||
/// regression rather than on a slow machine. Before this, every re-listing paid
|
||||
/// a `path_of` walk plus an ancestor walk per descendant.
|
||||
#[test]
|
||||
fn re_listing_a_folder_does_not_scale_with_its_subtree() {
|
||||
fn elapsed_for(children: usize) -> std::time::Duration {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("big", Some("root"), "Big")).unwrap();
|
||||
let kids: Vec<Node> = (0..children)
|
||||
.map(|i| file(&format!("k{i}"), "big", &format!("child-{i}.txt"), 1))
|
||||
.collect();
|
||||
db.upsert_nodes(&kids).unwrap();
|
||||
|
||||
let unchanged = folder("big", Some("root"), "Big");
|
||||
let start = std::time::Instant::now();
|
||||
for _ in 0..20 {
|
||||
db.upsert_node(&unchanged).unwrap();
|
||||
}
|
||||
start.elapsed()
|
||||
}
|
||||
|
||||
let small = elapsed_for(100);
|
||||
let large = elapsed_for(2_000);
|
||||
assert!(
|
||||
large < small * 8,
|
||||
"re-listing a folder scaled with its subtree: {small:?} for 100 children, \
|
||||
{large:?} for 2000 (20x the descendants)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_v25_backfills_paths_for_an_existing_tree() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-db-v24-fixture-{}-{}.db",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
let db = Db::open(&path).unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("docs", Some("root"), "Documents"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "docs", "report.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("orphan", Some("never-cached"), "Work"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f2", "orphan", "notes.txt", 1))
|
||||
.unwrap();
|
||||
}
|
||||
{
|
||||
// Put the file back in the state a released V24 database was in.
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE nodes DROP COLUMN path;
|
||||
UPDATE sync_state SET value = '24' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Db::open(&path).unwrap();
|
||||
assert_eq!(stored_path(&db, "root").as_deref(), Some(""));
|
||||
assert_eq!(stored_path(&db, "docs").as_deref(), Some("Documents"));
|
||||
assert_eq!(
|
||||
stored_path(&db, "f1").as_deref(),
|
||||
Some("Documents/report.pdf"),
|
||||
"an existing tree must come out of the migration with the same paths \
|
||||
the walk used to produce"
|
||||
);
|
||||
// A subtree whose top has an uncached parent is ordinary data, not an error.
|
||||
assert_eq!(stored_path(&db, "orphan").as_deref(), Some("Work"));
|
||||
assert_eq!(stored_path(&db, "f2").as_deref(), Some("Work/notes.txt"));
|
||||
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||
}
|
||||
|
||||
// --- read-only connection pool (DB2) ---------------------------------------
|
||||
|
||||
/// A read must not queue behind whatever happens to be writing.
|
||||
///
|
||||
/// The write connection is held for the whole measurement — which is what a
|
||||
/// large listing's commit does to it — while a reader asks for a search. Before
|
||||
/// the pool this was the same mutex, so the read waited for the writer;
|
||||
/// `busy_timeout` never entered into it, because the contention was this
|
||||
/// process's own lock rather than SQLite's.
|
||||
#[test]
|
||||
fn a_read_does_not_wait_for_the_write_connection() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-db-readpool-{}-{}.db",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let db = std::sync::Arc::new(Db::open(&path).unwrap());
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&file("f1", "root", "findme.txt", 1))
|
||||
.unwrap();
|
||||
|
||||
let held = std::time::Duration::from_millis(500);
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
let writer = {
|
||||
let db = db.clone();
|
||||
std::thread::spawn(move || {
|
||||
db.with_conn(|_conn| {
|
||||
tx.send(()).unwrap();
|
||||
std::thread::sleep(held);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
})
|
||||
};
|
||||
rx.recv().unwrap();
|
||||
|
||||
let start = std::time::Instant::now();
|
||||
let hits = db.search("findme", 10).unwrap();
|
||||
let waited = start.elapsed();
|
||||
writer.join().unwrap();
|
||||
|
||||
assert_eq!(hits.len(), 1, "the read must still see committed data");
|
||||
assert!(
|
||||
waited < held / 2,
|
||||
"the read waited {waited:?} for a write connection held {held:?}"
|
||||
);
|
||||
|
||||
drop(db);
|
||||
let _ = std::fs::remove_file(&path);
|
||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||
}
|
||||
@@ -39,7 +39,7 @@ impl Db {
|
||||
/// The persisted trash listing, folders first then by name — the order the
|
||||
/// Trash page shows it in.
|
||||
pub fn trash_list(&self) -> Result<Vec<StoredTrash>> {
|
||||
let conn = self.conn.lock();
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT uid, name, is_dir, size, mtime FROM trash
|
||||
ORDER BY is_dir DESC, name COLLATE NOCASE",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Small query helpers shared across the table modules.
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
|
||||
use crate::Result;
|
||||
|
||||
@@ -8,14 +8,19 @@ use crate::Result;
|
||||
/// grams), so short queries fall back to a `LIKE` scan over `nodes.name`.
|
||||
pub(super) const TRIGRAM_MIN: usize = 3;
|
||||
|
||||
pub(super) fn pair(row: &rusqlite::Row) -> rusqlite::Result<(String, String)> {
|
||||
Ok((row.get(0)?, row.get(1)?))
|
||||
/// One raw search row: the node's JSON, its uid, and its stored path — `None`
|
||||
/// only for a row written before the `path` column existed, which
|
||||
/// [`path_of`] repairs by walking.
|
||||
pub(super) type HitRow = (String, String, Option<String>);
|
||||
|
||||
pub(super) fn hit_row(row: &rusqlite::Row) -> rusqlite::Result<HitRow> {
|
||||
Ok((row.get(0)?, row.get(1)?, row.get(2)?))
|
||||
}
|
||||
|
||||
/// Drain a `query_map` of [`pair`] rows into a `Vec`, propagating row errors.
|
||||
pub(super) fn collect_pairs(
|
||||
rows: impl Iterator<Item = rusqlite::Result<(String, String)>>,
|
||||
) -> Result<Vec<(String, String)>> {
|
||||
/// Drain a `query_map` of [`hit_row`] rows into a `Vec`, propagating row errors.
|
||||
pub(super) fn collect_hits(
|
||||
rows: impl Iterator<Item = rusqlite::Result<HitRow>>,
|
||||
) -> Result<Vec<HitRow>> {
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
@@ -31,19 +36,58 @@ pub(super) fn like_escape(s: &str) -> String {
|
||||
.replace('_', "\\_")
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// a top-level file `report.pdf` yields `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||
/// How deep the parent walk may go before it gives up. Real Drive trees are
|
||||
/// 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.
|
||||
pub(super) const MAX_PATH_DEPTH: usize = 256;
|
||||
|
||||
/// Append `name` to a parent's stored path. An empty parent path is the root,
|
||||
/// whose name is the mount and so contributes nothing.
|
||||
pub(super) fn join_path(parent_path: &str, name: &str) -> String {
|
||||
if parent_path.is_empty() {
|
||||
name.to_string()
|
||||
} else {
|
||||
format!("{parent_path}/{name}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a node's mountpoint-relative path. The root (the node with no
|
||||
/// parent) is excluded, so a top-level file `report.pdf` yields
|
||||
/// `"report.pdf"`, not `"My Files/report.pdf"`.
|
||||
///
|
||||
/// Reads the stored `nodes.path`, which the write path maintains (see
|
||||
/// `upsert_node_tx`). Falls back to [`walk_path_of`] for a row that predates the
|
||||
/// column or was never written through — a search serving a keystroke used to
|
||||
/// run that walk once per candidate, which is the reason the column exists.
|
||||
pub(super) fn path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let mut stmt = conn.prepare(
|
||||
let stored: Option<Option<String>> = conn
|
||||
.query_row("SELECT path FROM nodes WHERE uid = ?1", params![uid], |r| {
|
||||
r.get(0)
|
||||
})
|
||||
.optional()?;
|
||||
match stored.flatten() {
|
||||
Some(path) => Ok(path),
|
||||
None => walk_path_of(conn, uid),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a path by walking `parent_uid` to the root via a recursive CTE.
|
||||
///
|
||||
/// A cycle in `parent_uid` is corrupt data the API can still hand us, and
|
||||
/// `UNION ALL` over one never terminates — the walk is therefore depth-capped
|
||||
/// and returns the truncated path rather than hanging the caller.
|
||||
pub(super) fn walk_path_of(conn: &Connection, uid: &str) -> Result<String> {
|
||||
let mut stmt = conn.prepare(&format!(
|
||||
"WITH RECURSIVE anc(uid, parent_uid, name, depth) AS (
|
||||
SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1
|
||||
UNION ALL
|
||||
SELECT n.uid, n.parent_uid, n.name, anc.depth + 1
|
||||
FROM nodes n JOIN anc ON n.uid = anc.parent_uid
|
||||
WHERE anc.depth < {MAX_PATH_DEPTH}
|
||||
)
|
||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC",
|
||||
)?;
|
||||
SELECT name, parent_uid FROM anc ORDER BY depth DESC"
|
||||
))?;
|
||||
let rows = stmt.query_map(params![uid], |r| {
|
||||
Ok((r.get::<_, String>(0)?, r.get::<_, Option<String>>(1)?))
|
||||
})?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -26,10 +26,20 @@ const SKIP_DIRS: &[&str] = &[
|
||||
"venv",
|
||||
".venv",
|
||||
"vendor",
|
||||
"dist",
|
||||
"dist-newstyle",
|
||||
"build",
|
||||
"Trash",
|
||||
];
|
||||
|
||||
/// Directory *paths* skipped when they end with one of these, for caches whose
|
||||
/// own name is too generic to blacklist (`mod`). The Go module cache was 63,399
|
||||
/// of the 110,187 entries indexed on the machine this was measured on — 58% of
|
||||
/// the index, crowding real files out of the bounded candidate pool the fuzzy
|
||||
/// scorer draws from. The other big dependency caches (`.cargo`, `.rustup`,
|
||||
/// `.gradle`, `.m2`) are already skipped by the hidden-file filter.
|
||||
const SKIP_PATH_SUFFIXES: &[&str] = &["go/pkg/mod"];
|
||||
|
||||
/// Hard cap on indexed entries, so a pathological home directory cannot grow the
|
||||
/// database without bound. Reaching it stops the walk early.
|
||||
const MAX_ENTRIES: usize = 400_000;
|
||||
@@ -90,7 +100,10 @@ pub fn scan(
|
||||
if excludes.iter().any(|e| path.starts_with(e)) {
|
||||
return false;
|
||||
}
|
||||
!matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name))
|
||||
if matches!(entry.file_name().to_str(), Some(name) if SKIP_DIRS.contains(&name)) {
|
||||
return false;
|
||||
}
|
||||
!is_skipped_path(path)
|
||||
});
|
||||
|
||||
let state = parking_lot::Mutex::new(SinkState {
|
||||
@@ -150,6 +163,17 @@ impl<F: FnMut(Vec<LocalEntry>)> SinkState<F> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` is (or is inside) one of the [`SKIP_PATH_SUFFIXES`] caches.
|
||||
/// Matched on the path's own components so a directory merely *named* `mod`
|
||||
/// keeps being indexed.
|
||||
fn is_skipped_path(path: &Path) -> bool {
|
||||
let path = path.to_string_lossy();
|
||||
SKIP_PATH_SUFFIXES.iter().any(|suffix| {
|
||||
path.strip_suffix(suffix)
|
||||
.is_some_and(|head| head.is_empty() || head.ends_with('/'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Convert a walker entry into a [`LocalEntry`], reusing the metadata the walker
|
||||
/// already fetched. Entries with non-UTF-8 paths are dropped: the index (and the
|
||||
/// JSON wire format) is UTF-8 only.
|
||||
@@ -221,4 +245,27 @@ mod tests {
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
|
||||
/// The Go module cache is the single largest source of index noise on a
|
||||
/// developer's machine (58% of the entries on the one this was measured
|
||||
/// on), and it cannot be pruned by directory name: `mod` is far too
|
||||
/// common a name to blacklist outright.
|
||||
#[test]
|
||||
fn the_go_module_cache_is_pruned_but_a_plain_mod_dir_is_not() {
|
||||
let tmp = std::env::temp_dir().join(format!("pdfs-scan-gomod-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&tmp);
|
||||
std::fs::create_dir_all(tmp.join("go/pkg/mod/example.com/lib")).unwrap();
|
||||
std::fs::create_dir_all(tmp.join("kernel/mod")).unwrap();
|
||||
std::fs::write(tmp.join("go/pkg/mod/example.com/lib/vendored.go"), b"x").unwrap();
|
||||
std::fs::write(tmp.join("kernel/mod/mine.conf"), b"x").unwrap();
|
||||
|
||||
let mut got = Vec::new();
|
||||
scan(std::slice::from_ref(&tmp), &[], |batch| got.extend(batch));
|
||||
|
||||
let names: Vec<&str> = got.iter().map(|e| e.name.as_str()).collect();
|
||||
assert!(!names.contains(&"vendored.go"), "{names:?}");
|
||||
assert!(names.contains(&"mine.conf"), "{names:?}");
|
||||
|
||||
std::fs::remove_dir_all(&tmp).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -8,35 +8,20 @@ use super::*;
|
||||
///
|
||||
/// Returns whether this mount actually held the node, so the caller can evict
|
||||
/// its content blob once if *any* mount did.
|
||||
fn forget_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) -> bool {
|
||||
fn forget_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) -> bool {
|
||||
// Capture the inode before `forget` clears the uid mapping.
|
||||
let child = st.by_uid.get(uid).copied();
|
||||
let Some((parent, name)) = st.forget(uid) else {
|
||||
return false;
|
||||
};
|
||||
if let Some(notifier) = notifier {
|
||||
match child {
|
||||
Some(child) => {
|
||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||
}
|
||||
None => {
|
||||
let _ = notifier.inval_entry(INodeNo(parent), OsStr::new(&name));
|
||||
}
|
||||
}
|
||||
match child {
|
||||
Some(child) => notify.delete(parent, child, name),
|
||||
None => notify.inval_entry(parent, name),
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn invalidate_access_changes(notifier: Option<&Notifier>, changed: &[u64]) {
|
||||
let Some(notifier) = notifier else {
|
||||
return;
|
||||
};
|
||||
for &ino in changed {
|
||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>, uid: &NodeUid) {
|
||||
fn hide_foreign_deleted_and_notify(st: &mut State, notify: &mut NotifyBatch, uid: &NodeUid) {
|
||||
let child = st.by_uid.get(uid).copied();
|
||||
let dentry = child.and_then(|ino| {
|
||||
st.entries
|
||||
@@ -45,9 +30,9 @@ fn hide_foreign_deleted_and_notify(st: &mut State, notifier: Option<&Notifier>,
|
||||
});
|
||||
let changed = st.downgrade_shared_subtree(uid);
|
||||
st.hide_foreign_subtree(uid);
|
||||
invalidate_access_changes(notifier, &changed);
|
||||
if let (Some(notifier), Some((parent, name)), Some(child)) = (notifier, dentry, child) {
|
||||
let _ = notifier.delete(INodeNo(parent), INodeNo(child), OsStr::new(&name));
|
||||
notify.extend_inodes(&changed);
|
||||
if let (Some((parent, name)), Some(child)) = (dentry, child) {
|
||||
notify.delete(parent, child, name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -132,18 +117,18 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
&& foreign_delete
|
||||
{
|
||||
apply_foreign_delete(&core.db, node_uid, |uid| {
|
||||
core.for_each_mount(|st, notifier| {
|
||||
hide_foreign_deleted_and_notify(st, notifier, uid);
|
||||
core.for_each_mount(|st, notify| {
|
||||
hide_foreign_deleted_and_notify(st, notify, uid);
|
||||
});
|
||||
})
|
||||
} else {
|
||||
apply_access_downgrade(&core.db, event, |root| {
|
||||
core.for_each_mount(|st, notifier| {
|
||||
core.for_each_mount(|st, notify| {
|
||||
let changed = match root {
|
||||
Some(uid) => st.downgrade_shared_subtree(uid),
|
||||
None => st.downgrade_known_shared_access(),
|
||||
};
|
||||
invalidate_access_changes(notifier, &changed);
|
||||
notify.extend_inodes(&changed);
|
||||
});
|
||||
})
|
||||
};
|
||||
@@ -173,19 +158,17 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
debug!(uid = %node_uid, "ignoring remote event for a node with a queued write");
|
||||
}
|
||||
let mut had_node = false;
|
||||
core.for_each_mount(|st, notifier| {
|
||||
core.for_each_mount(|st, notify| {
|
||||
if *is_trashed {
|
||||
// Trashing makes a node vanish from its parent listing.
|
||||
had_node |= forget_and_notify(st, notifier, node_uid);
|
||||
had_node |= forget_and_notify(st, notify, node_uid);
|
||||
} else if !ours && let Some(&ino) = st.by_uid.get(node_uid) {
|
||||
// Known node changed: drop its cached attrs/data (and
|
||||
// listing if it is a directory) so the next access
|
||||
// re-fetches. Its content blob may now be stale too.
|
||||
had_node = true;
|
||||
st.invalidate_listing(ino);
|
||||
if let Some(notifier) = notifier {
|
||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||
}
|
||||
notify.inval_inode(ino);
|
||||
}
|
||||
});
|
||||
// Once, not once per mount: the content cache is per-daemon.
|
||||
@@ -209,8 +192,8 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
DriveEvent::NodeDeleted { node_uid, .. } => {
|
||||
core.cache.evict(node_uid);
|
||||
if !foreign_delete {
|
||||
core.for_each_mount(|st, notifier| {
|
||||
forget_and_notify(st, notifier, node_uid);
|
||||
core.for_each_mount(|st, notify| {
|
||||
forget_and_notify(st, notify, node_uid);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -223,13 +206,11 @@ fn apply_event(core: &Core, event: &DriveEvent, dirty: &mut DirtyParents) -> pdf
|
||||
| DriveEvent::SharedWithMeUpdated { .. } => {
|
||||
warn!("event access continuity lost; downgrading shared trees and resyncing lazily");
|
||||
apply_result = apply_result.and(core.db.clear_state(SHARED_WITH_ME_SYNCED_MS));
|
||||
core.for_each_mount(|st, notifier| {
|
||||
core.for_each_mount(|st, notify| {
|
||||
let dirs: Vec<u64> = st.children.keys().copied().collect();
|
||||
for &ino in &dirs {
|
||||
st.invalidate_listing(ino);
|
||||
if let Some(notifier) = notifier {
|
||||
let _ = notifier.inval_inode(INodeNo(ino), 0, 0);
|
||||
}
|
||||
notify.inval_inode(ino);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -303,18 +284,16 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
||||
if dirty.foreign.is_empty() && dirty.ours.is_empty() {
|
||||
return;
|
||||
}
|
||||
core.for_each_mount(|st, notifier| {
|
||||
let drop_listing = |st: &mut State, parent_uid: &NodeUid| {
|
||||
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||
return;
|
||||
};
|
||||
st.invalidate_listing(parent);
|
||||
if let Some(notifier) = notifier {
|
||||
let _ = notifier.inval_inode(INodeNo(parent), 0, 0);
|
||||
}
|
||||
fn drop_listing(st: &mut State, notify: &mut NotifyBatch, parent_uid: &NodeUid) {
|
||||
let Some(&parent) = st.by_uid.get(parent_uid) else {
|
||||
return;
|
||||
};
|
||||
st.invalidate_listing(parent);
|
||||
notify.inval_inode(parent);
|
||||
}
|
||||
core.for_each_mount(|st, notify| {
|
||||
for parent_uid in &dirty.foreign {
|
||||
drop_listing(st, parent_uid);
|
||||
drop_listing(st, notify, parent_uid);
|
||||
}
|
||||
for (parent_uid, children) in &dirty.ours {
|
||||
if dirty.foreign.contains(parent_uid) {
|
||||
@@ -328,7 +307,7 @@ fn flush_dirty_parents(core: &Core, dirty: &DirtyParents) {
|
||||
{
|
||||
continue;
|
||||
}
|
||||
drop_listing(st, parent_uid);
|
||||
drop_listing(st, notify, parent_uid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -29,7 +29,23 @@ use super::{Core, count_noun, human_bytes, human_duration, parse_uid};
|
||||
|
||||
const MAX_CONTROL_REQUEST_BYTES: u64 = 1024 * 1024;
|
||||
const CONTROL_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// A client that connects, sends a request and then never reads its reply used
|
||||
/// to park a handler thread forever in `write_all`; 64 of those exhaust
|
||||
/// [`MAX_CONTROL_HANDLERS`] permanently. Generous enough for the largest
|
||||
/// responses (photo bytes) over a local socket, finite so a wedged peer can't
|
||||
/// hold a permit.
|
||||
const CONTROL_WRITE_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_CONTROL_HANDLERS: usize = 64;
|
||||
/// Ceiling on any client-supplied row `limit`. Every in-tree caller asks for far
|
||||
/// less (200 for activity, 60 for a photo page), but the value goes straight
|
||||
/// into `SQL LIMIT ?` under the daemon's single connection — an unclamped one
|
||||
/// materialises the whole account, and then serialises it into one JSON line.
|
||||
const MAX_ROW_LIMIT: usize = 1000;
|
||||
|
||||
/// Clamp a client-supplied row limit to [`MAX_ROW_LIMIT`].
|
||||
fn clamp_limit(limit: usize) -> usize {
|
||||
limit.min(MAX_ROW_LIMIT)
|
||||
}
|
||||
static ACTIVE_CONTROL_HANDLERS: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
struct ControlHandlerPermit;
|
||||
@@ -109,6 +125,10 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
warn!(error = %e, "control: could not set request timeout");
|
||||
return;
|
||||
}
|
||||
if let Err(e) = stream.set_write_timeout(Some(CONTROL_WRITE_TIMEOUT)) {
|
||||
warn!(error = %e, "control: could not set response timeout");
|
||||
return;
|
||||
}
|
||||
let mut reader = BufReader::new(match stream.try_clone() {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
@@ -128,7 +148,13 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Ok(CtlRequest::Status) => {
|
||||
let pins = core.cache.list_pins();
|
||||
let queued = core.db.pending_op_counts().unwrap_or_default();
|
||||
let staging = core.cache.staging_usage();
|
||||
CtlResponse::Status {
|
||||
parked_uploads: queued.parked.max(0) as u64,
|
||||
failing_ops: queued.failing.max(0) as u64,
|
||||
failing_error: queued.last_error.clone(),
|
||||
staged_bytes: staging.bytes,
|
||||
staged_oldest_secs: staging.oldest_secs,
|
||||
username: username.to_string(),
|
||||
mountpoint: mountpoint.display().to_string(),
|
||||
pinned: pins.len(),
|
||||
@@ -204,7 +230,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
kind,
|
||||
range,
|
||||
favorites,
|
||||
}) => match core.photos_timeline(offset, limit, kind, range, favorites) {
|
||||
}) => match core.photos_timeline(offset, clamp_limit(limit), kind, range, favorites) {
|
||||
Ok(Some(items)) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
@@ -245,7 +271,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
// An album page is a timeline page as far as the front-end is
|
||||
// concerned, so it comes back in the same reply — with `counts`
|
||||
// unset, which describe the whole timeline rather than this album.
|
||||
Some(album) => match core.album_photos(&album, offset, limit) {
|
||||
Some(album) => match core.album_photos(&album, offset, clamp_limit(limit)) {
|
||||
Ok(items) => CtlResponse::Photos {
|
||||
available: true,
|
||||
items,
|
||||
@@ -346,22 +372,24 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, limit) {
|
||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(limit)) {
|
||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SearchLocal { query, limit }) => match core.search_local(&query, limit) {
|
||||
Ok(hits) => CtlResponse::LocalResults {
|
||||
hits,
|
||||
indexing: core.indexing.load(Ordering::Relaxed),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::SearchLocal { query, limit }) => {
|
||||
match core.search_local(&query, clamp_limit(limit)) {
|
||||
Ok(hits) => CtlResponse::LocalResults {
|
||||
hits,
|
||||
indexing: core.indexing.load(Ordering::Relaxed),
|
||||
},
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::SearchV2 {
|
||||
query,
|
||||
limit,
|
||||
filters,
|
||||
}) => match core.search_v2(&query, limit, &filters) {
|
||||
}) => match core.search_v2(&query, clamp_limit(limit), &filters) {
|
||||
Ok((drive_hits, local_hits)) => CtlResponse::SearchResultsV2 {
|
||||
drive_hits,
|
||||
local_hits,
|
||||
@@ -540,19 +568,20 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
Ok(CtlRequest::CacheInspect { deep }) => match core.db.stats() {
|
||||
Ok(stats) => {
|
||||
// The deep check reads every page, so it runs only on request;
|
||||
// `integrity_checked` tells the caller which answer it is
|
||||
// holding, since "no problems found" and "never looked" are
|
||||
// both an empty list.
|
||||
let (integrity_problems, integrity_checked) = if deep {
|
||||
match core.db.integrity_check() {
|
||||
Ok(problems) => (problems, true),
|
||||
Err(e) => (vec![format!("integrity check failed: {e:?}")], true),
|
||||
}
|
||||
// The deep check reads every page — tens of seconds on a real
|
||||
// database, all of it holding the connection every FUSE handler
|
||||
// needs — so it runs on a thread and this request returns
|
||||
// whatever the last completed check found. `integrity_checked`
|
||||
// tells the caller which answer it is holding, since "no
|
||||
// problems found" and "never looked" are both an empty list;
|
||||
// `integrity_running` says a fresh answer is on its way.
|
||||
let (integrity_problems, integrity_checked, integrity_running) = if deep {
|
||||
core.start_or_read_integrity_check()
|
||||
} else {
|
||||
(Vec::new(), false)
|
||||
(Vec::new(), false, false)
|
||||
};
|
||||
CtlResponse::CacheReport {
|
||||
integrity_running,
|
||||
schema_version: stats.schema_version,
|
||||
db_bytes: stats.total_bytes(),
|
||||
db_reclaimable_bytes: stats.reclaimable_bytes(),
|
||||
@@ -565,17 +594,16 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
}
|
||||
Err(e) => CtlResponse::error(CoreError::internal(format!("read db stats: {e:?}"))),
|
||||
},
|
||||
Ok(CtlRequest::CacheVacuum) => match core.db.vacuum() {
|
||||
Ok(outcome) => CtlResponse::Ok {
|
||||
message: format!(
|
||||
"vacuumed: {:.1} MiB freed ({:.1} MiB → {:.1} MiB), {} WAL frame(s) checkpointed",
|
||||
outcome.freed_bytes() as f64 / 1_048_576.0,
|
||||
outcome.before_bytes as f64 / 1_048_576.0,
|
||||
outcome.after_bytes as f64 / 1_048_576.0,
|
||||
outcome.wal_frames_checkpointed,
|
||||
),
|
||||
// Ack and vacuum on a thread. `VACUUM` rewrites the whole database
|
||||
// under the connection mutex — minutes on a large one — and the
|
||||
// request's own socket timeout is 10 s, so running it inline both froze
|
||||
// the mount and lost its own answer. Progress shows up as a job in
|
||||
// `GetQueueStatus`; the outcome goes to the log.
|
||||
Ok(CtlRequest::CacheVacuum) => match core.start_vacuum() {
|
||||
Ok(()) => CtlResponse::Ok {
|
||||
message: "compacting the cache database in the background".into(),
|
||||
},
|
||||
Err(e) => CtlResponse::error(CoreError::internal(format!("vacuum: {e:?}"))),
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::GetQueueStatus) => CtlResponse::Transfers {
|
||||
items: core.transfers.snapshot(),
|
||||
@@ -1023,7 +1051,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListActivity { limit }) => CtlResponse::Activity {
|
||||
items: core.list_activity(limit),
|
||||
items: core.list_activity(clamp_limit(limit)),
|
||||
},
|
||||
// The request did not parse: the caller sent something malformed.
|
||||
Err(e) => CtlResponse::error(CoreError::invalid(format!("bad request: {e}"))),
|
||||
|
||||
@@ -536,7 +536,14 @@ impl Core {
|
||||
// folder the daemon no longer tracks, and nothing would ever unmount it.
|
||||
// Tear it down first — including before trashing the remote tree it
|
||||
// serves, which would otherwise leave it answering for deleted nodes.
|
||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
||||
// Taken in its own statement so the registry lock is released before the
|
||||
// teardown: an edition-2024 `if let` chain holds the guard for the whole
|
||||
// body, and `teardown` unmounts and joins the session thread — which can
|
||||
// be inside a multi-second transfer. The shutdown path takes the same
|
||||
// lock, so holding it here risks systemd's stop timeout landing mid-
|
||||
// unmount and stranding the endpoint.
|
||||
let taken = self.mounts.lock().remove(&id);
|
||||
if let Some(mount) = taken
|
||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||
{
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
@@ -584,19 +591,8 @@ impl Core {
|
||||
pub(crate) fn fork_state(&self) -> Core {
|
||||
let mut fork = self.clone();
|
||||
fork.primary = false;
|
||||
let share_access = self.state.lock().share_access.clone();
|
||||
fork.state = Arc::new(Mutex::new(State {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
next_ino: 2,
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
access_changes: std::collections::HashSet::new(),
|
||||
share_access,
|
||||
db: self.db.clone(),
|
||||
}));
|
||||
let share_access = self.state().share_access.clone();
|
||||
fork.state = Arc::new(Mutex::new(State::new(self.db.clone(), share_access, 2)));
|
||||
// A fresh inode space needs a fresh notification channel: this fork's
|
||||
// session is the only one that knows these inodes, so it must be the one
|
||||
// notified about them. Filled in by `spawn_ondemand_mount`.
|
||||
@@ -866,8 +862,11 @@ impl Core {
|
||||
Ok(format!("{} is now on-demand", local.display()))
|
||||
}
|
||||
ApplicableMountMode::Mirror => {
|
||||
// ondemand→mirror: tear down the secondary mount first.
|
||||
if let Some(mount) = self.mounts.lock().remove(&id)
|
||||
// ondemand→mirror: tear down the secondary mount first. Taken
|
||||
// before the teardown so the registry lock is not held across
|
||||
// the unmount and thread join (see `remove_sync_folder`).
|
||||
let taken = self.mounts.lock().remove(&id);
|
||||
if let Some(mount) = taken
|
||||
&& let Err(error) = commit_after_teardown(mount.teardown(), || ())
|
||||
{
|
||||
let _ = self.db.sync_folder_set_state(id, "error", now_secs());
|
||||
|
||||
@@ -136,6 +136,10 @@ impl Core {
|
||||
let due = self.db.next_due_op(now).unwrap_or_default();
|
||||
|
||||
let Some(op) = due.filter(|_| self.online.load(Ordering::Relaxed)) else {
|
||||
// Nothing to upload is exactly when the connection is free, so
|
||||
// this is where the read path's buffered LRU touches get written
|
||||
// (`ContentCache::flush_touches`). Cheap and a no-op when empty.
|
||||
self.cache.flush_touches();
|
||||
self.recover_fsynced_writes();
|
||||
// A debounced or backed-off op may be waiting: sleep only
|
||||
// until it becomes due rather than the full idle-poll.
|
||||
@@ -351,6 +355,9 @@ impl Core {
|
||||
}
|
||||
self.db.complete_trash_op(op.id, &uid)?;
|
||||
self.own_sealed_revs.lock().remove(&uid);
|
||||
if let Err(e) = self.db.clear_own_sealed_rev(&uid.to_string()) {
|
||||
debug!(%uid, error = %e, "clearing the sealed-revision record failed");
|
||||
}
|
||||
self.invalidate_trash();
|
||||
self.log_activity(ActivityKind::Trash, &name, "trashed", true);
|
||||
info!(%uid, name, "pending trash landed");
|
||||
@@ -625,7 +632,16 @@ impl Core {
|
||||
// instead of forking — so the finished bytes win the name rather than
|
||||
// being exiled to a `(sync-conflict)` copy.
|
||||
let remote_rev = node_revision_id(&node);
|
||||
let own_rev = self.own_sealed_revs.lock().get(uid).cloned();
|
||||
// In memory first, then the table it is written through to: the map is
|
||||
// this process's cache of it, and after a restart only the table has
|
||||
// the answer (which is the whole reason it is persisted).
|
||||
let own_rev = match self.own_sealed_revs.lock().get(uid).cloned() {
|
||||
Some(rev) => Some(rev),
|
||||
None => self
|
||||
.db
|
||||
.own_sealed_rev(&uid.to_string(), now_millis())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
if is_own_self_supersede(meta.complete, remote_rev.as_deref(), own_rev.as_deref()) {
|
||||
debug!(%uid, ?remote_rev,
|
||||
"queued write chains onto our own sealed revision; not a conflict");
|
||||
@@ -709,8 +725,9 @@ impl Core {
|
||||
self.db.delete_op(op.id)?;
|
||||
// Dropping the pending entry hands the node back to the remote's truth:
|
||||
// reads stop coming from the staged blob, and the event sync stops
|
||||
// skipping it as "ahead of the server" (offline.md Phase 3a).
|
||||
self.pending.lock().remove(uid);
|
||||
// skipping it as "ahead of the server" (offline.md Phase 3a). Only if it
|
||||
// is still *this* write's entry — see [`Core::release_pending`].
|
||||
self.release_pending(uid, blob);
|
||||
self.cache.discard_staged(blob);
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
@@ -748,7 +765,7 @@ impl Core {
|
||||
error!(%uid, name, reason, staged = %blob.display(),
|
||||
"cannot place a conflicted write; bytes kept in staging");
|
||||
self.db.delete_op(op.id)?;
|
||||
self.pending.lock().remove(uid);
|
||||
self.release_pending(uid, blob);
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
self.log_activity(
|
||||
@@ -760,6 +777,24 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Drop `uid`'s pending entry, but only while it still names `blob`.
|
||||
///
|
||||
/// A supersede can replace the entry while the op that owned it is in
|
||||
/// flight, so an unconditional `remove` retires a *newer* write's staged
|
||||
/// blob: reads fall back to a stale remote, the next `remote_baseline` forks
|
||||
/// a spurious `(sync-conflict)` copy of bytes that were never in conflict,
|
||||
/// and the incomplete-write interlock — which keys off the pending entry —
|
||||
/// is disarmed, letting a gap-fill read from a revision that no longer
|
||||
/// describes the file.
|
||||
pub(crate) fn release_pending(&self, uid: &NodeUid, blob: &Path) {
|
||||
let mut pending = self.pending.lock();
|
||||
if let Some(current) = pending.get(uid)
|
||||
&& current.path == blob
|
||||
{
|
||||
pending.remove(uid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a folder a queued op targets has stopped being a place a node can
|
||||
/// go. A failure to ask is not an answer: only a definite "trashed" or "not
|
||||
/// there" counts, so a network fault leaves the op to retry normally.
|
||||
@@ -826,7 +861,7 @@ impl Core {
|
||||
/// root yet, which is not where the drain runs; the drain must not panic
|
||||
/// over it regardless, since that would stop the queue for good.
|
||||
pub(crate) fn root_uid(&self) -> Option<NodeUid> {
|
||||
let st = self.state.lock();
|
||||
let st = self.state();
|
||||
st.entries.get(&ROOT_INO).map(|e| e.uid.clone())
|
||||
}
|
||||
|
||||
@@ -886,14 +921,7 @@ impl Core {
|
||||
// an orphaned file (harmless), whereas the reverse would leave a queued
|
||||
// op pointing at nothing.
|
||||
self.db.delete_op(op.id)?;
|
||||
{
|
||||
let mut p = self.pending.lock();
|
||||
if let Some(op_in_mem) = p.get(&uid)
|
||||
&& op_in_mem.path == blob
|
||||
{
|
||||
p.remove(&uid);
|
||||
}
|
||||
}
|
||||
self.release_pending(&uid, &blob);
|
||||
|
||||
// The staged blob now matches the sealed revision, so a pinned file keeps
|
||||
// it as its cached content rather than re-downloading what we just sent.
|
||||
@@ -969,7 +997,16 @@ impl Core {
|
||||
// change and does not fork (B70 layer B; consulted in
|
||||
// `revision_conflict`).
|
||||
if let Some(rev) = sealed_rev.clone() {
|
||||
self.own_sealed_revs.lock().insert(uid.clone(), rev);
|
||||
self.own_sealed_revs.lock().insert(uid.clone(), rev.clone());
|
||||
// Write through: losing this at shutdown reopened the fork
|
||||
// window B70 layer B closed, for the first drain after a
|
||||
// restart.
|
||||
if let Err(e) = self
|
||||
.db
|
||||
.set_own_sealed_rev(&uid.to_string(), &rev, now_millis())
|
||||
{
|
||||
warn!(%uid, error = %e, "persisting our sealed revision failed");
|
||||
}
|
||||
}
|
||||
self.for_each_state(|st| {
|
||||
for aw in st.active_writes.values_mut() {
|
||||
|
||||
+360
-227
@@ -3,6 +3,73 @@
|
||||
use super::*;
|
||||
|
||||
impl Filesystem for ProtonFs {
|
||||
/// Negotiate kernel-side behaviour once, before any other callback.
|
||||
///
|
||||
/// Everything here is a hint the kernel is free to decline, so each is
|
||||
/// requested and its refusal logged rather than treated as fatal — an older
|
||||
/// kernel that lacks a capability must still mount.
|
||||
///
|
||||
/// Deliberately *not* requested: `FUSE_ATOMIC_O_TRUNC` and
|
||||
/// `FUSE_DO_READDIRPLUS` change what the kernel sends (truncation folded
|
||||
/// into `open`, `readdirplus` instead of `readdir` + N lookups), so they
|
||||
/// need the matching handlers first; and `FOPEN_KEEP_CACHE` needs the write
|
||||
/// path to invalidate page cache on every locally staged revision, not just
|
||||
/// on remote events.
|
||||
fn init(&mut self, _req: &Request, config: &mut KernelConfig) -> std::io::Result<()> {
|
||||
// Readahead the kernel issues per file. Matching the block size means a
|
||||
// sequential reader's readahead lands inside the block a demand read is
|
||||
// already fetching, instead of one 128 KiB slice at a time.
|
||||
if let Err(nearest) = config.set_max_readahead(BLOCK_SIZE as u32) {
|
||||
debug!(nearest, "kernel capped max_readahead");
|
||||
let _ = config.set_max_readahead(nearest);
|
||||
}
|
||||
if let Err(nearest) = config.set_max_background(MAX_BACKGROUND) {
|
||||
debug!(nearest, "kernel capped max_background");
|
||||
}
|
||||
// Without this the kernel serialises lookups within one directory, so a
|
||||
// cold `ls` of a folder is a chain of round trips rather than a fan-out.
|
||||
if let Err(unsupported) = config.add_capabilities(InitFlags::FUSE_PARALLEL_DIROPS) {
|
||||
debug!(?unsupported, "kernel does not support parallel dirops");
|
||||
}
|
||||
if let Err(unsupported) = config.add_capabilities(InitFlags::FUSE_CACHE_SYMLINKS) {
|
||||
debug!(?unsupported, "kernel does not support symlink caching");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Free and total space, as `df` and every "will this fit?" preflight ask.
|
||||
///
|
||||
/// Reported in [`REPORTED_BLKSIZE`] units against the *account* quota, not
|
||||
/// the local cache: what a copy into the mount consumes is Drive storage.
|
||||
/// The default implementation answers all-zeroes, which reads as a full
|
||||
/// filesystem and makes installers and file managers refuse to write.
|
||||
///
|
||||
/// On a worker, because a cold answer is a round trip
|
||||
/// ([`Core::account_quota_cached`] serves a warm one from memory).
|
||||
fn statfs(&self, _req: &Request, _ino: INodeNo, reply: ReplyStatfs) {
|
||||
let core = self.core.clone();
|
||||
self.core.workers.run(Lane::Meta, move || {
|
||||
let (max_space, used_space) = core.account_quota_cached().unwrap_or((0, 0));
|
||||
let bsize = u64::from(REPORTED_BLKSIZE);
|
||||
let blocks = (max_space.max(0) as u64).div_ceil(bsize);
|
||||
let used = (used_space.max(0) as u64).div_ceil(bsize);
|
||||
let free = blocks.saturating_sub(used);
|
||||
// `files`/`ffree` are unknowable — Drive has no inode budget — so
|
||||
// they are reported as "plenty" rather than as zero, which some
|
||||
// tools read as "no more files may be created".
|
||||
reply.statfs(
|
||||
blocks,
|
||||
free,
|
||||
free,
|
||||
0,
|
||||
u64::MAX,
|
||||
REPORTED_BLKSIZE,
|
||||
MAX_NAME_LEN,
|
||||
REPORTED_BLKSIZE,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
fn lookup(&self, _req: &Request, parent: INodeNo, name: &OsStr, reply: ReplyEntry) {
|
||||
let parent = parent.0;
|
||||
let name = match fuse_name(name) {
|
||||
@@ -27,13 +94,13 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
|
||||
fn forget(&self, _req: &Request, ino: INodeNo, nlookup: u64) {
|
||||
let mut st = self.core.state.lock();
|
||||
let mut st = self.core.state();
|
||||
st.forget_lookup(ino.0, nlookup);
|
||||
}
|
||||
|
||||
fn getattr(&self, _req: &Request, ino: INodeNo, _fh: Option<FileHandle>, reply: ReplyAttr) {
|
||||
let (attr, provisional_in) = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
match st.entries.get(&ino.0) {
|
||||
Some(e) => {
|
||||
// A file still carrying the cheap enumeration's placeholder
|
||||
@@ -69,23 +136,30 @@ impl Filesystem for ProtonFs {
|
||||
// single upgrade. This is why B12's split is still worth having — the
|
||||
// plain listing never comes here at all.
|
||||
//
|
||||
// Off the dispatch loop, because it goes to the network: blocking there
|
||||
// would stall every other operation on the mount (the B5 lesson).
|
||||
// Starting the batch is local work — a `state` read and a thread spawn —
|
||||
// so it happens here; what goes to the network is the batch itself, and
|
||||
// this reply is parked on it rather than waiting on any thread of the
|
||||
// mount's (audit F4).
|
||||
let ino = ino.0;
|
||||
let fs = self.clone();
|
||||
self.core.workers.run(Lane::Meta, move || {
|
||||
fs.core.upgrade_sizes_for_parent(ino.0, &uid, parent);
|
||||
let respond = move || {
|
||||
// Re-read: the upgrade writes through `state`, and on timeout or
|
||||
// failure this is simply the provisional attr we already had.
|
||||
let attr = {
|
||||
let st = fs.core.state.lock();
|
||||
match st.entries.get(&ino.0) {
|
||||
Some(e) => fs.attr(ino.0, &e.node, e.access),
|
||||
let st = fs.core.state();
|
||||
match st.entries.get(&ino) {
|
||||
Some(e) => fs.attr(ino, &e.node, e.access),
|
||||
// Forgotten while we waited.
|
||||
None => attr,
|
||||
}
|
||||
};
|
||||
reply.attr(&TTL, &attr);
|
||||
});
|
||||
};
|
||||
match self.core.upgrade_sizes_for_parent(ino, &uid, parent) {
|
||||
Some(slot) => self.core.await_size(ino, slot, respond),
|
||||
// Nothing to upgrade, so nothing to wait for.
|
||||
None => respond(),
|
||||
}
|
||||
}
|
||||
|
||||
fn readdir(
|
||||
@@ -109,109 +183,19 @@ impl Filesystem for ProtonFs {
|
||||
.run(Lane::Meta, move || fs.serve_readdir(ino, offset, reply));
|
||||
}
|
||||
|
||||
/// A read-only open is a map lookup and answers inline; a write open has to
|
||||
/// allocate a scratch file and, when a queued revision is the base, duplicate
|
||||
/// a blob that is the whole file — so it is served from a worker rather than
|
||||
/// stalling every other request on the mount behind it.
|
||||
fn open(&self, _req: &Request, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
||||
let (uid, base_mtime, base_size, base_revision_id) = {
|
||||
let mut st = self.core.state.lock();
|
||||
match st.entries.get_mut(&ino.0) {
|
||||
Some(e) if e.node.is_file() => {
|
||||
if write_requested && !e.writable() {
|
||||
reply.error(Errno::EACCES);
|
||||
return;
|
||||
}
|
||||
e.open_count = e.open_count.saturating_add(1);
|
||||
(
|
||||
e.uid.clone(),
|
||||
e.node.modification_time,
|
||||
node_size(&e.node),
|
||||
node_revision_id(&e.node),
|
||||
)
|
||||
}
|
||||
Some(_) => {
|
||||
reply.error(Errno::EISDIR);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
debug!(ino = ino.0, ?flags, base_size, base_mtime, "open");
|
||||
// Read-only opens stay stateless (fh 0). A write open allocates a
|
||||
// disk-backed handle; bytes are authored into a scratch file and the
|
||||
// untouched remainder is pulled from the base lazily (on read / commit).
|
||||
if flags.acc_mode() == OpenAccMode::O_RDONLY {
|
||||
reply.opened(FileHandle(0), FopenFlags::empty());
|
||||
self.serve_open(ino, flags, reply);
|
||||
return;
|
||||
}
|
||||
// A previous close may already have newer bytes queued locally while
|
||||
// the server still holds the older revision. That staged blob, not the
|
||||
// remote, is the base for this handle. In particular, `truncate file`
|
||||
// opens for writing and then shrinks via setattr; starting its scratch
|
||||
// as a sparse zero file would turn the preserved prefix into zeros.
|
||||
//
|
||||
// Complete pending blobs are the common case and can be copied without
|
||||
// any network access. An incomplete blob still has gaps referring to
|
||||
// the remote base; stacking another write on it cannot be represented
|
||||
// safely by WriteHandle, so fail the open rather than risk corruption.
|
||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||
if let Some(entry) = self.core.state.lock().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
error!(%uid, "refusing write over incomplete queued revision");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
let (file, path) = match self.core.cache.create_scratch() {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
if let Some(entry) = self.core.state.lock().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
error!(%uid, error = %e, "create scratch file failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut initial_written = Intervals::default();
|
||||
if let Some(pending) = &pending_base {
|
||||
if let Err(e) = std::fs::copy(&pending.path, &path) {
|
||||
if let Some(entry) = self.core.state.lock().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
error!(%uid, source = %pending.path.display(), error = %e,
|
||||
"copy queued revision into write scratch failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
initial_written.add(0, pending.meta.len);
|
||||
}
|
||||
let mut st = self.core.state.lock();
|
||||
let fh = st.next_fh;
|
||||
st.next_fh += 1;
|
||||
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
||||
WriteHandle {
|
||||
ino: ino.0,
|
||||
uid,
|
||||
file: Arc::new(file),
|
||||
path,
|
||||
written: initial_written,
|
||||
// Starts at the current size; reads in [0, base_size) come from
|
||||
// the base until overwritten.
|
||||
len: base_size,
|
||||
base_size,
|
||||
base_mtime,
|
||||
base_revision_id,
|
||||
dirty: false,
|
||||
open_count: 0,
|
||||
}
|
||||
});
|
||||
aw.open_count += 1;
|
||||
st.handles.insert(fh, ino.0);
|
||||
reply.opened(FileHandle(fh), FopenFlags::empty());
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Transfer, move || fs.serve_open(ino, flags, reply));
|
||||
}
|
||||
|
||||
fn read(
|
||||
@@ -234,7 +218,7 @@ impl Filesystem for ProtonFs {
|
||||
// has always had between a write and a read of the same handle; a mostly
|
||||
// local read is not worth reasoning about that for.
|
||||
let handle = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
st.active_writes.get(&ino.0).map(|h| {
|
||||
(
|
||||
h.file.clone(),
|
||||
@@ -263,7 +247,7 @@ impl Filesystem for ProtonFs {
|
||||
return;
|
||||
}
|
||||
let (uid, mtime, fsize, is_video) = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
match st.entries.get(&ino.0) {
|
||||
Some(e) if e.node.is_file() => {
|
||||
let media_type = match &e.node.kind {
|
||||
@@ -363,7 +347,7 @@ impl Filesystem for ProtonFs {
|
||||
// Stage the bytes straight into the scratch file (no base download): only
|
||||
// the untouched remainder is pulled from the remote, and only at commit.
|
||||
let file = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
match st.handles.get(&fh).and_then(|&i| st.active_writes.get(&i)) {
|
||||
Some(aw) => aw.file.clone(),
|
||||
None => {
|
||||
@@ -374,11 +358,20 @@ impl Filesystem for ProtonFs {
|
||||
};
|
||||
if let Err(e) = file.write_all_at(data, offset) {
|
||||
error!(ino = ino.0, fh, error = %e, "scratch write failed");
|
||||
reply.error(Errno::EIO);
|
||||
// A full local disk is not a broken file: answer `ENOSPC` so the
|
||||
// application can say so (and so `cp`/editors take their own
|
||||
// out-of-space path), and free what the cache is holding — the
|
||||
// scratch file the next write goes to shares that filesystem.
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
self.core.cache.emergency_evict();
|
||||
reply.error(Errno::ENOSPC);
|
||||
} else {
|
||||
reply.error(Errno::EIO);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let new_len = {
|
||||
let mut st = self.core.state.lock();
|
||||
let mut st = self.core.state();
|
||||
let Some(aw) = st
|
||||
.handles
|
||||
.get(&fh)
|
||||
@@ -435,7 +428,7 @@ impl Filesystem for ProtonFs {
|
||||
if let Some(size) = size {
|
||||
let fh = fh.map(|f| f.0).filter(|&f| f != 0);
|
||||
let handled = {
|
||||
let mut st = self.core.state.lock();
|
||||
let mut st = self.core.state();
|
||||
match st.active_writes.get_mut(&ino.0) {
|
||||
Some(aw) => {
|
||||
if size < aw.len {
|
||||
@@ -478,7 +471,7 @@ impl Filesystem for ProtonFs {
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.core.state.lock().set_size(ino.0, size);
|
||||
self.core.state().set_size(ino.0, size);
|
||||
}
|
||||
self.reply_attr(ino.0, reply);
|
||||
}
|
||||
@@ -503,7 +496,7 @@ impl Filesystem for ProtonFs {
|
||||
let is_punch_hole = (mode & 0x02) != 0; // FALLOC_FL_PUNCH_HOLE
|
||||
|
||||
let res = {
|
||||
let mut st = self.core.state.lock();
|
||||
let mut st = self.core.state();
|
||||
match st
|
||||
.handles
|
||||
.get(&_fh.0)
|
||||
@@ -566,6 +559,12 @@ impl Filesystem for ProtonFs {
|
||||
/// for fsync wants its bytes to survive a crash, and blocking it on an upload
|
||||
/// (which offline would never finish) buys nothing the queue does not already
|
||||
/// guarantee.
|
||||
///
|
||||
/// Served on a worker: `sync_all` is a disk barrier and the sidecar write is
|
||||
/// two more file operations, and an application that fsyncs per record would
|
||||
/// otherwise stall every request on the mount at its barrier rate. Nothing
|
||||
/// orders this against other callbacks — the kernel waits for the reply
|
||||
/// either way, which is the whole contract.
|
||||
fn fsync(
|
||||
&self,
|
||||
_req: &Request,
|
||||
@@ -574,76 +573,10 @@ impl Filesystem for ProtonFs {
|
||||
_datasync: bool,
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
// Everything the durability sidecar needs, copied out so no lock is held
|
||||
// across the sync.
|
||||
let handle = {
|
||||
let st = self.core.state.lock();
|
||||
let ino = st.handles.get(&fh.0).copied();
|
||||
ino.and_then(|i| st.active_writes.get(&i)).map(|aw| {
|
||||
(
|
||||
aw.file.clone(),
|
||||
aw.path.clone(),
|
||||
aw.uid.clone(),
|
||||
aw.dirty,
|
||||
aw.written.clone(),
|
||||
aw.len,
|
||||
aw.base_size,
|
||||
aw.base_mtime,
|
||||
aw.base_revision_id.clone(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
|
||||
handle
|
||||
else {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
};
|
||||
if let Err(e) = f.sync_all() {
|
||||
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
// A clean handle has nothing on it that the remote does not already
|
||||
// hold, so there is nothing for a crash to lose.
|
||||
if !dirty {
|
||||
reply.ok();
|
||||
return;
|
||||
}
|
||||
// The bytes are on stable storage, but only the scratch file knows that,
|
||||
// and the scratch directory is cleared at open. Record what the blob is
|
||||
// so a restart can hand it to the upload queue instead of deleting it.
|
||||
//
|
||||
// Deliberately *not* the full release path: staging the blob here would
|
||||
// move the file out from under a handle the application still has open,
|
||||
// and queueing an upload per `fsync` would push a revision for every
|
||||
// barrier in a write loop. This only makes the existing file findable.
|
||||
let authored: Vec<(u64, u64)> = written
|
||||
.segments(0, len)
|
||||
.into_iter()
|
||||
.filter(|&(_, _, authored)| authored)
|
||||
.map(|(s, e, _)| (s, e))
|
||||
.collect();
|
||||
let meta = StagedWrite {
|
||||
uid: uid.to_string(),
|
||||
len,
|
||||
base_size,
|
||||
base_mtime,
|
||||
complete: authored == [(0, len)],
|
||||
authored,
|
||||
based_on: self
|
||||
.core
|
||||
.remote_baseline(&uid, base_mtime, base_size, base_revision_id),
|
||||
};
|
||||
// A failed sidecar means the write is not durable, and `fsync` promising
|
||||
// otherwise is the defect this exists to fix — so it is an error, not a
|
||||
// warning.
|
||||
if let Err(e) = self.core.cache.mark_scratch_durable(&path, &meta) {
|
||||
error!(%uid, error = %e, "recording fsync durability failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
reply.ok();
|
||||
let fs = self.clone();
|
||||
self.core
|
||||
.workers
|
||||
.run(Lane::Transfer, move || fs.serve_fsync(fh, reply));
|
||||
}
|
||||
|
||||
fn release(
|
||||
@@ -657,7 +590,7 @@ impl Filesystem for ProtonFs {
|
||||
reply: ReplyEmpty,
|
||||
) {
|
||||
let (handle, unlinked_uid) = {
|
||||
let mut st = self.core.state.lock();
|
||||
let mut st = self.core.state();
|
||||
let unlinked_uid = release_unlinked_entry(&mut st, _ino.0);
|
||||
let ino = st.handles.remove(&fh.0);
|
||||
let h = ino.and_then(|i| {
|
||||
@@ -680,10 +613,11 @@ impl Filesystem for ProtonFs {
|
||||
// acceptance suite's concurrency case failed with "concurrent file 1
|
||||
// mismatch" on all three runs the moment this moved.
|
||||
//
|
||||
// The cost is real — `queue_revision` gap-fills a partial write from the
|
||||
// remote — so moving it off still wants doing, but it needs the ordering
|
||||
// made explicit first (a per-node "staging in flight" barrier that reads
|
||||
// wait on) rather than inherited from the dispatch loop.
|
||||
// That inherited ordering is now affordable because the network came out
|
||||
// of this path: `queue_revision` no longer downloads the ranges an edit
|
||||
// did not touch, it stages an incomplete blob and lets the drain thread
|
||||
// finish it (audit F1). What is left is a rename, two fsyncs, a database
|
||||
// insert, and at most a bounded copy out of the local cache.
|
||||
self.finish_release(handle, unlinked_uid, reply);
|
||||
}
|
||||
|
||||
@@ -802,7 +736,7 @@ impl Filesystem for ProtonFs {
|
||||
}
|
||||
|
||||
fn access(&self, _req: &Request, ino: INodeNo, mask: AccessFlags, reply: ReplyEmpty) {
|
||||
let state = self.core.state.lock();
|
||||
let state = self.core.state();
|
||||
let Some(entry) = state.entries.get(&ino.0) else {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
@@ -987,7 +921,7 @@ impl ProtonFs {
|
||||
/// Build the filesystem rooted at `root` (the user's My Files folder).
|
||||
pub(super) fn new(core: Core, root: Node) -> Self {
|
||||
{
|
||||
let mut st = core.state.lock();
|
||||
let mut st = core.state();
|
||||
if let Err(e) = st.db.upsert_node(&root) {
|
||||
warn!(uid = %root.uid, error = %e, "db upsert root failed");
|
||||
}
|
||||
@@ -1029,7 +963,7 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
let hit = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
st.children.get(&parent).and_then(|kids| {
|
||||
kids.iter().copied().find_map(|ino| {
|
||||
st.entries
|
||||
@@ -1074,16 +1008,22 @@ impl ProtonFs {
|
||||
});
|
||||
return;
|
||||
}
|
||||
self.core.upgrade_sizes_for_parent(ino, &uid, grandparent);
|
||||
// Re-read: on timeout or failure this is the provisional attr again,
|
||||
// which is no worse than what this path used to always send.
|
||||
let attr = {
|
||||
let st = self.core.state.lock();
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.map_or(attr, |e| self.attr(ino, &e.node, e.access))
|
||||
let fs = self.clone();
|
||||
let respond = move || {
|
||||
// Re-read: on timeout or failure this is the provisional attr again,
|
||||
// which is no worse than what this path used to always send.
|
||||
let attr = {
|
||||
let st = fs.core.state();
|
||||
st.entries
|
||||
.get(&ino)
|
||||
.map_or(attr, |e| fs.attr(ino, &e.node, e.access))
|
||||
};
|
||||
reply.entry(&TTL, &attr, Generation(0));
|
||||
};
|
||||
reply.entry(&TTL, &attr, Generation(0));
|
||||
match self.core.upgrade_sizes_for_parent(ino, &uid, grandparent) {
|
||||
Some(slot) => self.core.await_size(ino, slot, respond),
|
||||
None => respond(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::readdir`], on whichever thread ends up serving it.
|
||||
@@ -1092,7 +1032,7 @@ impl ProtonFs {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
let parent = st.entries.get(&ino).map_or(ROOT_INO, |e| e.parent);
|
||||
|
||||
// "." and ".." occupy offsets 0 and 1; real children follow.
|
||||
@@ -1146,7 +1086,7 @@ impl ProtonFs {
|
||||
uid: self.uid,
|
||||
gid: self.gid,
|
||||
rdev: 0,
|
||||
blksize: 512,
|
||||
blksize: REPORTED_BLKSIZE,
|
||||
flags: 0,
|
||||
}
|
||||
}
|
||||
@@ -1154,6 +1094,202 @@ impl ProtonFs {
|
||||
/// Trash the child `name` under `parent` on the remote, then drop it from the
|
||||
/// local cache. Backs both `unlink` and `rmdir` (Proton trashes whole
|
||||
/// subtrees, so an `rmdir` of a non-empty dir behaves the same).
|
||||
/// The body of [`Filesystem::open`], run off the dispatch loop for a write
|
||||
/// open.
|
||||
fn serve_open(&self, ino: INodeNo, flags: OpenFlags, reply: ReplyOpen) {
|
||||
let write_requested = flags.acc_mode() != OpenAccMode::O_RDONLY;
|
||||
let (uid, base_mtime, base_size, base_revision_id) = {
|
||||
let mut st = self.core.state();
|
||||
match st.entries.get_mut(&ino.0) {
|
||||
Some(e) if e.node.is_file() => {
|
||||
if write_requested && !e.writable() {
|
||||
reply.error(Errno::EACCES);
|
||||
return;
|
||||
}
|
||||
e.open_count = e.open_count.saturating_add(1);
|
||||
(
|
||||
e.uid.clone(),
|
||||
e.node.modification_time,
|
||||
node_size(&e.node),
|
||||
node_revision_id(&e.node),
|
||||
)
|
||||
}
|
||||
Some(_) => {
|
||||
reply.error(Errno::EISDIR);
|
||||
return;
|
||||
}
|
||||
None => {
|
||||
reply.error(Errno::ENOENT);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
debug!(ino = ino.0, ?flags, base_size, base_mtime, "open");
|
||||
// Read-only opens stay stateless (fh 0). A write open allocates a
|
||||
// disk-backed handle; bytes are authored into a scratch file and the
|
||||
// untouched remainder is pulled from the base lazily (on read / commit).
|
||||
if flags.acc_mode() == OpenAccMode::O_RDONLY {
|
||||
reply.opened(FileHandle(0), FopenFlags::empty());
|
||||
return;
|
||||
}
|
||||
// A previous close may already have newer bytes queued locally while
|
||||
// the server still holds the older revision. That staged blob, not the
|
||||
// remote, is the base for this handle. In particular, `truncate file`
|
||||
// opens for writing and then shrinks via setattr; starting its scratch
|
||||
// as a sparse zero file would turn the preserved prefix into zeros.
|
||||
//
|
||||
// Complete pending blobs are the common case and can be copied without
|
||||
// any network access. An incomplete blob still has gaps referring to
|
||||
// the remote base; stacking another write on it cannot be represented
|
||||
// safely by WriteHandle, so fail the open rather than risk corruption.
|
||||
let pending_base = self.core.pending.lock().get(&uid).cloned();
|
||||
if pending_base.as_ref().is_some_and(|p| !p.meta.complete) {
|
||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
error!(%uid, "refusing write over incomplete queued revision");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
let (file, path) = match self.core.cache.create_scratch() {
|
||||
Ok(x) => x,
|
||||
Err(e) => {
|
||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
error!(%uid, error = %e, "create scratch file failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut initial_written = Intervals::default();
|
||||
if let Some(pending) = &pending_base {
|
||||
// Reflink where the filesystem can: this blob is the whole file, and
|
||||
// a byte-for-byte copy of a multi-GB pending revision is the kind of
|
||||
// work `open` must not do (`ContentCache::clone_or_copy`).
|
||||
if let Err(e) = ContentCache::clone_or_copy(&pending.path, &path) {
|
||||
if let Some(entry) = self.core.state().entries.get_mut(&ino.0) {
|
||||
entry.open_count = entry.open_count.saturating_sub(1);
|
||||
}
|
||||
let _ = std::fs::remove_file(&path);
|
||||
error!(%uid, source = %pending.path.display(), error = %e,
|
||||
"copy queued revision into write scratch failed");
|
||||
reply.error(Errno::EIO);
|
||||
return;
|
||||
}
|
||||
initial_written.add(0, pending.meta.len);
|
||||
}
|
||||
let mut st = self.core.state();
|
||||
let fh = st.next_fh;
|
||||
st.next_fh += 1;
|
||||
let aw = st.active_writes.entry(ino.0).or_insert_with(|| {
|
||||
WriteHandle {
|
||||
ino: ino.0,
|
||||
uid,
|
||||
file: Arc::new(file),
|
||||
path,
|
||||
written: initial_written,
|
||||
// Starts at the current size; reads in [0, base_size) come from
|
||||
// the base until overwritten.
|
||||
len: base_size,
|
||||
base_size,
|
||||
base_mtime,
|
||||
base_revision_id,
|
||||
dirty: false,
|
||||
open_count: 0,
|
||||
}
|
||||
});
|
||||
aw.open_count += 1;
|
||||
st.handles.insert(fh, ino.0);
|
||||
reply.opened(FileHandle(fh), FopenFlags::empty());
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::fsync`], run off the dispatch loop.
|
||||
fn serve_fsync(&self, fh: FileHandle, reply: ReplyEmpty) {
|
||||
// Everything the durability sidecar needs, copied out so no lock is held
|
||||
// across the sync.
|
||||
let handle = {
|
||||
let st = self.core.state();
|
||||
let ino = st.handles.get(&fh.0).copied();
|
||||
ino.and_then(|i| st.active_writes.get(&i)).map(|aw| {
|
||||
(
|
||||
aw.file.clone(),
|
||||
aw.path.clone(),
|
||||
aw.uid.clone(),
|
||||
aw.dirty,
|
||||
aw.written.clone(),
|
||||
aw.len,
|
||||
aw.base_size,
|
||||
aw.base_mtime,
|
||||
aw.base_revision_id.clone(),
|
||||
)
|
||||
})
|
||||
};
|
||||
let Some((f, path, uid, dirty, written, len, base_size, base_mtime, base_revision_id)) =
|
||||
handle
|
||||
else {
|
||||
reply.error(Errno::EBADF);
|
||||
return;
|
||||
};
|
||||
if let Err(e) = f.sync_all() {
|
||||
error!(fh = fh.0, error = %e, "fsync of scratch file failed");
|
||||
// Delayed allocation means a full disk usually surfaces here rather
|
||||
// than at `write(2)`; `ENOSPC` is what an application checks for.
|
||||
if e.raw_os_error() == Some(libc::ENOSPC) {
|
||||
self.core.cache.emergency_evict();
|
||||
reply.error(Errno::ENOSPC);
|
||||
} else {
|
||||
reply.error(Errno::EIO);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// A clean handle has nothing on it that the remote does not already
|
||||
// hold, so there is nothing for a crash to lose.
|
||||
if !dirty {
|
||||
reply.ok();
|
||||
return;
|
||||
}
|
||||
// The bytes are on stable storage, but only the scratch file knows that,
|
||||
// and the scratch directory is cleared at open. Record what the blob is
|
||||
// so a restart can hand it to the upload queue instead of deleting it.
|
||||
//
|
||||
// Deliberately *not* the full release path: staging the blob here would
|
||||
// move the file out from under a handle the application still has open,
|
||||
// and queueing an upload per `fsync` would push a revision for every
|
||||
// barrier in a write loop. This only makes the existing file findable.
|
||||
let authored: Vec<(u64, u64)> = written
|
||||
.segments(0, len)
|
||||
.into_iter()
|
||||
.filter(|&(_, _, authored)| authored)
|
||||
.map(|(s, e, _)| (s, e))
|
||||
.collect();
|
||||
let meta = StagedWrite {
|
||||
uid: uid.to_string(),
|
||||
len,
|
||||
base_size,
|
||||
base_mtime,
|
||||
complete: authored == [(0, len)],
|
||||
authored,
|
||||
based_on: self
|
||||
.core
|
||||
.remote_baseline(&uid, base_mtime, base_size, base_revision_id),
|
||||
};
|
||||
// A failed sidecar means the write is not durable, and `fsync` promising
|
||||
// otherwise is the defect this exists to fix — so it is an error, not a
|
||||
// warning.
|
||||
if let Err(e) = self.core.cache.mark_scratch_durable(&path, &meta) {
|
||||
error!(%uid, error = %e, "recording fsync durability failed");
|
||||
if e.is_disk_full() {
|
||||
self.core.cache.emergency_evict();
|
||||
reply.error(Errno::ENOSPC);
|
||||
} else {
|
||||
reply.error(Errno::EIO);
|
||||
}
|
||||
return;
|
||||
}
|
||||
reply.ok();
|
||||
}
|
||||
|
||||
/// The body of [`Filesystem::create`], run off the dispatch loop.
|
||||
fn serve_create(&self, parent: u64, name: &str, reply: ReplyCreate) {
|
||||
if let Err(error) = self.core.require_writable(parent) {
|
||||
@@ -1165,7 +1301,7 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
let parent_uid = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
match st.entries.get(&parent) {
|
||||
Some(e) => e.uid.clone(),
|
||||
None => {
|
||||
@@ -1247,7 +1383,7 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut st = self.core.state.lock();
|
||||
let mut st = self.core.state();
|
||||
let ino = st.intern(parent, node);
|
||||
if let Some(entry) = st.entries.get_mut(&ino) {
|
||||
entry.open_count = entry.open_count.saturating_add(1);
|
||||
@@ -1302,7 +1438,7 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
let parent_uid = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
match st.entries.get(&parent) {
|
||||
Some(e) => e.uid.clone(),
|
||||
None => {
|
||||
@@ -1352,7 +1488,7 @@ impl ProtonFs {
|
||||
}
|
||||
}
|
||||
};
|
||||
let mut st = self.core.state.lock();
|
||||
let mut st = self.core.state();
|
||||
let local = is_local_uid(&node.uid);
|
||||
let uid = node.uid.clone();
|
||||
let ino = st.intern(parent, node);
|
||||
@@ -1384,7 +1520,7 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
if let Some(entry) = st.entries.get(&ino)
|
||||
&& entry.node.is_folder()
|
||||
{
|
||||
@@ -1403,7 +1539,7 @@ impl ProtonFs {
|
||||
}
|
||||
if let Ok((ino, _)) = self.core.lookup_child(parent, name_str) {
|
||||
{
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
if st
|
||||
.entries
|
||||
.get(&ino)
|
||||
@@ -1420,7 +1556,7 @@ impl ProtonFs {
|
||||
reply.error(e);
|
||||
return;
|
||||
}
|
||||
if self.core.state.lock().has_children(ino) {
|
||||
if self.core.state().has_children(ino) {
|
||||
reply.error(Errno::ENOTEMPTY);
|
||||
return;
|
||||
}
|
||||
@@ -1501,7 +1637,7 @@ impl ProtonFs {
|
||||
};
|
||||
// Ancestor cycle check: moving a directory into itself or one of its descendants is forbidden by POSIX (EINVAL).
|
||||
{
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
if let Some(entry) = st.entries.get(&ino)
|
||||
&& entry.node.is_folder()
|
||||
&& st.is_ancestor_of(ino, newparent)
|
||||
@@ -1520,7 +1656,7 @@ impl ProtonFs {
|
||||
return;
|
||||
}
|
||||
let (old_parent_uid, new_parent_uid) = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
match (st.entries.get(&parent), st.entries.get(&newparent)) {
|
||||
(Some(old), Some(new)) => (old.uid.clone(), new.uid.clone()),
|
||||
_ => {
|
||||
@@ -1561,7 +1697,7 @@ impl ProtonFs {
|
||||
// check has to happen before anything is trashed: getting it wrong
|
||||
// turns a refusal into the destruction of the destination.
|
||||
let (src_dir, dst_dir) = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
let is_dir = |i: &u64| {
|
||||
st.entries
|
||||
.get(i)
|
||||
@@ -1624,12 +1760,9 @@ impl ProtonFs {
|
||||
newname,
|
||||
) {
|
||||
Ok(true) => {
|
||||
self.core.state.lock().rename_in_place(
|
||||
ino,
|
||||
newparent,
|
||||
&new_parent_uid,
|
||||
newname,
|
||||
);
|
||||
self.core
|
||||
.state()
|
||||
.rename_in_place(ino, newparent, &new_parent_uid, newname);
|
||||
// Finalize: a transient scratch file (its create parked, bytes
|
||||
// held back) renamed to a finished name is the moment the
|
||||
// completed file is meant to reach Drive. Un-park the create so
|
||||
@@ -1770,7 +1903,7 @@ impl ProtonFs {
|
||||
if let Err(e) = moved {
|
||||
error!(%uid, attempts, error = %e, "move after rename failed");
|
||||
// The rename half landed in the source directory.
|
||||
let mut state = self.core.state.lock();
|
||||
let mut state = self.core.state();
|
||||
let old_parent_uid = state
|
||||
.entries
|
||||
.get(&parent)
|
||||
@@ -1796,7 +1929,7 @@ impl ProtonFs {
|
||||
/// Answer with an inode's current attributes, or `ENOENT` if it went away
|
||||
/// while the caller was doing something slower.
|
||||
fn reply_attr(&self, ino: u64, reply: ReplyAttr) {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
match st.entries.get(&ino) {
|
||||
Some(e) => {
|
||||
let attr = self.attr(ino, &e.node, e.access);
|
||||
@@ -1833,7 +1966,7 @@ impl ProtonFs {
|
||||
reply.error(error);
|
||||
return;
|
||||
}
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
self.core.state().forget_or_unlink(&uid);
|
||||
debug!(%uid, name, "deleted a node that had not been created remotely yet");
|
||||
reply.ok();
|
||||
return;
|
||||
@@ -1867,7 +2000,7 @@ impl ProtonFs {
|
||||
// Only remove the namespace entry after Drive accepted the mutation.
|
||||
// Otherwise an EIO response still made the path disappear locally.
|
||||
self.core.hidden.lock().insert(uid.clone());
|
||||
self.core.state.lock().forget_or_unlink(&uid);
|
||||
self.core.state().forget_or_unlink(&uid);
|
||||
if !open_now {
|
||||
if let Err(error) = self.core.discard_queued_ops(&uid) {
|
||||
warn!(%uid, ?error, "remote trash landed but queued-op cleanup failed");
|
||||
@@ -1931,7 +2064,7 @@ impl ProtonFs {
|
||||
/// request for an unadvertised name, so nothing becomes unreachable.
|
||||
fn serve_listxattr(&self, ino: INodeNo, size: u32, reply: ReplyXattr) {
|
||||
let thumbnailable = {
|
||||
let st = self.core.state.lock();
|
||||
let st = self.core.state();
|
||||
match st.entries.get(&ino.0) {
|
||||
Some(e) => match &e.node.kind {
|
||||
NodeKind::File { media_type, .. } => {
|
||||
|
||||
+1209
-243
File diff suppressed because it is too large.
Load diff
@@ -255,6 +255,14 @@ pub(super) fn spawn_session(
|
||||
root: Node,
|
||||
) -> std::io::Result<BackgroundSession> {
|
||||
let mut config = Config::default();
|
||||
// `n_threads` stays at fuser's default of 1 (with `clone_fd` off) on
|
||||
// purpose, for now. A concurrent dispatch loop is worth having, but
|
||||
// `release` currently relies on being serialised against the `open`+`read`
|
||||
// an application issues right after `close(2)` — see the comment on
|
||||
// `Filesystem::release`. Raising it before that ordering is made explicit
|
||||
// (a per-node staging-in-flight barrier) reintroduces the acceptance
|
||||
// suite's "concurrent file 1 mismatch". Kernel-side caps that do *not*
|
||||
// depend on that ordering are negotiated in `ProtonFs::init`.
|
||||
config.mount_options = vec![
|
||||
MountOption::FSName("protondrive".to_string()),
|
||||
MountOption::Subtype("protondrive".to_string()),
|
||||
@@ -349,23 +357,16 @@ pub fn mount(
|
||||
let core = Core {
|
||||
client: client.clone(),
|
||||
rt: rt.clone(),
|
||||
maintenance: Arc::new(Mutex::new(Default::default())),
|
||||
primary_root_uid: root.uid.clone(),
|
||||
primary: true,
|
||||
state: Arc::new(Mutex::new(State {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
next_ino: 2,
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
access_changes: HashSet::new(),
|
||||
share_access,
|
||||
db: db.clone(),
|
||||
})),
|
||||
state: Arc::new(Mutex::new(State::new(db.clone(), share_access, 2))),
|
||||
cache: Arc::new(cache),
|
||||
readers: Arc::new(Mutex::new(HashMap::new())),
|
||||
stream_ring: Arc::new(Mutex::new(StreamRing::default())),
|
||||
block_ring: Arc::new(Mutex::new(BlockRing::default())),
|
||||
block_flight: Arc::new(BlockFlight::default()),
|
||||
prefetch: Arc::new(Prefetch::default()),
|
||||
prefetch_budget: Arc::new(tokio::sync::Semaphore::new(PREFETCH_BUDGET)),
|
||||
workers: Arc::new(Workers::new(FUSE_WORKERS)?),
|
||||
db,
|
||||
shared_publication: Arc::new(Mutex::new(())),
|
||||
@@ -385,7 +386,9 @@ pub fn mount(
|
||||
self_changes: Arc::new(Mutex::new(HashMap::new())),
|
||||
thumb_gen: Arc::new(Mutex::new(HashSet::new())),
|
||||
no_thumbnail: Arc::new(Mutex::new(HashMap::new())),
|
||||
quota: Arc::new(Mutex::new(None)),
|
||||
size_upgrades: Arc::new(Mutex::new(HashMap::new())),
|
||||
size_waiters: Arc::new(Default::default()),
|
||||
notifier: Arc::new(OnceLock::new()),
|
||||
session_live: Arc::new(AtomicBool::new(false)),
|
||||
transfers: TransferRegistry::new(),
|
||||
@@ -407,6 +410,9 @@ pub fn mount(
|
||||
// aside at open. After `hydrate_pending`, so a recovered partial write can
|
||||
// see an already-queued write to the same node.
|
||||
core.recover_fsynced_writes();
|
||||
// And finally the staged blobs neither of those accounts for: bytes a
|
||||
// failed release left behind with nothing pointing at them.
|
||||
core.reconcile_staging();
|
||||
{
|
||||
let core = core.clone();
|
||||
std::thread::Builder::new()
|
||||
|
||||
@@ -149,10 +149,10 @@ impl Core {
|
||||
.pin_list()
|
||||
.map_err(|e| CoreError::internal(format!("db: {e:?}")))?
|
||||
.into_iter()
|
||||
.map(|(uid, path, recursive)| ProfilePin {
|
||||
uid,
|
||||
path,
|
||||
recursive,
|
||||
.map(|row| ProfilePin {
|
||||
uid: row.uid,
|
||||
path: row.path,
|
||||
recursive: row.recursive,
|
||||
})
|
||||
.collect();
|
||||
let cfg = pdfs_core::config::AppDirs::new()
|
||||
|
||||
+540
-85
@@ -10,18 +10,30 @@ use super::*;
|
||||
/// usual.
|
||||
pub(super) const STREAM_BYPASS_MIN: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// How many bytes of streamed blocks the in-memory ring keeps. Bypassing the
|
||||
/// on-disk cache must not mean re-fetching: the kernel asks for a streamed file
|
||||
/// in reads far smaller than [`BLOCK_SIZE`], so without a ring every 128 KiB the
|
||||
/// player consumes would download and decrypt a whole 4 MiB block again. Sized
|
||||
/// for a handful of blocks per concurrently-streamed file.
|
||||
const STREAM_RING_BYTES: u64 = 128 * 1024 * 1024;
|
||||
/// How many bytes of decrypted blocks the in-memory ring keeps.
|
||||
///
|
||||
/// The kernel asks for a file in reads far smaller than [`BLOCK_SIZE`], so
|
||||
/// without a ring every 128 KiB it consumes costs a whole 4 MiB block again —
|
||||
/// downloaded and decrypted for a streaming file, or re-read off disk and
|
||||
/// re-allocated for a cached one (audit T3: 128 MiB of I/O to deliver 4 MiB).
|
||||
/// Sized for a handful of blocks per concurrently-read file. Bounded and only
|
||||
/// ever filled by active reads.
|
||||
const RING_BYTES: u64 = 128 * 1024 * 1024;
|
||||
|
||||
/// Blocks fetched *past* the one a sequential streaming read needed, warmed into
|
||||
/// the ring in the background so the player's next read is already in memory
|
||||
/// instead of waiting a round-trip. Kept small so it cannot crowd out the
|
||||
/// in-flight block governor.
|
||||
const STREAM_READAHEAD: u64 = 4;
|
||||
/// Deepest read-ahead window, in blocks, for a read that has proved sequential.
|
||||
const PREFETCH_MAX: u64 = 8;
|
||||
|
||||
/// Window a sequential read starts at, and returns to after a seek.
|
||||
const PREFETCH_MIN: u64 = 2;
|
||||
|
||||
/// Blocks that may be prefetching at once, across every file. Prefetch takes a
|
||||
/// permit and gives up rather than queueing, so it can never sit in front of a
|
||||
/// demand read in the SDK's flat block semaphore.
|
||||
pub(super) const PREFETCH_BUDGET: usize = 8;
|
||||
|
||||
/// Cap on tracked sequential streams. Only reads in progress matter, so the map
|
||||
/// is cleared wholesale rather than aged — losing the ramp costs one read.
|
||||
const MAX_PREFETCH_STREAMS: usize = 256;
|
||||
|
||||
/// How many [`RevisionReader`]s stay open at once.
|
||||
///
|
||||
@@ -76,35 +88,39 @@ pub(super) struct PendingOpen {
|
||||
/// does; ids are never compared for order.
|
||||
static NEXT_OPEN_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// Drive client, a Tokio handle to bridge the synchronous FUSE/socket threads
|
||||
/// to the async SDK, the inode bookkeeping, and the on-disk content cache.
|
||||
///
|
||||
/// Cheaply cloneable (every field is a handle/`Arc`), so the control-socket task
|
||||
/// gets its own copy while the FUSE session keeps another.
|
||||
/// In-memory LRU of blocks belonging to a file streaming past the on-disk cache
|
||||
/// (see [`STREAM_BYPASS_MIN`]). Bypassing the disk stops one film evicting the
|
||||
/// block LRU; it must not also mean the same 4 MiB block is downloaded once per
|
||||
/// 128 KiB the player reads out of it.
|
||||
/// In-memory LRU of decrypted 4 MiB blocks, in front of both block paths: the
|
||||
/// on-disk block cache, and the disk-bypassing stream of a large unpinned video
|
||||
/// (see [`STREAM_BYPASS_MIN`], which stops one film evicting the whole disk
|
||||
/// LRU). Either way the point is the same — the kernel reads a block out in ~32
|
||||
/// pieces, and none of them should cost the block again.
|
||||
///
|
||||
/// Validated by the same `(mtime, size)` pair the on-disk caches use: a node
|
||||
/// whose tag no longer matches has its blocks dropped rather than served, so a
|
||||
/// new revision can never be stitched together from the old one.
|
||||
#[derive(Default)]
|
||||
pub(super) struct StreamRing {
|
||||
pub(super) struct BlockRing {
|
||||
blocks: HashMap<(NodeUid, u64), Arc<Vec<u8>>>,
|
||||
/// Keys oldest-first, for eviction.
|
||||
/// Keys least-recently-used first, for eviction.
|
||||
order: VecDeque<(NodeUid, u64)>,
|
||||
/// Per-node validity tag, `(mtime, size)`.
|
||||
tags: HashMap<NodeUid, (i64, u64)>,
|
||||
bytes: u64,
|
||||
}
|
||||
|
||||
impl StreamRing {
|
||||
impl BlockRing {
|
||||
fn get(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64) -> Option<Arc<Vec<u8>>> {
|
||||
if self.tags.get(uid) != Some(&(mtime, size)) {
|
||||
return None;
|
||||
}
|
||||
self.blocks.get(&(uid.clone(), idx)).cloned()
|
||||
let key = (uid.clone(), idx);
|
||||
let hit = self.blocks.get(&key).cloned()?;
|
||||
// Move to the young end: a block being read repeatedly (the whole reason
|
||||
// this exists) must not age out under a sequential read passing through.
|
||||
if let Some(at) = self.order.iter().position(|k| *k == key) {
|
||||
self.order.remove(at);
|
||||
self.order.push_back(key);
|
||||
}
|
||||
Some(hit)
|
||||
}
|
||||
|
||||
fn insert(&mut self, uid: &NodeUid, mtime: i64, size: u64, idx: u64, bytes: Arc<Vec<u8>>) {
|
||||
@@ -121,13 +137,18 @@ impl StreamRing {
|
||||
self.bytes += bytes.len() as u64;
|
||||
self.blocks.insert(key.clone(), bytes);
|
||||
self.order.push_back(key);
|
||||
while self.bytes > STREAM_RING_BYTES {
|
||||
while self.bytes > RING_BYTES {
|
||||
let Some(victim) = self.order.pop_front() else {
|
||||
break;
|
||||
};
|
||||
if let Some(dropped) = self.blocks.remove(&victim) {
|
||||
self.bytes -= dropped.len() as u64;
|
||||
}
|
||||
// A node whose last block just aged out keeps no tag: otherwise the
|
||||
// tag map is the one thing here that grows with every file ever read.
|
||||
if !self.blocks.keys().any(|(u, _)| *u == victim.0) {
|
||||
self.tags.remove(&victim.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,6 +168,87 @@ impl StreamRing {
|
||||
}
|
||||
}
|
||||
|
||||
/// The result of fetching one block, shared between everyone who wanted it.
|
||||
type BlockResult = Result<Arc<Vec<u8>>, Errno>;
|
||||
|
||||
/// Identifies a block *of a revision*: node, validity tag, index.
|
||||
type BlockKey = (NodeUid, i64, u64, u64);
|
||||
|
||||
/// A claim on a block key: who owns the fetch, and where its result is published.
|
||||
type Claim = (u64, tokio::sync::watch::Receiver<Option<BlockResult>>);
|
||||
|
||||
/// Block fetches in flight, so a block is downloaded and decrypted once however
|
||||
/// many readers want it at that moment (audit T1).
|
||||
///
|
||||
/// With `FUSE_ASYNC_READ` the demand read and the kernel's read-ahead land in
|
||||
/// the same 4 MiB block for 31 of every 32 reads, and read-ahead of our own
|
||||
/// (see [`Core::prefetch`]) aims at blocks a demand read may be about to ask
|
||||
/// for. Without this each of them pays the full round-trip and decrypt and all
|
||||
/// but one result is discarded — roughly double the bandwidth and CPU in steady
|
||||
/// state. Same shape as [`ReaderSlot::Pending`] one level up.
|
||||
#[derive(Default)]
|
||||
pub(super) struct BlockFlight {
|
||||
inflight: Mutex<HashMap<BlockKey, Claim>>,
|
||||
}
|
||||
|
||||
/// Distinguishes attempts at the same key, so a leader only retires its own.
|
||||
static NEXT_FLIGHT_ID: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// The leader's claim on a key. Retires it on drop — including when the task is
|
||||
/// cancelled mid-fetch, which is what keeps a dropped `JoinSet` from leaving a
|
||||
/// key nobody will ever publish.
|
||||
struct Flight {
|
||||
flight: Arc<BlockFlight>,
|
||||
key: BlockKey,
|
||||
id: u64,
|
||||
}
|
||||
|
||||
impl Drop for Flight {
|
||||
fn drop(&mut self) {
|
||||
let mut inflight = self.flight.inflight.lock();
|
||||
if inflight
|
||||
.get(&self.key)
|
||||
.is_some_and(|(id, _)| *id == self.id)
|
||||
{
|
||||
inflight.remove(&self.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Where each file's reader is expected to read next, so sequential reads can be
|
||||
/// told from random ones and only the former prefetched (audit T2).
|
||||
#[derive(Default)]
|
||||
pub(super) struct Prefetch {
|
||||
/// Per node: the block index a sequential reader should ask for next, and
|
||||
/// the window depth it has earned.
|
||||
streams: Mutex<HashMap<NodeUid, (u64, u64)>>,
|
||||
}
|
||||
|
||||
impl Prefetch {
|
||||
/// How far past `last` to read ahead for a read of blocks `[first, last]`,
|
||||
/// and record where that read leaves the file's cursor.
|
||||
///
|
||||
/// A read that starts where the last one stopped doubles the window (from
|
||||
/// [`PREFETCH_MIN`], up to [`PREFETCH_MAX`]); anything else is a seek and
|
||||
/// earns nothing, so random access — a database file, a linker — never pays
|
||||
/// for speculative blocks it will not read. The first read of a file counts
|
||||
/// as sequential: `(0, 0)` is both the initial cursor and block zero.
|
||||
fn window(&self, uid: &NodeUid, first: u64, last: u64) -> u64 {
|
||||
let mut streams = self.streams.lock();
|
||||
if streams.len() > MAX_PREFETCH_STREAMS {
|
||||
streams.clear();
|
||||
}
|
||||
let stream = streams.entry(uid.clone()).or_insert((0, 0));
|
||||
let depth = if stream.0 == first {
|
||||
(stream.1 * 2).clamp(PREFETCH_MIN, PREFETCH_MAX)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
*stream = (last + 1, depth);
|
||||
depth
|
||||
}
|
||||
}
|
||||
|
||||
impl Core {
|
||||
/// An open [`RevisionReader`] for `uid`, reusing the cached one when it is
|
||||
/// still valid for `(mtime, fsize)` and opening a fresh one otherwise.
|
||||
@@ -304,6 +406,10 @@ impl Core {
|
||||
/// revision is no longer the truth.
|
||||
pub(super) fn evict_reader(&self, uid: &NodeUid) {
|
||||
self.readers.lock().remove(uid);
|
||||
// The ring holds decrypted bytes of the same revision, and an eviction is
|
||||
// exactly the statement that those are no longer the file's content.
|
||||
self.block_ring.lock().drop_node(uid);
|
||||
self.prefetch.streams.lock().remove(uid);
|
||||
}
|
||||
|
||||
/// Serve bytes `[offset, offset + len)` of `uid`'s active revision, hitting
|
||||
@@ -389,20 +495,141 @@ impl Core {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Warm the [`STREAM_READAHEAD`] blocks after `last` into the ring, in the
|
||||
/// background, so a player reading forward finds its next block already in
|
||||
/// memory rather than paying a fetch + decrypt round-trip at each boundary.
|
||||
/// Fetch block `bidx`, joining a fetch already in flight for it rather than
|
||||
/// issuing a second one, and publish the result to everyone who joined.
|
||||
///
|
||||
/// Fire-and-forget: a failure here is not an error, it only means the read
|
||||
/// that needs those bytes will fetch them itself.
|
||||
fn stream_readahead(&self, uid: &NodeUid, mtime: i64, fsize: u64, last: u64) {
|
||||
let last_block = (fsize.saturating_sub(1)) / BLOCK_SIZE;
|
||||
let wanted: Vec<u64> = ((last + 1)..=(last + STREAM_READAHEAD).min(last_block))
|
||||
/// See [`BlockFlight`]. A follower whose leader disappears (a panic, or a
|
||||
/// cancelled `JoinSet`) fetches the block itself rather than failing — the
|
||||
/// leader's claim is gone by then, so this cannot recurse.
|
||||
async fn fetch_block(
|
||||
&self,
|
||||
reader: &Arc<RevisionReader>,
|
||||
uid: &NodeUid,
|
||||
mtime: i64,
|
||||
fsize: u64,
|
||||
bidx: u64,
|
||||
cache_blocks: bool,
|
||||
) -> BlockResult {
|
||||
let key: BlockKey = (uid.clone(), mtime, fsize, bidx);
|
||||
let claim = {
|
||||
let mut inflight = self.block_flight.inflight.lock();
|
||||
match inflight.get(&key) {
|
||||
Some((_, rx)) => Err(rx.clone()),
|
||||
None => {
|
||||
let id = NEXT_FLIGHT_ID.fetch_add(1, Ordering::Relaxed);
|
||||
let (tx, rx) = tokio::sync::watch::channel(None);
|
||||
inflight.insert(key.clone(), (id, rx));
|
||||
Ok((
|
||||
tx,
|
||||
Flight {
|
||||
flight: self.block_flight.clone(),
|
||||
key: key.clone(),
|
||||
id,
|
||||
},
|
||||
))
|
||||
}
|
||||
}
|
||||
};
|
||||
let (tx, flight) = match claim {
|
||||
Ok(lead) => lead,
|
||||
Err(mut rx) => {
|
||||
loop {
|
||||
if let Some(result) = rx.borrow_and_update().clone() {
|
||||
return result;
|
||||
}
|
||||
if rx.changed().await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return self
|
||||
.read_block(reader, uid, mtime, fsize, bidx, cache_blocks)
|
||||
.await;
|
||||
}
|
||||
};
|
||||
let result = self
|
||||
.read_block(reader, uid, mtime, fsize, bidx, cache_blocks)
|
||||
.await;
|
||||
// Retire the claim before publishing, so a racer that wakes and re-enters
|
||||
// finds no stale entry to join.
|
||||
drop(flight);
|
||||
let _ = tx.send(Some(result.clone()));
|
||||
result
|
||||
}
|
||||
|
||||
/// Download and decrypt one block, then put it where the next reader will
|
||||
/// find it: the in-memory ring always, the on-disk block cache too unless
|
||||
/// this is a disk-bypassing stream.
|
||||
///
|
||||
/// The disk store is handed to a blocking thread — it is a 4 MiB write plus
|
||||
/// a cache-index update, and neither the reactor nor the kernel read waiting
|
||||
/// on these bytes should be held up by it.
|
||||
async fn read_block(
|
||||
&self,
|
||||
reader: &Arc<RevisionReader>,
|
||||
uid: &NodeUid,
|
||||
mtime: i64,
|
||||
fsize: u64,
|
||||
bidx: u64,
|
||||
cache_blocks: bool,
|
||||
) -> BlockResult {
|
||||
let bstart = bidx * BLOCK_SIZE;
|
||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
||||
let bytes = reader.read_at(bstart, blen).await.map_err(|e| {
|
||||
warn!(%uid, bstart, blen, error = %e, "block read failed");
|
||||
Errno::EIO
|
||||
})?;
|
||||
let bytes = Arc::new(bytes);
|
||||
if cache_blocks {
|
||||
let cache = self.cache.clone();
|
||||
let uid = uid.clone();
|
||||
let bytes = bytes.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let _ = cache.store_block(&uid, mtime, fsize, bidx, &bytes);
|
||||
});
|
||||
}
|
||||
self.block_ring
|
||||
.lock()
|
||||
.insert(uid, mtime, fsize, bidx, bytes.clone());
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// Warm the blocks after a sequential read into the caches, in the
|
||||
/// background, so the next read finds them instead of paying a round-trip.
|
||||
///
|
||||
/// A read that continues where the last one stopped doubles the window, up
|
||||
/// to [`PREFETCH_MAX`]; a seek drops it back to nothing and one more
|
||||
/// sequential read is needed to earn it again. Without this every read is a
|
||||
/// serial fetch/serve/stall chain: on a 100 ms link that ceilings a file at
|
||||
/// ~24 MB/s of the ~125 MB/s available, and the SDK's block concurrency is
|
||||
/// never exercised at all.
|
||||
///
|
||||
/// Fire-and-forget, and permit-bounded ([`PREFETCH_BUDGET`]) so speculative
|
||||
/// work never sits in front of a read someone is waiting on. A failure here
|
||||
/// is not an error: the read that needs those bytes will fetch them itself.
|
||||
fn prefetch_ahead(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
mtime: i64,
|
||||
fsize: u64,
|
||||
first: u64,
|
||||
last: u64,
|
||||
cache_blocks: bool,
|
||||
) {
|
||||
let depth = self.prefetch.window(uid, first, last);
|
||||
if depth == 0 {
|
||||
return;
|
||||
}
|
||||
let last_block = fsize.saturating_sub(1) / BLOCK_SIZE;
|
||||
let wanted: Vec<u64> = ((last + 1)..=(last + depth).min(last_block))
|
||||
.filter(|&bidx| {
|
||||
self.stream_ring
|
||||
// Blocks already in memory need nothing; blocks already on disk
|
||||
// are cheap enough to serve that spending a round-trip to move
|
||||
// them into the ring would be the wrong trade.
|
||||
self.block_ring
|
||||
.lock()
|
||||
.get(uid, mtime, fsize, bidx)
|
||||
.is_none()
|
||||
&& !(cache_blocks && self.cache.has_block(uid, mtime, fsize, bidx))
|
||||
})
|
||||
.collect();
|
||||
if wanted.is_empty() {
|
||||
@@ -416,26 +643,25 @@ impl Core {
|
||||
};
|
||||
// Concurrently, for the same reason the demand path fetches its
|
||||
// misses concurrently: serially awaited blocks cost the sum of their
|
||||
// round-trips, and the player catches up with the readahead before it
|
||||
// finishes. The SDK's in-flight block governor bounds the fan-out.
|
||||
// round-trips, and the reader catches up with the window before it
|
||||
// finishes.
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for bidx in wanted {
|
||||
let reader = reader.clone();
|
||||
let bstart = bidx * BLOCK_SIZE;
|
||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
||||
set.spawn(async move { (bidx, reader.read_at(bstart, blen).await) });
|
||||
}
|
||||
while let Some(joined) = set.join_next().await {
|
||||
let Ok((bidx, read)) = joined else { continue };
|
||||
match read {
|
||||
Ok(bytes) => {
|
||||
core.stream_ring
|
||||
.lock()
|
||||
.insert(&uid, mtime, fsize, bidx, Arc::new(bytes))
|
||||
let Ok(permit) = core.prefetch_budget.clone().try_acquire_owned() else {
|
||||
break; // the budget is spent; the demand path can have it
|
||||
};
|
||||
let (core, reader, uid) = (core.clone(), reader.clone(), uid.clone());
|
||||
set.spawn(async move {
|
||||
let _permit = permit;
|
||||
if let Err(error) = core
|
||||
.fetch_block(&reader, &uid, mtime, fsize, bidx, cache_blocks)
|
||||
.await
|
||||
{
|
||||
debug!(%uid, bidx, ?error, "prefetch failed");
|
||||
}
|
||||
Err(e) => debug!(%uid, bidx, error = %e, "stream readahead failed"),
|
||||
}
|
||||
});
|
||||
}
|
||||
while set.join_next().await.is_some() {}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -473,17 +699,23 @@ impl Core {
|
||||
let mut blocks: Vec<Option<Arc<Vec<u8>>>> = Vec::with_capacity((last - first + 1) as usize);
|
||||
let mut misses: Vec<u64> = Vec::new();
|
||||
for bidx in first..=last {
|
||||
// A streaming read keeps its blocks only in memory, so the ring is
|
||||
// the only cache it has — check it before the disk (which will not
|
||||
// hold them) and before the network (which would refetch the same
|
||||
// 4 MiB block for every 128 KiB the player consumes).
|
||||
let hit = if cache_blocks {
|
||||
self.cache
|
||||
.cached_block(uid, mtime, fsize, bidx)
|
||||
.map(Arc::new)
|
||||
} else {
|
||||
self.stream_ring.lock().get(uid, mtime, fsize, bidx)
|
||||
};
|
||||
// The in-memory ring comes first on both paths: a streaming read has
|
||||
// no other cache at all, and a cached one would otherwise pay two
|
||||
// opens, a JSON parse and a 4 MiB read+alloc for every 128 KiB the
|
||||
// kernel asks for (audit T3).
|
||||
let hit = self.block_ring.lock().get(uid, mtime, fsize, bidx);
|
||||
let hit = hit.or_else(|| {
|
||||
if !cache_blocks {
|
||||
return None;
|
||||
}
|
||||
let bytes = Arc::new(self.cache.cached_block(uid, mtime, fsize, bidx)?);
|
||||
// Promote it, so the other ~31 reads of this block are answered
|
||||
// from memory.
|
||||
self.block_ring
|
||||
.lock()
|
||||
.insert(uid, mtime, fsize, bidx, bytes.clone());
|
||||
Some(bytes)
|
||||
});
|
||||
match hit {
|
||||
Some(b) => blocks.push(Some(b)),
|
||||
None => {
|
||||
@@ -502,19 +734,11 @@ impl Core {
|
||||
|
||||
let mut set = tokio::task::JoinSet::new();
|
||||
for &bidx in &misses {
|
||||
let reader = reader.clone();
|
||||
let uid = uid.clone();
|
||||
let bstart = bidx * BLOCK_SIZE;
|
||||
let blen = BLOCK_SIZE.min(fsize - bstart);
|
||||
let (core, reader, uid) = (self.clone(), reader.clone(), uid.clone());
|
||||
set.spawn(async move {
|
||||
reader
|
||||
.read_at(bstart, blen)
|
||||
core.fetch_block(&reader, &uid, mtime, fsize, bidx, cache_blocks)
|
||||
.await
|
||||
.map(|bytes| (bidx, bytes))
|
||||
.map_err(|e| {
|
||||
warn!(%uid, bstart, blen, error = %e, "block read failed");
|
||||
Errno::EIO
|
||||
})
|
||||
});
|
||||
}
|
||||
let mut out = Vec::with_capacity(misses.len());
|
||||
@@ -525,23 +749,12 @@ impl Core {
|
||||
Ok::<_, Errno>(out)
|
||||
})?;
|
||||
for (bidx, bytes) in fetched {
|
||||
// A streaming read (large unpinned video) skips the on-disk cache
|
||||
// so it can't evict the rest of it; it keeps the block in the
|
||||
// in-memory ring instead, which is bounded and dies with the read.
|
||||
let bytes = Arc::new(bytes);
|
||||
if cache_blocks {
|
||||
let _ = self.cache.store_block(uid, mtime, fsize, bidx, &bytes);
|
||||
} else {
|
||||
self.stream_ring
|
||||
.lock()
|
||||
.insert(uid, mtime, fsize, bidx, bytes.clone());
|
||||
}
|
||||
blocks[(bidx - first) as usize] = Some(bytes);
|
||||
}
|
||||
if !cache_blocks {
|
||||
self.stream_readahead(uid, mtime, fsize, last);
|
||||
}
|
||||
}
|
||||
// Also after a wholly-cached read: that is what keeps the window sliding
|
||||
// ahead of a sequential reader instead of stalling once it catches up.
|
||||
self.prefetch_ahead(uid, mtime, fsize, first, last, cache_blocks);
|
||||
|
||||
for (i, block) in blocks.into_iter().enumerate() {
|
||||
let bidx = first + i as u64;
|
||||
@@ -605,6 +818,106 @@ impl Core {
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The largest amount [`Core::fill_gaps_cached`] will copy out of the local
|
||||
/// caches while a `release(2)` is in progress.
|
||||
///
|
||||
/// The point of the cached fill is that editing a file the mount already
|
||||
/// holds costs nothing extra; it is not to reconstruct arbitrarily large
|
||||
/// files on the dispatch loop. Past this budget the blob stays incomplete
|
||||
/// and the drain finishes it, which is the same work in a place where it
|
||||
/// blocks nobody.
|
||||
const CACHED_FILL_BUDGET: u64 = 64 * 1024 * 1024;
|
||||
|
||||
/// Bytes `[offset, offset + len)` of `uid`'s base revision, but only if the
|
||||
/// local caches can answer in full — the whole-file blob first, then every
|
||||
/// overlapping block. `None` the moment anything would have to be fetched.
|
||||
fn cached_range(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
mtime: i64,
|
||||
fsize: u64,
|
||||
offset: u64,
|
||||
len: u64,
|
||||
) -> Option<Vec<u8>> {
|
||||
if len == 0 {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
if let Some(bytes) = self.cache.read_range(uid, mtime, fsize, offset, len) {
|
||||
return Some(bytes);
|
||||
}
|
||||
let end = offset.checked_add(len)?.min(fsize);
|
||||
if offset >= end {
|
||||
return None;
|
||||
}
|
||||
let mut out = Vec::with_capacity((end - offset) as usize);
|
||||
for bidx in (offset / BLOCK_SIZE)..=((end - 1) / BLOCK_SIZE) {
|
||||
let ring = self.block_ring.lock().get(uid, mtime, fsize, bidx);
|
||||
let block = match ring {
|
||||
Some(bytes) => bytes,
|
||||
None => Arc::new(self.cache.cached_block(uid, mtime, fsize, bidx)?),
|
||||
};
|
||||
let bstart = bidx * BLOCK_SIZE;
|
||||
let s = (offset.max(bstart) - bstart) as usize;
|
||||
let e = (end.min(bstart + block.len() as u64) - bstart) as usize;
|
||||
if s >= e {
|
||||
return None;
|
||||
}
|
||||
out.extend_from_slice(&block[s..e]);
|
||||
}
|
||||
(out.len() as u64 == end - offset).then_some(out)
|
||||
}
|
||||
|
||||
/// Fill what [`Core::fill_gaps`] would, but only from bytes already on this
|
||||
/// machine, and only up to [`Core::CACHED_FILL_BUDGET`]. Ranges past the
|
||||
/// base are zeros the caller's `set_len` already wrote, so they count as
|
||||
/// filled. `written` is extended with everything that ends up resolved.
|
||||
///
|
||||
/// This is the release-time half of gap filling. Doing the network half
|
||||
/// there as well is what froze the whole mount for the length of a download
|
||||
/// (audit F1): the closing file's untouched ranges are allowed to stay
|
||||
/// unresolved in the staged blob — `StagedWrite::complete` is false, reads
|
||||
/// resolve the gaps against the base, and `drain_revision` fills them on the
|
||||
/// drain thread before it uploads. What is left here is the case where
|
||||
/// filling is free, which is also the common one: a file that was read
|
||||
/// before it was edited.
|
||||
pub(super) fn fill_gaps_cached(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
file: &File,
|
||||
len: u64,
|
||||
base_mtime: i64,
|
||||
base_size: u64,
|
||||
written: &mut Intervals,
|
||||
) {
|
||||
let mut budget = Self::CACHED_FILL_BUDGET;
|
||||
for (s, e, authored) in written.clone().segments(0, len) {
|
||||
if authored {
|
||||
continue;
|
||||
}
|
||||
let bend = e.min(base_size);
|
||||
if s < bend {
|
||||
let want = bend - s;
|
||||
if want > budget {
|
||||
continue;
|
||||
}
|
||||
let Some(bytes) = self.cached_range(uid, base_mtime, base_size, s, want) else {
|
||||
continue;
|
||||
};
|
||||
if let Err(error) = file.write_all_at(&bytes, s) {
|
||||
warn!(%uid, %error, "cached gap-fill write failed");
|
||||
continue;
|
||||
}
|
||||
budget -= want;
|
||||
written.add(s, bend);
|
||||
}
|
||||
// Past the base there is nothing to fetch: the resize zeroed it, so
|
||||
// those bytes are already the file's content.
|
||||
if e > base_size {
|
||||
written.add(s.max(base_size), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Fill every unauthored range of a scratch/staged file that overlaps its
|
||||
/// base with the base's bytes, so the file becomes the complete new content.
|
||||
///
|
||||
@@ -643,3 +956,145 @@ impl Core {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod block_ring_tests {
|
||||
use super::*;
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||
}
|
||||
|
||||
/// One block's worth of bytes, so byte accounting is exercised at the scale
|
||||
/// the ring actually sees.
|
||||
fn block(byte: u8) -> Arc<Vec<u8>> {
|
||||
Arc::new(vec![byte; BLOCK_SIZE as usize])
|
||||
}
|
||||
|
||||
/// How many whole blocks fit before the ring starts evicting.
|
||||
fn capacity() -> u64 {
|
||||
RING_BYTES / BLOCK_SIZE
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stored_block_is_served_back() {
|
||||
let mut ring = BlockRing::default();
|
||||
let node = uid("a");
|
||||
ring.insert(&node, 7, 100, 3, block(0xab));
|
||||
assert_eq!(ring.get(&node, 7, 100, 3).map(|b| b[0]), Some(0xab));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_block_of_another_revision_is_never_served() {
|
||||
let mut ring = BlockRing::default();
|
||||
let node = uid("a");
|
||||
ring.insert(&node, 7, 100, 0, block(1));
|
||||
// Same node, newer revision: the tag moved, so the old bytes go.
|
||||
assert!(ring.get(&node, 8, 100, 0).is_none());
|
||||
assert!(ring.get(&node, 7, 101, 0).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_new_revision_drops_what_the_old_one_left() {
|
||||
let mut ring = BlockRing::default();
|
||||
let node = uid("a");
|
||||
ring.insert(&node, 7, 100, 0, block(1));
|
||||
ring.insert(&node, 8, 100, 0, block(2));
|
||||
assert_eq!(ring.get(&node, 8, 100, 0).map(|b| b[0]), Some(2));
|
||||
assert_eq!(ring.bytes, BLOCK_SIZE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_ring_evicts_the_least_recently_used_block() {
|
||||
let mut ring = BlockRing::default();
|
||||
let node = uid("a");
|
||||
for idx in 0..capacity() {
|
||||
ring.insert(&node, 1, u64::MAX, idx, block(idx as u8));
|
||||
}
|
||||
// Re-reading block 0 makes block 1 the oldest instead.
|
||||
assert!(ring.get(&node, 1, u64::MAX, 0).is_some());
|
||||
ring.insert(&node, 1, u64::MAX, capacity(), block(0xff));
|
||||
assert!(ring.get(&node, 1, u64::MAX, 0).is_some());
|
||||
assert!(ring.get(&node, 1, u64::MAX, 1).is_none());
|
||||
assert_eq!(ring.bytes, RING_BYTES);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_node_whose_last_block_ages_out_keeps_no_tag() {
|
||||
let mut ring = BlockRing::default();
|
||||
let (old, new) = (uid("old"), uid("new"));
|
||||
ring.insert(&old, 1, u64::MAX, 0, block(1));
|
||||
for idx in 0..capacity() {
|
||||
ring.insert(&new, 1, u64::MAX, idx, block(2));
|
||||
}
|
||||
assert!(!ring.tags.contains_key(&old));
|
||||
assert!(ring.tags.contains_key(&new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_node_frees_only_its_own_bytes() {
|
||||
let mut ring = BlockRing::default();
|
||||
let (a, b) = (uid("a"), uid("b"));
|
||||
ring.insert(&a, 1, 100, 0, block(1));
|
||||
ring.insert(&b, 1, 100, 0, block(2));
|
||||
ring.drop_node(&a);
|
||||
assert!(ring.get(&a, 1, 100, 0).is_none());
|
||||
assert_eq!(ring.get(&b, 1, 100, 0).map(|x| x[0]), Some(2));
|
||||
assert_eq!(ring.bytes, BLOCK_SIZE);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod prefetch_tests {
|
||||
use super::*;
|
||||
|
||||
fn uid(link: &str) -> NodeUid {
|
||||
NodeUid::new(VolumeId::from("vol"), LinkId::from(link))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sequential_reader_ramps_up_to_the_cap() {
|
||||
let prefetch = Prefetch::default();
|
||||
let node = uid("a");
|
||||
let depths: Vec<u64> = (0..6)
|
||||
.map(|next| prefetch.window(&node, next, next))
|
||||
.collect();
|
||||
assert_eq!(depths, vec![2, 4, 8, 8, 8, 8]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_seek_forfeits_the_window() {
|
||||
let prefetch = Prefetch::default();
|
||||
let node = uid("a");
|
||||
assert_eq!(prefetch.window(&node, 0, 0), PREFETCH_MIN);
|
||||
assert_eq!(prefetch.window(&node, 40, 40), 0);
|
||||
// And has to be earned again from the bottom.
|
||||
assert_eq!(prefetch.window(&node, 41, 41), PREFETCH_MIN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_block_read_advances_the_cursor_past_all_of_it() {
|
||||
let prefetch = Prefetch::default();
|
||||
let node = uid("a");
|
||||
assert_eq!(prefetch.window(&node, 0, 3), PREFETCH_MIN);
|
||||
assert_eq!(prefetch.window(&node, 4, 7), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_are_tracked_independently() {
|
||||
let prefetch = Prefetch::default();
|
||||
let (a, b) = (uid("a"), uid("b"));
|
||||
assert_eq!(prefetch.window(&a, 0, 0), PREFETCH_MIN);
|
||||
assert_eq!(prefetch.window(&b, 9, 9), 0);
|
||||
assert_eq!(prefetch.window(&a, 1, 1), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_stream_map_cannot_grow_without_bound() {
|
||||
let prefetch = Prefetch::default();
|
||||
for n in 0..=MAX_PREFETCH_STREAMS + 1 {
|
||||
prefetch.window(&uid(&n.to_string()), 0, 0);
|
||||
}
|
||||
assert!(prefetch.streams.lock().len() <= MAX_PREFETCH_STREAMS + 1);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@ use pdfs_core::control::{
|
||||
BookmarkInfo, DirEntry, InvitationInfo, PublicLinkInfo, ShareEntry, ShareEntryKind, SharedItem,
|
||||
};
|
||||
use pdfs_core::{CoreError, CoreResult};
|
||||
use tracing::debug;
|
||||
|
||||
use crate::QUOTA_TTL;
|
||||
use proton_drive_rs::proton_sdk::ids::NodeUid;
|
||||
use proton_drive_rs::{MemberRole, NodeKind};
|
||||
|
||||
@@ -533,9 +536,33 @@ impl Core {
|
||||
.rt
|
||||
.block_on(self.client.quota())
|
||||
.map_err(|e| CoreError::from_api(&e, "account quota"))?;
|
||||
*self.quota.lock() = Some((std::time::Instant::now(), q.max_space, q.used_space));
|
||||
Ok((q.max_space, q.used_space))
|
||||
}
|
||||
|
||||
/// The account quota as `statfs(2)` needs it: cached for [`QUOTA_TTL`],
|
||||
/// refetched after that, and `None` when there is no usable answer at all —
|
||||
/// offline with nothing cached.
|
||||
///
|
||||
/// Never blocks on the network for a *fresh* value when a recent one exists,
|
||||
/// because the callers are `df` and every "is there room?" preflight, and a
|
||||
/// stale byte count is worth more to them than a stalled syscall. Offline,
|
||||
/// the last known figures keep being served rather than degrading to zero.
|
||||
pub(crate) fn account_quota_cached(&self) -> Option<(i64, i64)> {
|
||||
if let Some((at, max_space, used_space)) = *self.quota.lock()
|
||||
&& at.elapsed() < QUOTA_TTL
|
||||
{
|
||||
return Some((max_space, used_space));
|
||||
}
|
||||
match self.account_quota() {
|
||||
Ok(q) => Some(q),
|
||||
Err(error) => {
|
||||
debug!(%error, "account quota refresh failed; serving the last known figures");
|
||||
self.quota.lock().map(|(_, max, used)| (max, used))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- shared by me -----------------------------------------------------
|
||||
|
||||
/// List the nodes I have shared with others, each with a summary of its share
|
||||
|
||||
+251
-85
@@ -166,13 +166,163 @@ pub(crate) struct State {
|
||||
/// on every explicit role/tombstone change. Descendant inheritance reads
|
||||
/// this map instead of issuing one SQLite query per interned inode.
|
||||
pub(crate) share_access: HashMap<NodeUid, Access>,
|
||||
/// Unified SQLite metadata cache. Every map mutation below writes through to
|
||||
/// it inside the `State` lock so the DB stays the authoritative copy across
|
||||
/// restarts (see plan.md P1).
|
||||
/// Unified SQLite metadata cache. Read from here (`has_children`) but never
|
||||
/// written: mutations go to [`State::outbox`] and are applied after the lock
|
||||
/// is released. See [`StateGuard`].
|
||||
pub(crate) db: Arc<Db>,
|
||||
/// Write-throughs this mutation owes SQLite, applied by [`StateGuard`] once
|
||||
/// the inode lock is released.
|
||||
///
|
||||
/// The DB stays the authoritative copy across restarts, so every map
|
||||
/// mutation still writes through — it just no longer does so with the
|
||||
/// whole-mount lock held. A single listing's write-through is one
|
||||
/// transaction over up to a few thousand rows, and every other FUSE callback
|
||||
/// used to wait behind it for no reason: the maps were already updated
|
||||
/// before the first row was written.
|
||||
outbox: Vec<DbWrite>,
|
||||
}
|
||||
|
||||
/// A write-through queued by a `State` mutation, applied after the lock drops.
|
||||
///
|
||||
/// Deliberately a small closed set rather than a boxed closure: these run
|
||||
/// outside the lock, and what they can touch should be readable here.
|
||||
pub(crate) enum DbWrite {
|
||||
/// One node or a whole listing. Both go through `upsert_nodes`, which is a
|
||||
/// single transaction either way, so there is nothing to gain by splitting
|
||||
/// them — and a `Node` is large enough that a variant holding one inline
|
||||
/// would set the size of every other.
|
||||
Upsert(Vec<Node>),
|
||||
Delete(NodeUid),
|
||||
SetListed(NodeUid, bool),
|
||||
SetShareAccess(NodeUid, Access),
|
||||
}
|
||||
|
||||
impl DbWrite {
|
||||
fn apply(self, db: &Db) {
|
||||
let outcome = match &self {
|
||||
Self::Upsert(nodes) => db.upsert_nodes(nodes),
|
||||
Self::Delete(uid) => db.delete_node(uid),
|
||||
Self::SetListed(uid, listed) => db.set_listed(uid, *listed),
|
||||
Self::SetShareAccess(uid, access) => db.set_share_access(uid, *access),
|
||||
};
|
||||
if let Err(error) = outcome {
|
||||
let what = match &self {
|
||||
Self::Upsert(_) => "upsert_nodes",
|
||||
Self::Delete(_) => "delete_node",
|
||||
Self::SetListed(..) => "set_listed",
|
||||
Self::SetShareAccess(..) => "set_share_access",
|
||||
};
|
||||
warn!(%error, write = what, "db write-through failed");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The inode lock, held for the duration of a mutation, plus the write-throughs
|
||||
/// that mutation owed the database.
|
||||
///
|
||||
/// Dropping it releases the lock *first* and only then writes, which is the
|
||||
/// whole point: interning a five-thousand-child listing commits one transaction
|
||||
/// — several fsyncs' worth — and nothing about that needs the maps frozen. The
|
||||
/// write still happens synchronously on this thread before the caller proceeds,
|
||||
/// so a read that follows a mutation still sees it.
|
||||
pub(crate) struct StateGuard<'a> {
|
||||
guard: Option<parking_lot::MutexGuard<'a, State>>,
|
||||
db: &'a Arc<Db>,
|
||||
}
|
||||
|
||||
/// Take a mount's inode lock through a [`StateGuard`], for the loops that walk
|
||||
/// every live mount rather than this `Core`'s own.
|
||||
pub(crate) fn lock_state<'a>(
|
||||
state: &'a parking_lot::Mutex<State>,
|
||||
db: &'a Arc<Db>,
|
||||
) -> StateGuard<'a> {
|
||||
StateGuard::new(state.lock(), db)
|
||||
}
|
||||
|
||||
impl<'a> StateGuard<'a> {
|
||||
pub(crate) fn new(guard: parking_lot::MutexGuard<'a, State>, db: &'a Arc<Db>) -> Self {
|
||||
Self {
|
||||
guard: Some(guard),
|
||||
db,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::Deref for StateGuard<'_> {
|
||||
type Target = State;
|
||||
|
||||
fn deref(&self) -> &State {
|
||||
self.guard.as_ref().expect("held until drop")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::ops::DerefMut for StateGuard<'_> {
|
||||
fn deref_mut(&mut self) -> &mut State {
|
||||
self.guard.as_mut().expect("held until drop")
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for StateGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let Some(mut guard) = self.guard.take() else {
|
||||
return;
|
||||
};
|
||||
let writes = std::mem::take(&mut guard.outbox);
|
||||
// The lock goes before the writes do. That ordering is the feature.
|
||||
drop(guard);
|
||||
for write in writes {
|
||||
write.apply(self.db);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl State {
|
||||
/// A fresh, empty inode space over `db`.
|
||||
///
|
||||
/// `next_ino` differs by caller: a real mount hands out [`crate::ROOT_INO`]
|
||||
/// to its root and starts allocating above it, while a bare state under test
|
||||
/// has no root and starts at 1.
|
||||
/// Apply the write-throughs this state owes SQLite, here and now.
|
||||
///
|
||||
/// Production code never calls this: [`StateGuard`] releases the inode lock
|
||||
/// first and then applies them, which is the whole point. A test driving a
|
||||
/// bare `State` has no guard, so it calls this where the daemon would have
|
||||
/// dropped one.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn flush_outbox(&mut self) {
|
||||
let db = self.db.clone();
|
||||
for write in std::mem::take(&mut self.outbox) {
|
||||
write.apply(&db);
|
||||
}
|
||||
}
|
||||
|
||||
/// The database, with everything this state owes it already written.
|
||||
///
|
||||
/// What a test means by `st.db` is almost always "the database as of now",
|
||||
/// which in the daemon is the state after the guard dropped. Handing back an
|
||||
/// owned `Arc` rather than a borrow keeps it usable inside an `assert_eq!`
|
||||
/// that also touches `st`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn flushed_db(&mut self) -> Arc<Db> {
|
||||
self.flush_outbox();
|
||||
self.db.clone()
|
||||
}
|
||||
|
||||
pub(crate) fn new(db: Arc<Db>, share_access: HashMap<NodeUid, Access>, next_ino: u64) -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
next_ino,
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
access_changes: HashSet::new(),
|
||||
share_access,
|
||||
db,
|
||||
outbox: Vec::new(),
|
||||
}
|
||||
}
|
||||
/// Whether `uid` names an owned node currently reachable from this mount's
|
||||
/// root. Resident entries can outlive their dentries for open-handle and
|
||||
/// access-revocation semantics, so `by_uid` membership alone is not proof
|
||||
@@ -278,11 +428,9 @@ impl State {
|
||||
|| stored.unwrap_or(Access::Viewer),
|
||||
|membership| access_for(membership.role_exact(), parent_access, true),
|
||||
);
|
||||
if let Err(error) = self.db.set_share_access(&node.uid, access) {
|
||||
warn!(uid = %node.uid, ?access, %error, "db set_share_access failed");
|
||||
} else {
|
||||
self.share_access.insert(node.uid.clone(), access);
|
||||
}
|
||||
self.outbox
|
||||
.push(DbWrite::SetShareAccess(node.uid.clone(), access));
|
||||
self.share_access.insert(node.uid.clone(), access);
|
||||
return access;
|
||||
}
|
||||
parent_access
|
||||
@@ -313,11 +461,9 @@ impl State {
|
||||
None => (None, Access::Unknown),
|
||||
};
|
||||
let access = access_for(role, parent_access, true);
|
||||
if let Err(error) = self.db.set_share_access(uid, access) {
|
||||
warn!(%uid, ?access, %error, "db set observed share access failed");
|
||||
} else {
|
||||
self.share_access.insert(uid.clone(), access);
|
||||
}
|
||||
self.outbox
|
||||
.push(DbWrite::SetShareAccess(uid.clone(), access));
|
||||
self.share_access.insert(uid.clone(), access);
|
||||
let Some(root) = root else {
|
||||
return Vec::new();
|
||||
};
|
||||
@@ -602,18 +748,14 @@ impl State {
|
||||
})
|
||||
.collect();
|
||||
for (uid, access) in discovered {
|
||||
if let Err(error) = self.db.set_share_access(&uid, access) {
|
||||
warn!(%uid, ?access, %error, "db set hydrated share access failed");
|
||||
} else {
|
||||
self.share_access.insert(uid, access);
|
||||
}
|
||||
self.outbox
|
||||
.push(DbWrite::SetShareAccess(uid.clone(), access));
|
||||
self.share_access.insert(uid, access);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn intern(&mut self, parent: u64, node: Node) -> u64 {
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed");
|
||||
}
|
||||
self.outbox.push(DbWrite::Upsert(vec![node.clone()]));
|
||||
self.intern_mem(parent, node)
|
||||
}
|
||||
|
||||
@@ -685,9 +827,7 @@ impl State {
|
||||
/// This is what keeps `ls` on a large folder quick: one commit for the
|
||||
/// listing, rather than one autocommit — and one fsync — per child.
|
||||
pub(crate) fn intern_batch(&mut self, parent: u64, nodes: Vec<Node>) -> Vec<u64> {
|
||||
if let Err(e) = self.db.upsert_nodes(&nodes) {
|
||||
warn!(error = %e, "db upsert_nodes failed");
|
||||
}
|
||||
self.outbox.push(DbWrite::Upsert(nodes.clone()));
|
||||
nodes
|
||||
.into_iter()
|
||||
.map(|node| self.intern_mem(parent, node))
|
||||
@@ -769,9 +909,7 @@ impl State {
|
||||
/// `(parent_ino, name)` when the node was known, so the caller can notify
|
||||
/// the kernel.
|
||||
pub(crate) fn forget(&mut self, uid: &NodeUid) -> Option<(u64, String)> {
|
||||
if let Err(e) = self.db.delete_node(uid) {
|
||||
warn!(%uid, error = %e, "db delete_node failed");
|
||||
}
|
||||
self.outbox.push(DbWrite::Delete(uid.clone()));
|
||||
self.forget_mem(uid)
|
||||
}
|
||||
|
||||
@@ -834,9 +972,7 @@ impl State {
|
||||
kids.push(ino);
|
||||
}
|
||||
}
|
||||
if let Err(e) = self.db.upsert_node(&node) {
|
||||
warn!(uid = %node.uid, error = %e, "db upsert_node failed for a queued rename");
|
||||
}
|
||||
self.outbox.push(DbWrite::Upsert(vec![node]));
|
||||
self.recompute_descendant_access(ino);
|
||||
}
|
||||
|
||||
@@ -851,9 +987,7 @@ impl State {
|
||||
self.children.remove(&ino);
|
||||
if let Some(e) = self.entries.get(&ino) {
|
||||
let uid = e.uid.clone();
|
||||
if let Err(err) = self.db.set_listed(&uid, false) {
|
||||
warn!(%uid, error = %err, "db set_listed(false) failed");
|
||||
}
|
||||
self.outbox.push(DbWrite::SetListed(uid, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -920,14 +1054,15 @@ impl State {
|
||||
/// file is as long as the caller was told it is. Without it a restart serves
|
||||
/// the stale size and the file reads as truncated — or empty — while its
|
||||
/// bytes sit safely in staging (offline.md Phase 3).
|
||||
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) {
|
||||
/// Returns the node to persist rather than queueing it in the outbox, and is
|
||||
/// the one mutation here whose write-through the caller must do itself:
|
||||
/// failure has to be reported, not logged. Dropping the row silently means
|
||||
/// the daemon acknowledged a write and then serves the old size for it, so
|
||||
/// the caller has to be able to turn it into a failed `release`.
|
||||
pub(crate) fn record_pending_write(&mut self, ino: u64, size: u64, mtime: i64) -> Option<Node> {
|
||||
self.set_size(ino, size);
|
||||
self.touch_mtime(ino, mtime);
|
||||
if let Some(e) = self.entries.get(&ino)
|
||||
&& let Err(err) = self.db.upsert_node(&e.node)
|
||||
{
|
||||
warn!(uid = %e.uid, error = %err, "db upsert_node failed for a queued write");
|
||||
}
|
||||
self.entries.get(&ino).map(|e| e.node.clone())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1008,18 +1143,7 @@ mod tests {
|
||||
let dir = TempDir::new();
|
||||
let db = Db::open(&dir.0.join("cache.db")).unwrap();
|
||||
let share_access = db.all_share_access().unwrap();
|
||||
let st = State {
|
||||
entries: HashMap::new(),
|
||||
by_uid: HashMap::new(),
|
||||
children: HashMap::new(),
|
||||
next_ino: 1,
|
||||
active_writes: HashMap::new(),
|
||||
handles: HashMap::new(),
|
||||
next_fh: 1,
|
||||
access_changes: HashSet::new(),
|
||||
share_access,
|
||||
db: Arc::new(db),
|
||||
};
|
||||
let st = State::new(Arc::new(db), share_access, 1);
|
||||
(st, dir)
|
||||
}
|
||||
|
||||
@@ -1032,8 +1156,8 @@ mod tests {
|
||||
let f = st.intern(src, node("f", "src", "f.txt", false));
|
||||
st.children.insert(src, vec![f]);
|
||||
st.children.insert(dst, vec![]);
|
||||
st.db.set_listed(&uid("src"), true).unwrap();
|
||||
st.db.set_listed(&uid("dst"), true).unwrap();
|
||||
st.flushed_db().set_listed(&uid("src"), true).unwrap();
|
||||
st.flushed_db().set_listed(&uid("dst"), true).unwrap();
|
||||
(st, dir, src, dst)
|
||||
}
|
||||
|
||||
@@ -1047,11 +1171,17 @@ mod tests {
|
||||
st.relocate(f, src, dst, &uid("dst"), "f.txt");
|
||||
|
||||
assert!(
|
||||
st.db.children_if_listed(&uid("dst")).unwrap().is_none(),
|
||||
st.flushed_db()
|
||||
.children_if_listed(&uid("dst"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"destination re-enumerates: it gained a child whose row was just deleted"
|
||||
);
|
||||
assert!(
|
||||
st.db.children_if_listed(&uid("src")).unwrap().is_none(),
|
||||
st.flushed_db()
|
||||
.children_if_listed(&uid("src"))
|
||||
.unwrap()
|
||||
.is_none(),
|
||||
"source re-enumerates: its listing predates the move, and a stale \
|
||||
`listed` flag would hide every later remote change under it"
|
||||
);
|
||||
@@ -1074,12 +1204,41 @@ mod tests {
|
||||
let (mut st, _dir, src, _dst) = two_folders();
|
||||
let f = st.by_uid[&uid("f")];
|
||||
st.relocate(f, src, src, &uid("src"), "renamed.txt");
|
||||
assert!(st.db.children_if_listed(&uid("src")).unwrap().is_none());
|
||||
assert!(
|
||||
st.flushed_db()
|
||||
.children_if_listed(&uid("src"))
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(!st.children.contains_key(&src));
|
||||
assert_eq!(st.by_uid.get(&uid("f")), Some(&f), "the inode is stable");
|
||||
assert_eq!(st.entries[&f].node.name, "renamed.txt");
|
||||
}
|
||||
|
||||
/// DB1: a mutation's write-through must not happen with the inode lock
|
||||
/// held. Interning a listing commits a transaction over every child, and
|
||||
/// every other FUSE callback used to wait behind that for no reason — the
|
||||
/// maps it needs were updated before the first row was written.
|
||||
#[test]
|
||||
fn a_mutation_writes_through_only_after_the_lock_is_released() {
|
||||
let (st, _dir) = state();
|
||||
let db = st.db.clone();
|
||||
let root = uid("root").to_string();
|
||||
let state = parking_lot::Mutex::new(st);
|
||||
{
|
||||
let mut guard = lock_state(&state, &db);
|
||||
guard.intern(0, node("root", "none", "My Files", true));
|
||||
assert!(
|
||||
db.node_by_uid(&root).unwrap().is_none(),
|
||||
"the row must not be written while the lock is held"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
db.node_by_uid(&root).unwrap().is_some(),
|
||||
"…and must be written by the time the guard is gone"
|
||||
);
|
||||
}
|
||||
|
||||
/// The failure mode the fix exists for, stated directly: forgetting the node
|
||||
/// prunes it from the resident listing but leaves the DB claiming the source
|
||||
/// is fully enumerated. This pins *why* `relocate` cannot just call `forget`.
|
||||
@@ -1087,7 +1246,7 @@ mod tests {
|
||||
fn forget_alone_leaves_the_source_claiming_a_complete_listing() {
|
||||
let (mut st, _dir, _src, _dst) = two_folders();
|
||||
st.forget(&uid("f"));
|
||||
let listed = st.db.children_if_listed(&uid("src")).unwrap();
|
||||
let listed = st.flushed_db().children_if_listed(&uid("src")).unwrap();
|
||||
assert!(
|
||||
listed.is_some(),
|
||||
"forget does not clear the flag — which is exactly why relocate must"
|
||||
@@ -1136,7 +1295,7 @@ mod tests {
|
||||
assert!(!st.has_children(folder), "cleared memory children");
|
||||
|
||||
// DB children
|
||||
st.db
|
||||
st.flushed_db()
|
||||
.upsert_node(&node("file2_uid", "dir_uid", "file2.txt", false))
|
||||
.unwrap();
|
||||
assert!(
|
||||
@@ -1162,7 +1321,7 @@ mod tests {
|
||||
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
||||
assert_eq!(st.entries[&child].access, Access::Editor);
|
||||
assert_eq!(
|
||||
st.db.share_access(&uid("shared")).unwrap(),
|
||||
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||
Some(Access::Editor)
|
||||
);
|
||||
}
|
||||
@@ -1174,8 +1333,8 @@ mod tests {
|
||||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||||
let mut virtual_root = node("placeholder", "root", "Shared with me", true);
|
||||
virtual_root.uid = virtual_uid.clone();
|
||||
st.db.upsert_node(&virtual_root).unwrap();
|
||||
st.db
|
||||
st.flushed_db().upsert_node(&virtual_root).unwrap();
|
||||
st.flushed_db()
|
||||
.set_share_access(&virtual_uid, Access::Viewer)
|
||||
.unwrap();
|
||||
let virtual_ino = st.intern_published_share_root(owned_root, virtual_root, Access::Viewer);
|
||||
@@ -1183,15 +1342,17 @@ mod tests {
|
||||
let mut shared = node("shared", "placeholder", "Retained", true);
|
||||
shared.parent_uid = Some(virtual_uid.clone());
|
||||
shared.membership = Some(membership(6));
|
||||
st.db.upsert_node(&shared).unwrap();
|
||||
st.db.set_share_access(&shared.uid, Access::Editor).unwrap();
|
||||
st.flushed_db().upsert_node(&shared).unwrap();
|
||||
st.flushed_db()
|
||||
.set_share_access(&shared.uid, Access::Editor)
|
||||
.unwrap();
|
||||
let shared_ino =
|
||||
st.intern_published_share_root(virtual_ino, shared.clone(), Access::Editor);
|
||||
let child = st.intern(
|
||||
shared_ino,
|
||||
node("shared-child", "shared", "pending.txt", false),
|
||||
);
|
||||
st.db
|
||||
st.flushed_db()
|
||||
.enqueue_op(&pdfs_core::db::PendingOp {
|
||||
id: 0,
|
||||
kind: pdfs_core::db::OP_RENAME.to_string(),
|
||||
@@ -1209,20 +1370,20 @@ mod tests {
|
||||
assert_eq!(st.entries[&shared_ino].access, Access::Editor);
|
||||
assert_eq!(st.entries[&child].access, Access::Editor);
|
||||
|
||||
st.db
|
||||
st.flushed_db()
|
||||
.publish_shared_roots(&virtual_uid, std::slice::from_ref(&shared.uid), &[])
|
||||
.unwrap();
|
||||
let snapshot = st.db.visible_children(&virtual_uid).unwrap();
|
||||
let snapshot = st.flushed_db().visible_children(&virtual_uid).unwrap();
|
||||
assert_eq!(snapshot.len(), 1);
|
||||
st.intern_published_share_root(virtual_ino, snapshot[0].clone(), Access::Viewer);
|
||||
|
||||
assert_eq!(
|
||||
st.db.share_access(&shared.uid).unwrap(),
|
||||
st.flushed_db().share_access(&shared.uid).unwrap(),
|
||||
Some(Access::Viewer)
|
||||
);
|
||||
assert_eq!(st.entries[&shared_ino].access, Access::Viewer);
|
||||
assert_eq!(st.entries[&child].access, Access::Viewer);
|
||||
assert_eq!(st.db.pending_ops().unwrap().len(), 1);
|
||||
assert_eq!(st.flushed_db().pending_ops().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1233,12 +1394,12 @@ mod tests {
|
||||
let child_node = node("offline-child", "offline-share", "nested folder", true);
|
||||
let leaf_node = node("offline-leaf", "offline-child", "child.txt", false);
|
||||
for node in [&root_node, &shared_node, &child_node, &leaf_node] {
|
||||
st.db.upsert_node(node).unwrap();
|
||||
st.flushed_db().upsert_node(node).unwrap();
|
||||
}
|
||||
st.db
|
||||
st.flushed_db()
|
||||
.set_share_access(&uid("offline-share"), Access::Viewer)
|
||||
.unwrap();
|
||||
st.share_access = st.db.all_share_access().unwrap();
|
||||
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||
|
||||
// Reconstruct in deliberately non-topological order, matching the
|
||||
// database hydration path that first allocates every inode and only
|
||||
@@ -1296,7 +1457,7 @@ mod tests {
|
||||
/// `ORPHAN_INO`. `nodes[0]` becomes the mount root.
|
||||
fn hydrate_from(st: &mut State, nodes: &[Node]) -> Vec<u64> {
|
||||
for node in nodes {
|
||||
st.db.upsert_node(node).unwrap();
|
||||
st.flushed_db().upsert_node(node).unwrap();
|
||||
}
|
||||
let inos: Vec<u64> = nodes
|
||||
.iter()
|
||||
@@ -1394,10 +1555,10 @@ mod tests {
|
||||
#[test]
|
||||
fn persisted_authority_outranks_the_own_volume_fail_open() {
|
||||
let (mut st, _dir) = state();
|
||||
st.db
|
||||
st.flushed_db()
|
||||
.set_share_access(&uid("device-root"), Access::Viewer)
|
||||
.unwrap();
|
||||
st.share_access = st.db.all_share_access().unwrap();
|
||||
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||
let nodes = [
|
||||
node("root", "none", "My Files", true),
|
||||
node("documents", "device-root", "Documents", true),
|
||||
@@ -1431,7 +1592,7 @@ mod tests {
|
||||
assert_eq!(st.require_writable(ino).unwrap_err().code(), libc::EACCES);
|
||||
}
|
||||
assert_eq!(
|
||||
st.db.share_access(&uid("shared")).unwrap(),
|
||||
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||
Some(Access::Viewer)
|
||||
);
|
||||
}
|
||||
@@ -1441,8 +1602,10 @@ mod tests {
|
||||
for persisted in [Access::Viewer, Access::Editor] {
|
||||
let (mut st, _dir) = state();
|
||||
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
||||
st.db.set_share_access(&uid("shared"), persisted).unwrap();
|
||||
st.share_access = st.db.all_share_access().unwrap();
|
||||
st.flushed_db()
|
||||
.set_share_access(&uid("shared"), persisted)
|
||||
.unwrap();
|
||||
st.share_access = st.flushed_db().all_share_access().unwrap();
|
||||
let shared = st.intern(
|
||||
owned_root,
|
||||
node("shared", "root", "Shared without membership", true),
|
||||
@@ -1454,7 +1617,10 @@ mod tests {
|
||||
|
||||
assert_eq!(st.entries[&shared].access, persisted);
|
||||
assert_eq!(st.entries[&child].access, persisted);
|
||||
assert_eq!(st.db.share_access(&uid("shared")).unwrap(), Some(persisted));
|
||||
assert_eq!(
|
||||
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||
Some(persisted)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1462,7 +1628,7 @@ mod tests {
|
||||
fn explicit_missing_share_role_fails_closed_and_marks_kernel_attrs() {
|
||||
let (mut st, _dir) = state();
|
||||
let owned_root = st.intern(0, node("root", "none", "My Files", true));
|
||||
st.db
|
||||
st.flushed_db()
|
||||
.set_share_access(&uid("shared"), Access::Editor)
|
||||
.unwrap();
|
||||
let shared = st.intern(owned_root, node("shared", "root", "Shared", true));
|
||||
@@ -1474,7 +1640,7 @@ mod tests {
|
||||
assert_eq!(st.entries[&shared].access, Access::Viewer);
|
||||
assert_eq!(st.entries[&child].access, Access::Viewer);
|
||||
assert_eq!(
|
||||
st.db.share_access(&uid("shared")).unwrap(),
|
||||
st.flushed_db().share_access(&uid("shared")).unwrap(),
|
||||
Some(Access::Viewer)
|
||||
);
|
||||
assert!(changed.contains(&shared));
|
||||
@@ -1491,7 +1657,7 @@ mod tests {
|
||||
let virtual_uid = NodeUid::new(VolumeId::from("virtual"), LinkId::from("sharedwithme"));
|
||||
let mut virtual_node = node("sharedwithme", "root", "Shared with me", true);
|
||||
virtual_node.uid = virtual_uid.clone();
|
||||
st.db
|
||||
st.flushed_db()
|
||||
.set_share_access(&virtual_uid, Access::Viewer)
|
||||
.unwrap();
|
||||
st.share_access.insert(virtual_uid.clone(), Access::Viewer);
|
||||
@@ -1558,7 +1724,7 @@ mod tests {
|
||||
let child = st.intern(shared, node("child", "shared", "folder", true));
|
||||
let leaf = st.intern(child, node("leaf", "child", "leaf.txt", false));
|
||||
|
||||
st.db
|
||||
st.flushed_db()
|
||||
.set_share_access(&uid("shared"), Access::Viewer)
|
||||
.unwrap();
|
||||
let changed = st.downgrade_shared_subtree(&uid("shared"));
|
||||
@@ -1572,7 +1738,7 @@ mod tests {
|
||||
assert_eq!(st.entries[&leaf].access, Access::Viewer);
|
||||
assert_eq!(st.require_writable(leaf).unwrap_err().code(), libc::EACCES);
|
||||
assert_eq!(
|
||||
st.db.effective_node_access(&uid("leaf")).unwrap(),
|
||||
st.flushed_db().effective_node_access(&uid("leaf")).unwrap(),
|
||||
Some(Access::Viewer)
|
||||
);
|
||||
|
||||
@@ -1628,7 +1794,7 @@ mod tests {
|
||||
let shared = st.intern(owned_root, shared_node);
|
||||
let shared_child = st.intern(shared, node("child", "shared", "child.txt", false));
|
||||
|
||||
st.db.downgrade_all_share_access().unwrap();
|
||||
st.flushed_db().downgrade_all_share_access().unwrap();
|
||||
let changed = st.downgrade_known_shared_access();
|
||||
|
||||
assert!(changed.contains(&shared));
|
||||
@@ -1652,7 +1818,7 @@ mod tests {
|
||||
let parent_link = st.entries[&parent].uid.link_id.to_string();
|
||||
parent = st.intern(parent, node(&link, &parent_link, &link, true));
|
||||
}
|
||||
st.db.downgrade_all_share_access().unwrap();
|
||||
st.flushed_db().downgrade_all_share_access().unwrap();
|
||||
|
||||
let changed = st.downgrade_known_shared_access();
|
||||
|
||||
|
||||
@@ -138,9 +138,9 @@ impl Core {
|
||||
/// enumerated every node up front and has been doing network I/O since. Before
|
||||
/// touching anything remote this re-reads the node and refuses to act unless
|
||||
/// it is *still* the same revision, still untrashed, still unwritten and still
|
||||
/// unopened. Without that, a user edit landing inside the window is destroyed
|
||||
/// — `discard_queued_ops` below drops the queued write *and its staged blob*,
|
||||
/// which may hold the only copy of those bytes (`docs/BUGS.md` B71).
|
||||
/// unopened (`docs/BUGS.md` B71). Nothing on this path discards a queued write
|
||||
/// or its staged blob: a write that arrives after that check is left for the
|
||||
/// drain to resolve against the now-trashed node.
|
||||
fn remove_conflict_copy(&self, snapshot: &proton_drive_rs::Node, base: &str) {
|
||||
let uid = &snapshot.uid;
|
||||
let name = snapshot.name.as_str();
|
||||
@@ -166,10 +166,13 @@ impl Core {
|
||||
self.for_each_state(|st| {
|
||||
st.forget_or_unlink(uid);
|
||||
});
|
||||
if let Err(error) = self.discard_queued_ops(uid) {
|
||||
warn!(%uid, ?error, "conflict sweep: queued-op cleanup failed");
|
||||
return;
|
||||
}
|
||||
// Deliberately *not* `discard_queued_ops`: `duplicate_still_removable`
|
||||
// proved the queue empty before the trash above, but that proof is stale
|
||||
// the moment `trash_nodes` goes to the network. A `close(2)` landing in
|
||||
// that window stages bytes, and discarding here would unlink the staged
|
||||
// blob holding the only copy of them. Leaving the op queued is the
|
||||
// non-destructive outcome: the drain re-reads the node, sees it trashed,
|
||||
// and `revision_conflict` routes it to `keep_as_conflict_copy`.
|
||||
self.cache.evict(uid);
|
||||
self.evict_reader(uid);
|
||||
if let Err(e) = self.db.delete_node(uid) {
|
||||
@@ -194,8 +197,8 @@ impl Core {
|
||||
let uid = &snapshot.uid;
|
||||
let name = snapshot.name.as_str();
|
||||
|
||||
// A queued op means bytes are still owed an upload. `discard_queued_ops`
|
||||
// would throw those away along with the staged blob holding them.
|
||||
// A queued op means bytes are still owed an upload; trashing the node
|
||||
// under them turns an ordinary upload into a conflict fork.
|
||||
match self.db.has_any_op(&uid.to_string()) {
|
||||
Ok(false) => {}
|
||||
Ok(true) => {
|
||||
|
||||
@@ -171,7 +171,7 @@ impl Core {
|
||||
stats.bytes += size;
|
||||
match self.fetch_node(&uid) {
|
||||
Ok(node) => {
|
||||
let mut st = self.state.lock();
|
||||
let mut st = self.state();
|
||||
let ino = st.intern(parent_ino, node);
|
||||
if let Some(kids) = st.children.get_mut(&parent_ino)
|
||||
&& !kids.contains(&ino)
|
||||
@@ -216,7 +216,7 @@ impl Core {
|
||||
self.ensure_children(pino)
|
||||
.map_err(|e| self.errno_error(e, "enumerate"))?;
|
||||
let existing = {
|
||||
let st = self.state.lock();
|
||||
let st = self.state();
|
||||
st.children.get(&pino).and_then(|kids| {
|
||||
kids.iter().find_map(|ino| {
|
||||
st.entries
|
||||
@@ -243,7 +243,7 @@ impl Core {
|
||||
let node = self
|
||||
.fetch_node(&new_uid)
|
||||
.map_err(|e| self.errno_error(e, "fetch node"))?;
|
||||
let mut st = self.state.lock();
|
||||
let mut st = self.state();
|
||||
let ino = st.intern(pino, node);
|
||||
if let Some(kids) = st.children.get_mut(&pino)
|
||||
&& !kids.contains(&ino)
|
||||
|
||||
@@ -130,7 +130,7 @@ fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||
Hit::Drive(SearchHit {
|
||||
name: file_name(&pin.path),
|
||||
path: pin.path,
|
||||
is_dir: pin.recursive,
|
||||
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
|
||||
@@ -147,29 +147,26 @@ impl Hit {
|
||||
}
|
||||
|
||||
/// The dimmed second line: the containing folder, as the user thinks of it.
|
||||
///
|
||||
/// A Drive hit is only "My files" when it really sits in the primary mount.
|
||||
/// A node inside a device folder resolves to a path on this machine
|
||||
/// (`~/Downloads/…`) — the daemon has already worked that out in
|
||||
/// `mounted_path`, and saying "My files / Downloads" for it names a folder
|
||||
/// the user cannot find and does not match where activating the row opens.
|
||||
fn location(&self) -> String {
|
||||
match self {
|
||||
Hit::Drive(h) => {
|
||||
let parent = parent_of(&h.path);
|
||||
if parent.is_empty() {
|
||||
"My files".to_string()
|
||||
} else {
|
||||
format!("My files / {}", parent.replace('/', " / "))
|
||||
Hit::Drive(h) => match h.mounted_path.as_deref() {
|
||||
Some(mounted) => home_relative(&parent_of(mounted)),
|
||||
None => {
|
||||
let parent = parent_of(&h.path);
|
||||
if parent.is_empty() {
|
||||
"My files".to_string()
|
||||
} else {
|
||||
format!("My files / {}", parent.replace('/', " / "))
|
||||
}
|
||||
}
|
||||
}
|
||||
Hit::Local(h) => {
|
||||
let parent = parent_of(&h.path);
|
||||
match dirs_home().and_then(|home| {
|
||||
Path::new(&parent)
|
||||
.strip_prefix(&home)
|
||||
.ok()
|
||||
.map(|rel| rel.display().to_string())
|
||||
}) {
|
||||
Some(rel) if rel.is_empty() => "Home".to_string(),
|
||||
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
||||
None => parent,
|
||||
}
|
||||
}
|
||||
},
|
||||
Hit::Local(h) => home_relative(&parent_of(&h.path)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -869,7 +866,7 @@ impl Ui {
|
||||
.map(|pin| SearchHit {
|
||||
name: file_name(&pin.path),
|
||||
path: pin.path,
|
||||
is_dir: pin.recursive,
|
||||
is_dir: pin.is_dir.unwrap_or(pin.recursive),
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
@@ -1358,6 +1355,21 @@ fn file_name(path: &str) -> String {
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// An absolute directory path as "Home / …", falling back to the path itself
|
||||
/// for anything outside the home directory.
|
||||
fn home_relative(parent: &str) -> String {
|
||||
match dirs_home().and_then(|home| {
|
||||
Path::new(parent)
|
||||
.strip_prefix(&home)
|
||||
.ok()
|
||||
.map(|rel| rel.display().to_string())
|
||||
}) {
|
||||
Some(rel) if rel.is_empty() => "Home".to_string(),
|
||||
Some(rel) => format!("Home / {}", rel.replace('/', " / ")),
|
||||
None => parent.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn parent_of(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.parent()
|
||||
|
||||
+113
@@ -12,6 +12,119 @@ Conventions:
|
||||
|
||||
---
|
||||
|
||||
## B82 — A `parent_uid` cycle hangs the daemon on any short search
|
||||
|
||||
**Status:** Fixed (unverified) 2026-08-12 — unit-tested, not driven against a
|
||||
real corrupted database (nobody has one).
|
||||
**Found:** 2026-08-12, writing a test for B80. The test built a two-node cycle
|
||||
and called `search`; `cargo test -p pdfs-core` then sat at
|
||||
`search_excludes_a_parent_cycle` for 29 minutes with **zero CPU** before it was
|
||||
killed. Two earlier runs had already been abandoned to the same stall without
|
||||
the cause being understood — it looks exactly like a cargo lock contention
|
||||
problem, which is what it was first blamed on.
|
||||
**Where:** `path_of` (`crates/pdfs-core/src/db/utils.rs`), `upsert_node_tx`'s
|
||||
`descendants` CTE (`db/nodes.rs`).
|
||||
|
||||
**Root cause.** Both walks are recursive CTEs using `UNION ALL`, which does not
|
||||
deduplicate, so `a.parent = b, b.parent = a` never terminates. The index side
|
||||
was never exposed — `node_is_indexable_tx` rejects a cycle — but a query shorter
|
||||
than `TRIGRAM_MIN` (3 chars) takes the `LIKE` lane, and **that lane reads
|
||||
`nodes` directly and never consults the index**. Every row it returns is then
|
||||
handed to `path_of`. So typing two characters into the prompt was enough, and
|
||||
the spin happens while holding the daemon's only SQLite connection: the mount,
|
||||
the drain, and every control request stop with it.
|
||||
|
||||
The API is what would produce such a cycle (a rename/move race, a malformed
|
||||
event), so this is not a "corrupt your own DB" scenario only.
|
||||
|
||||
**Fix.** `path_of` is depth-capped at 256 and returns the truncated path rather
|
||||
than hanging; `path_relative_to` already carried a 1024 cap, which is the
|
||||
precedent. The `descendants` CTE in `upsert_node_tx` got the same guard for the
|
||||
write side. Cycles also stay out of the index (see B80).
|
||||
|
||||
**Still open:** `pin_is_pinned`'s ancestor walk (`db/pins.rs`) has the same
|
||||
shape and no guard. Not reached from search, so it was left alone.
|
||||
|
||||
## B81 — Prompt labels every Drive hit "My files", including device folders
|
||||
|
||||
**Status:** Fixed (unverified) 2026-08-12 — needs a GUI/dmenu run.
|
||||
**Found:** 2026-08-12, reading the prompt while fixing B80: with B80 fixed, a
|
||||
device-folder hit would render as `My files / Downloads`, a folder that does not
|
||||
exist under the primary mount, while activating the same row opens
|
||||
`~/Downloads/…`. Label and action disagreed.
|
||||
**Where:** `Hit::location()` (`crates/pdfs-gui/src/prompt.rs`), shared by the
|
||||
GTK prompt and the dmenu front-end.
|
||||
|
||||
The label was built from `SearchHit::path` with a hardcoded `My files /` prefix.
|
||||
The daemon already resolves a device/sync-folder hit to a real local path in
|
||||
`mounted_path` (`Core::mounted_search_path`), which is also what activation
|
||||
uses; the label now derives from that when it is set, and falls back to
|
||||
`My files` only for the primary mount.
|
||||
|
||||
**Second defect, same area.** Pin rows set `is_dir` from `Pin::recursive`
|
||||
(`prompt.rs`, `dmenu.rs`). That flag is pin *policy* — whether the subtree is
|
||||
kept on disk — not node kind, so a folder pinned non-recursively drew a file
|
||||
icon and sent an invalid `OpenFile`. `Pin` now carries an `is_dir: Option<bool>`
|
||||
resolved from the `nodes` row by `pin_list`, serde-defaulted so an older daemon
|
||||
(which omits it) still parses; front-ends fall back to `recursive` only when the
|
||||
node is not cached.
|
||||
|
||||
## B80 — 29% of the account is absent from the search index
|
||||
|
||||
**Status:** Fixed (unverified) 2026-08-12 — migration replayed against a
|
||||
read-only copy of the live DB; not yet run by the daemon itself.
|
||||
**Found:** 2026-08-12, auditing `cache.db` after a user report that search felt
|
||||
unreliable. `pdfs search tickets` returned only the My Files folder
|
||||
`Documents/Tickets`, never the `tickets.pdf` sitting in the device folder
|
||||
`Downloads`. Counting the tables explained it:
|
||||
|
||||
```
|
||||
nodes 9252
|
||||
nodes_fts 6547 ← 2705 rows never indexed
|
||||
nodes in orphan subtrees 2705 ← exactly the missing set
|
||||
```
|
||||
|
||||
**Where:** `node_is_indexable_tx` (`crates/pdfs-core/src/db/nodes.rs`).
|
||||
|
||||
**Root cause — the same one as B79.** A node was indexed only if walking
|
||||
`parent_uid` reached a row with `parent_uid IS NULL`. Exactly one row is stored
|
||||
that way (the My Files root). A device folder's root lives in `device` /
|
||||
`sync_folder` and is **never persisted as a `nodes` row**, so `Downloads`,
|
||||
`Pictures`, `Documents`, `Music` and `Videos` walk up to a parent that does not
|
||||
exist and were judged unindexable, along with everything beneath them. Stale
|
||||
`pdfs-acceptance-*` roots hit the same path.
|
||||
|
||||
The v16 backfill did *not* share the bug — it rooted its walk at any node whose
|
||||
parent is null **or absent**. So the index was correct immediately after
|
||||
migrating and then lost each subtree the first time one of its nodes was
|
||||
rewritten. That divergence between backfill and write path is why this survived:
|
||||
a fresh install looks fine.
|
||||
|
||||
**Fix.** Indexability now requires only that the walk terminates with nothing
|
||||
trashed on the way; a cycle is still rejected explicitly (B82). `MIGRATION_V21`
|
||||
(`SCHEMA_VERSION` → 21) rebuilds `nodes_fts` so existing installs recover
|
||||
without waiting for each node to be rewritten, and fixes two gaps in the v16
|
||||
walk it replays: descendants of a trashed folder stay out, and the walk is
|
||||
depth-capped.
|
||||
|
||||
Replayed against a `.backup` of the live DB: **9276 non-trashed nodes → 9276
|
||||
indexed** (from 6547), 300 rows under `Downloads`, `tickets.pdf` at
|
||||
`Downloads/tickets.pdf`.
|
||||
|
||||
**Watch for.** That path is the second thing this uncovered: the backfill first
|
||||
seeded an uncached-parent root with an empty path, so the migration indexed
|
||||
`tickets.pdf` while a later upsert indexed `Downloads/tickets.pdf` — the same
|
||||
node with two paths depending on which code wrote it. Any future rebuild of this
|
||||
index has to match `path_of`, not invent its own root rule.
|
||||
|
||||
**Related, not a defect.** The local half of the prompt indexes 110,187 files,
|
||||
63,399 of them (58%) the Go module cache. Candidates are capped at 1000 and
|
||||
scored *after* SQLite, so for a common term the pool filled with vendored files
|
||||
before a real one was considered — for "test", 367 of the 500 best-ranked
|
||||
candidates came from `go/pkg/mod`. `SKIP_DIRS` gained `dist`/`build`, a new
|
||||
`SKIP_PATH_SUFFIXES` covers `go/pkg/mod`, and both candidate functions gained a
|
||||
whole-query prefix lane ahead of the existing single-character one.
|
||||
|
||||
## B79 — Every write in an on-demand device-folder mount fails EACCES
|
||||
|
||||
**Status:** Fixed (verified live 2026-07-31, running in a locally rebuilt 1.2.1
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
# Search index + prompt display fixes
|
||||
|
||||
Working plan. Findings come from an audit of a real `cache.db`
|
||||
(9,252 Drive nodes, 110,187 local files) on 2026-08-12.
|
||||
|
||||
## Evidence
|
||||
|
||||
```
|
||||
nodes 9252
|
||||
nodes_fts 6547 ← 2705 rows never indexed
|
||||
nodes in orphan subtrees 2705 ← exactly the missing set
|
||||
local_files 110187
|
||||
/home/narl/go/pkg/mod 63399 (58% of the local index)
|
||||
query "test" 46405 local matches, candidate pool capped at 1000
|
||||
top 500 by fts rank 367 (73%) from go/pkg/mod
|
||||
```
|
||||
|
||||
`pdfs search tickets` returns only `Documents/Tickets` (My Files); the
|
||||
`tickets.pdf` sitting in the device folder `Downloads` is invisible.
|
||||
|
||||
## P1 — 29% of Drive is unsearchable
|
||||
|
||||
`node_is_indexable_tx` (`crates/pdfs-core/src/db/nodes.rs:606`) indexes a node
|
||||
only when the ancestor walk terminates at a row with `parent_uid IS NULL`. The
|
||||
DB holds exactly one such row (the My Files root). Device roots live in
|
||||
`device`/`sync_folder`, never in `nodes`, so every device-folder subtree
|
||||
(`Downloads`, `Pictures`, `Documents`, `Music`, `Videos` under device
|
||||
*nthinkpad*) walks up to a `parent_uid` with no row and is judged unindexable.
|
||||
Stale `pdfs-acceptance-*` roots hit the same path.
|
||||
|
||||
- [x] Relax the rule to "the walk terminates and no ancestor is trashed",
|
||||
keeping the existing cycle guard. A subtree whose top row's parent is
|
||||
simply not cached is still real, reachable data.
|
||||
- [x] `MIGRATION_V21`: rebuild `nodes_fts` under the new rule, so existing
|
||||
installs recover the missing rows instead of waiting for each node to be
|
||||
upserted again.
|
||||
- [x] Tests: a node under an uncached parent is indexed; a trashed ancestor
|
||||
still excludes; the migration backfills.
|
||||
|
||||
### Found while testing: a parent cycle hangs the daemon
|
||||
|
||||
Writing the cycle test surfaced a live defect, not a test artefact. A query
|
||||
below the trigram minimum takes the `LIKE` lane, which reads `nodes` directly
|
||||
and never consults the index, and every row it returns goes through `path_of` —
|
||||
whose parent walk is an unguarded `UNION ALL`. On a `parent_uid` cycle it never
|
||||
terminates, while holding the daemon's only SQLite connection.
|
||||
|
||||
- [x] Depth-cap `path_of` (`db/utils.rs`), matching the 1024 cap
|
||||
`path_relative_to` already carried.
|
||||
- [x] Depth-cap the `descendants` CTE in `upsert_node_tx`, which has the same
|
||||
exposure on the write side.
|
||||
- [x] Keep cycles out of the index (part of the P1 rule above).
|
||||
|
||||
Still unguarded, not touched here: `pin_is_pinned`'s ancestor walk.
|
||||
|
||||
## P2 — local index noise crowds real files out of the candidate pool
|
||||
|
||||
`SKIP_DIRS` (`crates/pdfs-core/src/localindex.rs:23`) misses the Go module
|
||||
cache and build output dirs. `search_v2` clamps candidates to ≤1000 and scores
|
||||
*after* SQLite, so for any common term the pool is exhausted by vendored files
|
||||
before a real one is considered.
|
||||
|
||||
- [x] Skip `go/pkg/mod` (path-relative, not a bare name), `dist`, `build`,
|
||||
`.cache`-style artefact dirs.
|
||||
- [x] Give `search_local_candidates` a name-prefix lane, mirroring what
|
||||
`search_candidates` already does for Drive, so an exact-prefix match
|
||||
cannot be starved by trigram rank.
|
||||
- [x] Tests for both.
|
||||
|
||||
## P3 — display lies about where a hit lives
|
||||
|
||||
- [x] `Hit::location()` (`crates/pdfs-gui/src/prompt.rs:150`) hardcodes
|
||||
`My files / …` for every Drive hit. Derive the label from
|
||||
`mounted_path` when the daemon resolved one (device folder, sync folder)
|
||||
and fall back to `My files` only for the primary mount.
|
||||
- [x] Pin rows set `is_dir: pin.recursive` (`dmenu.rs:133`, `prompt.rs:872`) —
|
||||
a non-recursively pinned folder renders and activates as a file.
|
||||
- [ ] `pdfs locations` prints `Device folder 1..5` rather than the device and
|
||||
folder name. (Cosmetic; deferred.)
|
||||
|
||||
## Verification
|
||||
|
||||
Done, 2026-08-12:
|
||||
|
||||
```
|
||||
cargo fmt --all -- --check clean
|
||||
cargo clippy --workspace --all-targets -- -D warnings clean
|
||||
cargo test --workspace --locked 439 passed, 0 failed
|
||||
```
|
||||
|
||||
The V21 backfill was also replayed against a read-only `.backup` of the live
|
||||
`cache.db`:
|
||||
|
||||
```
|
||||
live_nodes 9276
|
||||
indexed 9276 (was 6547)
|
||||
downloads_subtree 300
|
||||
tickets.pdf -> Downloads/tickets.pdf
|
||||
```
|
||||
|
||||
That run is what caught the last defect: the backfill originally seeded an
|
||||
uncached-parent root with an empty path, so a node indexed by the migration
|
||||
(`tickets.pdf`) and the same node after a later upsert (`Downloads/tickets.pdf`)
|
||||
disagreed. The seed now keeps the name for any row that has a parent, matching
|
||||
`path_of`.
|
||||
|
||||
Still to do, on the live system: rebuild, restart `proton-drive.service`, and
|
||||
confirm `pdfs search tickets` returns the device folder's `tickets.pdf`. The
|
||||
schema move to 21 is one-way — an older `pdfs` refuses a v21 database. The
|
||||
`local_files` shrink (~110k → ~47k) lands with the next background scan.
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# Build + install: cd packaging && makepkg -fi
|
||||
|
||||
pkgname=proton-drive-linux
|
||||
pkgver=1.5.0
|
||||
pkgver=1.7.0
|
||||
pkgrel=1
|
||||
pkgdesc="Proton Drive client for Linux: FUSE files-on-demand mount, CLI, GTK4 app + tray"
|
||||
arch=('x86_64')
|
||||
|
||||
Reference in new issue
Block a user