mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
release(1.8.1)
This commit is contained in:
1 parent
438b2b463e
commit
78dc72a34d
23 files changed
+1663
-177
No files matched your search
@@ -84,7 +84,11 @@ 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 {
|
||||
///
|
||||
/// Public because it is the *contract* a block has to satisfy, not a detail of
|
||||
/// this cache: the network read path validates against it too, so a block that
|
||||
/// comes back short is refused before it can truncate a file (bugs.md B84).
|
||||
pub fn block_len(size: u64, idx: u64) -> u64 {
|
||||
let start = idx.saturating_mul(BLOCK_SIZE);
|
||||
size.saturating_sub(start).min(BLOCK_SIZE)
|
||||
}
|
||||
@@ -651,6 +655,12 @@ impl ContentCache {
|
||||
/// Store `bytes` as cached block `idx` of `uid`, tagged `(mtime, size)`.
|
||||
/// Temp-file-then-rename like [`store`](Self::store); meta written last so a
|
||||
/// crash mid-store fails validation. Enforces the block-cache LRU budget.
|
||||
///
|
||||
/// The caller owes it a block of exactly [`block_len`] bytes; anything else
|
||||
/// reads back as a miss ([`cached_block`](Self::cached_block) checks), so a
|
||||
/// bad block cannot escape this cache even though this does not check.
|
||||
/// Validating a block is the *reader's* job, at the point it is fetched —
|
||||
/// see `Core::read_block` (bugs.md B84).
|
||||
pub fn store_block(
|
||||
&self,
|
||||
uid: &NodeUid,
|
||||
@@ -2248,6 +2258,34 @@ mod tests {
|
||||
assert!(c.cached_block(&u, 100, 5000, 0).is_none());
|
||||
}
|
||||
|
||||
/// The geometry the whole read path validates against, here and over the
|
||||
/// network (bugs.md B84): full blocks, then whatever is left.
|
||||
#[test]
|
||||
fn block_len_is_full_blocks_then_the_remainder() {
|
||||
let size = BLOCK_SIZE * 2 + 17;
|
||||
assert_eq!(block_len(size, 0), BLOCK_SIZE);
|
||||
assert_eq!(block_len(size, 1), BLOCK_SIZE);
|
||||
assert_eq!(block_len(size, 2), 17);
|
||||
// Past the end there is no block, not a short one.
|
||||
assert_eq!(block_len(size, 3), 0);
|
||||
// An exact multiple has no short tail at all.
|
||||
assert_eq!(block_len(BLOCK_SIZE, 0), BLOCK_SIZE);
|
||||
assert_eq!(block_len(BLOCK_SIZE, 1), 0);
|
||||
}
|
||||
|
||||
/// A block of the wrong length cannot escape the cache even though the
|
||||
/// store does not check: the read validates it and reports a miss, so the
|
||||
/// next read refetches rather than serving a hole (bugs.md B84).
|
||||
#[test]
|
||||
fn a_block_of_the_wrong_length_reads_as_a_miss() {
|
||||
let (c, _d) = cache();
|
||||
let u = uid("a");
|
||||
// A 10-byte file's only block is 10 bytes; 5 is not a short tail.
|
||||
c.store_block(&u, 100, 10, 0, b"block").unwrap();
|
||||
assert!(c.cached_block(&u, 100, 10, 0).is_none());
|
||||
assert!(!c.has_block(&u, 100, 10, 0));
|
||||
}
|
||||
|
||||
/// The block cache is written without an fsync, so a crash can leave a blob
|
||||
/// shorter than the sidecar beside it claims. Serving that as content would
|
||||
/// hand the reader a hole; it has to read as a miss.
|
||||
|
||||
@@ -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 = 27;
|
||||
pub(super) const SCHEMA_VERSION: i64 = 28;
|
||||
|
||||
impl Db {
|
||||
pub(super) fn migrate(&self) -> Result<()> {
|
||||
@@ -201,6 +201,23 @@ impl Db {
|
||||
tx.execute_batch(MIGRATION_V27)?;
|
||||
}
|
||||
}
|
||||
if current < 28 {
|
||||
// Same guards as V26/V27, for the same two reasons.
|
||||
let has_column: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM pragma_table_info('sync_entry') \
|
||||
WHERE name = 'local_mtime_ns'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
let has_entries: bool = tx.query_row(
|
||||
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name = 'sync_entry'",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0),
|
||||
)? > 0;
|
||||
if has_entries && !has_column {
|
||||
tx.execute_batch(MIGRATION_V28)?;
|
||||
}
|
||||
}
|
||||
tx.execute(
|
||||
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
||||
@@ -808,3 +825,24 @@ CREATE INDEX IF NOT EXISTS idx_pending_op_claimed
|
||||
const MIGRATION_V27: &str = "
|
||||
ALTER TABLE pending_op ADD COLUMN access_deferred_since INTEGER NOT NULL DEFAULT 0;
|
||||
";
|
||||
|
||||
/// Schema v28: sub-second precision for a mirror folder's local baseline.
|
||||
///
|
||||
/// A reconcile pass decides whether the local side of a path changed by
|
||||
/// comparing `(local_mtime, local_size)` against the baseline. Both sides of
|
||||
/// that comparison were whole seconds, so an edit that landed in the same second
|
||||
/// as the last sync and left the file the same length — a fixed-size record
|
||||
/// rewritten, an image re-exported, a database page updated in place — read as
|
||||
/// *unchanged* and was never uploaded. The remote copy silently stayed at the
|
||||
/// older content until something else about the file moved (bugs.md B25).
|
||||
///
|
||||
/// Nullable rather than a converted `local_mtime * 1_000_000_000`: the
|
||||
/// nanosecond part of an existing baseline is genuinely unknown, and inventing
|
||||
/// zero for it would make every already-synced file compare as changed and
|
||||
/// re-upload an entire mirror on first start. `NULL` means "this row predates
|
||||
/// the column", which the comparison reads as "fall back to whole seconds" —
|
||||
/// exactly the behaviour that row was written under. Each row gains a real value
|
||||
/// the next time its path is synced.
|
||||
const MIGRATION_V28: &str = "
|
||||
ALTER TABLE sync_entry ADD COLUMN local_mtime_ns INTEGER;
|
||||
";
|
||||
@@ -35,6 +35,14 @@ pub struct StoredSyncEntry {
|
||||
pub remote_uid: Option<String>,
|
||||
pub local_mtime: i64,
|
||||
pub local_size: i64,
|
||||
/// The same modification time to nanosecond precision, when it is known.
|
||||
///
|
||||
/// `local_mtime` alone cannot tell an edit that landed inside the same
|
||||
/// second as the last sync — and left the file the same length — from no
|
||||
/// edit at all, so those never uploaded (bugs.md B25). `None` on a row
|
||||
/// written before schema 28, which the comparison reads as "compare whole
|
||||
/// seconds", so upgrading does not make an entire mirror look changed.
|
||||
pub local_mtime_ns: Option<i64>,
|
||||
pub remote_rev: Option<String>,
|
||||
pub remote_hash: Option<String>,
|
||||
}
|
||||
@@ -168,7 +176,8 @@ impl Db {
|
||||
pub fn sync_entries(&self, folder_id: i64) -> Result<HashMap<String, StoredSyncEntry>> {
|
||||
let conn = self.read();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash
|
||||
"SELECT rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash,
|
||||
local_mtime_ns
|
||||
FROM sync_entry WHERE folder_id = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![folder_id], |r| {
|
||||
@@ -179,6 +188,7 @@ impl Db {
|
||||
local_size: r.get(3)?,
|
||||
remote_rev: r.get(4)?,
|
||||
remote_hash: r.get(5)?,
|
||||
local_mtime_ns: r.get(6)?,
|
||||
})
|
||||
})?;
|
||||
let mut map = HashMap::new();
|
||||
@@ -216,14 +226,16 @@ impl Db {
|
||||
let conn = self.conn.lock();
|
||||
conn.execute(
|
||||
"INSERT INTO sync_entry
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev, remote_hash)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
(folder_id, rel_path, remote_uid, local_mtime, local_size, remote_rev,
|
||||
remote_hash, local_mtime_ns)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
|
||||
ON CONFLICT(folder_id, rel_path) DO UPDATE SET
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash",
|
||||
remote_uid = excluded.remote_uid,
|
||||
local_mtime = excluded.local_mtime,
|
||||
local_size = excluded.local_size,
|
||||
remote_rev = excluded.remote_rev,
|
||||
remote_hash = excluded.remote_hash,
|
||||
local_mtime_ns = excluded.local_mtime_ns",
|
||||
params![
|
||||
folder_id,
|
||||
e.rel_path,
|
||||
@@ -232,6 +244,7 @@ impl Db {
|
||||
e.local_size,
|
||||
e.remote_rev,
|
||||
e.remote_hash,
|
||||
e.local_mtime_ns,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
|
||||
@@ -2408,6 +2408,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
||||
local_size: 2,
|
||||
remote_rev: Some("3".into()),
|
||||
remote_hash: Some("2".into()),
|
||||
local_mtime_ns: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -2424,6 +2425,7 @@ fn mirror_uid_lookup_covers_the_root_and_synced_descendants_only() {
|
||||
local_size: 2,
|
||||
remote_rev: Some("3".into()),
|
||||
remote_hash: Some("2".into()),
|
||||
local_mtime_ns: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
@@ -3545,6 +3547,78 @@ fn migration_v27_adds_the_access_deferral_column_to_a_v26_queue() {
|
||||
let _ = std::fs::remove_file(format!("{}.lock", path.display()));
|
||||
}
|
||||
|
||||
/// A mirror baseline carried over from V27 keeps its rows, and each one reads
|
||||
/// back with no nanosecond time. That `None` is the whole point: it makes the
|
||||
/// comparison fall back to whole seconds for rows written before the column
|
||||
/// existed, instead of asserting a sub-second time of zero and re-uploading
|
||||
/// every already-synced file in the folder (bugs.md B25).
|
||||
#[test]
|
||||
fn migration_v28_adds_sub_second_local_times_without_disturbing_a_v27_baseline() {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"pdfs-db-v27-fixture-{}-{}.db",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
{
|
||||
let db = Db::open(&path).unwrap();
|
||||
let folder = db
|
||||
.sync_folder_add("/home/me/Mirror", "vol~root", "share")
|
||||
.unwrap();
|
||||
db.sync_entry_upsert(
|
||||
folder,
|
||||
&StoredSyncEntry {
|
||||
rel_path: "notes.txt".into(),
|
||||
remote_uid: Some("vol~notes".into()),
|
||||
local_mtime: 1_700_000_000,
|
||||
local_mtime_ns: Some(1_700_000_000_123_456_789),
|
||||
local_size: 42,
|
||||
remote_rev: Some("1700000000".into()),
|
||||
remote_hash: Some("42".into()),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
{
|
||||
// Put the file back in the state a released V27 database was in.
|
||||
let conn = rusqlite::Connection::open(&path).unwrap();
|
||||
conn.execute_batch(
|
||||
"ALTER TABLE sync_entry DROP COLUMN local_mtime_ns;
|
||||
UPDATE sync_state SET value = '27' WHERE key = 'schema_version';",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let db = Db::open(&path).unwrap();
|
||||
let entries = db.sync_entries(1).unwrap();
|
||||
let entry = entries.get("notes.txt").expect("the baseline row survives");
|
||||
assert_eq!(entry.local_mtime, 1_700_000_000);
|
||||
assert_eq!(entry.local_size, 42);
|
||||
assert_eq!(
|
||||
entry.local_mtime_ns, None,
|
||||
"a carried-over row must not claim a sub-second time it never recorded"
|
||||
);
|
||||
|
||||
// And the column is live: a refreshed row keeps the precision it is given.
|
||||
db.sync_entry_upsert(
|
||||
1,
|
||||
&StoredSyncEntry {
|
||||
local_mtime_ns: Some(1_700_000_001_000_000_500),
|
||||
..entry.clone()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
db.sync_entries(1).unwrap()["notes.txt"].local_mtime_ns,
|
||||
Some(1_700_000_001_000_000_500)
|
||||
);
|
||||
|
||||
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.
|
||||
|
||||
Reference in new issue
Block a user