Give a shard's remote name the client that wrote it
Build and test / Desktop (Linux) (push) Failing after 50s
Build and test / Layer separation (push) Successful in 24s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 9m39s
Build and test / Desktop (Linux) (push) Failing after 50s
Build and test / Layer separation (push) Successful in 24s
🐳 Android image / Build and push (push) Successful in 3s
Build and test / android-image (push) Successful in 3s
Traceability / Requirement traces (push) Failing after 59s
Build and test / Android (aarch64) (push) Failing after 9m39s
Shard ids are per store: every client fills its own numbering from 0, so "shard 3" names different thumbnails on every device. The derived sync published them into a flat shard-NNNN.sqlite namespace anyway, which left two clients writing one name. Both failures that follow were live. On upload, a client's open shard overwrote a peer's file of the same id — content the peer still believed was published and would never restore, because its own copy was sealed and the name existed. On download, the loop skipped any remote id it already held locally, which is the only safe reading of a name that says nothing about who wrote it, so a client holding shards 0..5 never fetched the peer's 0..5 at all. Between them, two populated clients exchanged almost nothing: only shards numbered above the other's highest. A fresh device worked, having no local shards to collide with, which is why this went unnoticed — it is exactly the case the feature was written for. The name is now shard-<client>-NNNN.sqlite. The client id is minted per store in index.sqlite, beside the numbering it qualifies rather than in settings: a store deleted and rebuilt restarts at shard 0 and must not claim the remote names its predecessor wrote. Since our own ids now say nothing about what we have taken from others, index.sqlite also keeps a ledger of adopted remote names and the size each had when merged. A size rather than a flag, because a peer's sealed shard never returns but its open one grows, and re-merging the grown copy is how the thumbnails it gained since arrive. Flat names already on servers still parse, reporting no owner, so each client adopts them once, and nothing is written under that form again. One whose id and byte size match a local shard is that client's own earlier upload by the same identity argument the upload path already makes for sealed shards, so the rename does not cost every client a re-download of its whole store. Older builds ignore the new names and stop receiving shards until updated; their own uploads are still adopted, so nothing is lost. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -158,6 +158,18 @@ fn derived_path(root: &str) -> RemotePath {
|
||||
/// Upload what the server lacks, download what we lack. Sealed shards are
|
||||
/// immutable, so a name match is a content match and nothing needs comparing
|
||||
/// beyond existence — which is what keeps a steady-state sync to one listing.
|
||||
///
|
||||
/// # Why the name carries a client
|
||||
///
|
||||
/// Shard ids are per-store: every client fills its own numbering from 0, so
|
||||
/// "shard 3" names different thumbnails on each device. A flat `shard-0003`
|
||||
/// remote namespace therefore has two clients writing one name — the second
|
||||
/// upload overwrites content the first still believes is published — and
|
||||
/// leaves a client no way to tell a peer's shard 3 from its own, so the only
|
||||
/// safe reading of "I already have 3" is to skip it and never adopt anything.
|
||||
/// Qualifying the name with [`ThumbStore::client_id`] gives each store its own
|
||||
/// namespace, and [`ThumbStore::adopted`] then tracks what has been merged by
|
||||
/// remote name instead of by our own ids.
|
||||
async fn sync_shards(
|
||||
backend: &NextcloudBackend,
|
||||
base: &RemotePath,
|
||||
@@ -168,12 +180,14 @@ async fn sync_shards(
|
||||
let store = match ThumbStore::open(thumbs_dir) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
// No local store is not a failure: a fresh device has nothing to
|
||||
// upload and everything to gain from downloading.
|
||||
// A store that will not open can be neither read nor merged into,
|
||||
// so there is no half of this worth attempting. It is not a sync
|
||||
// failure: the next pass retries once the store is openable.
|
||||
log::debug!("thumbnail store unavailable: {e}");
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
let client = store.client_id().to_string();
|
||||
|
||||
let remote: std::collections::HashMap<String, u64> = backend
|
||||
.list(base, None)
|
||||
@@ -197,10 +211,11 @@ async fn sync_shards(
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let name = shard_name(shard.id);
|
||||
let name = shard_name(&client, shard.id);
|
||||
|
||||
// A sealed shard the server already has is byte-identical by
|
||||
// construction, so its presence is proof enough. The open shard is
|
||||
// construction, so its presence is proof enough — and with the client
|
||||
// in the name, no one else can have written it. The open shard is
|
||||
// re-uploaded whenever its size differs, which is the only way it
|
||||
// changes.
|
||||
let skip = match remote.get(&name) {
|
||||
@@ -222,12 +237,23 @@ async fn sync_shards(
|
||||
}
|
||||
|
||||
// ---- download --------------------------------------------------------
|
||||
let have: std::collections::HashSet<u32> = local.iter().map(|s| s.id).collect();
|
||||
let mut store = store;
|
||||
|
||||
for name in remote.keys() {
|
||||
let Some(id) = shard_id(name) else { continue };
|
||||
if have.contains(&id) {
|
||||
for (name, size) in &remote {
|
||||
let Some((owner, id)) = parse_shard(name) else {
|
||||
continue;
|
||||
};
|
||||
if owner == client {
|
||||
continue;
|
||||
}
|
||||
// Merged already, at the size it still has. A sealed shard never
|
||||
// reaches here twice; a peer's open one does each time it grows, which
|
||||
// is what carries its later thumbnails across.
|
||||
if store.adopted(name) == Some(*size) {
|
||||
continue;
|
||||
}
|
||||
if owner.is_empty() && legacy_upload_of_ours(&store, id, *size) {
|
||||
let _ = store.record_adopted(name, *size);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -251,6 +277,9 @@ async fn sync_shards(
|
||||
Ok(n) => {
|
||||
report.shards_downloaded += 1;
|
||||
report.thumbnails_adopted += n;
|
||||
// Recorded only on success, so a failed merge is retried next
|
||||
// pass rather than written off.
|
||||
let _ = store.record_adopted(name, *size);
|
||||
}
|
||||
Err(e) => log::warn!("merging {name}: {e}"),
|
||||
}
|
||||
@@ -260,6 +289,23 @@ async fn sync_shards(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Whether a flat-named remote shard is this client's own earlier upload.
|
||||
///
|
||||
/// Before the name carried a client every client wrote `shard-NNNN.sqlite`, so
|
||||
/// the folder still holds files with nothing in the name to say whose they
|
||||
/// are. A local shard of the same id and the same size is ours by
|
||||
/// construction — the same identity argument the upload path makes for
|
||||
/// skipping a sealed shard the server already has — and skipping those is what
|
||||
/// keeps the rename from costing every client a re-download of its whole
|
||||
/// store. Being wrong costs a peer's shard going unmerged and its thumbnails
|
||||
/// being derived locally instead; it loses nothing, and two independently
|
||||
/// filled 25 MB databases landing on the same byte count is not a real case.
|
||||
fn legacy_upload_of_ours(store: &ThumbStore, id: u32, remote_size: u64) -> bool {
|
||||
std::fs::metadata(store.shard_path(id))
|
||||
.map(|m| m.len() == remote_size)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Exchange the catalog, for its collections.
|
||||
///
|
||||
/// Only collections merge — see [`dr_catalog::sync`]. The rest of a catalog
|
||||
@@ -318,19 +364,26 @@ async fn sync_catalog(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn shard_name(id: u32) -> String {
|
||||
format!("shard-{id:04}.sqlite")
|
||||
fn shard_name(client: &str, id: u32) -> String {
|
||||
format!("shard-{client}-{id:04}.sqlite")
|
||||
}
|
||||
|
||||
/// The shard id in a filename, or `None` if it is not a shard.
|
||||
/// The client that wrote a remote shard and its id in that client's numbering,
|
||||
/// or `None` if the name is not a shard.
|
||||
///
|
||||
/// Also accepts the flat `shard-NNNN.sqlite` written before names carried a
|
||||
/// client, reporting an empty owner: those belong to nobody identifiable, so
|
||||
/// they read as foreign and are adopted once like any peer's. Nothing is ever
|
||||
/// uploaded under that form again.
|
||||
///
|
||||
/// Guards the download loop against adopting the catalog, a stray file, or
|
||||
/// anything else the folder happens to contain.
|
||||
fn shard_id(name: &str) -> Option<u32> {
|
||||
name.strip_prefix("shard-")?
|
||||
.strip_suffix(".sqlite")?
|
||||
.parse()
|
||||
.ok()
|
||||
fn parse_shard(name: &str) -> Option<(&str, u32)> {
|
||||
let stem = name.strip_prefix("shard-")?.strip_suffix(".sqlite")?;
|
||||
match stem.rsplit_once('-') {
|
||||
Some((client, id)) => Some((client, id.parse().ok()?)),
|
||||
None => Some(("", stem.parse().ok()?)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -351,20 +404,42 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn shard_names_round_trip() {
|
||||
assert_eq!(shard_name(0), "shard-0000.sqlite");
|
||||
assert_eq!(shard_name(42), "shard-0042.sqlite");
|
||||
assert_eq!(shard_id("shard-0042.sqlite"), Some(42));
|
||||
assert_eq!(shard_id(&shard_name(7)), Some(7));
|
||||
assert_eq!(shard_name("a1b2c3d4e5f6", 0), "shard-a1b2c3d4e5f6-0000.sqlite");
|
||||
assert_eq!(shard_name("a1b2c3d4e5f6", 42), "shard-a1b2c3d4e5f6-0042.sqlite");
|
||||
assert_eq!(
|
||||
parse_shard(&shard_name("a1b2c3d4e5f6", 7)),
|
||||
Some(("a1b2c3d4e5f6", 7))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_clients_shard_three_are_different_files() {
|
||||
// The whole point: one client's numbering must not name another's
|
||||
// shard, or the second upload overwrites the first's content and
|
||||
// neither can tell the other's shards from its own.
|
||||
assert_ne!(shard_name("aaaa", 3), shard_name("bbbb", 3));
|
||||
assert_eq!(parse_shard(&shard_name("aaaa", 3)).unwrap().0, "aaaa");
|
||||
assert_eq!(parse_shard(&shard_name("bbbb", 3)).unwrap().0, "bbbb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_names_read_as_belonging_to_nobody() {
|
||||
// Written before the name carried a client. They must still parse, so
|
||||
// a library synced by an older build is not stranded, and they must
|
||||
// not match any live client id, so they are never mistaken for ours.
|
||||
assert_eq!(parse_shard("shard-0042.sqlite"), Some(("", 42)));
|
||||
assert_ne!(parse_shard("shard-0042.sqlite").unwrap().0, "a1b2c3d4e5f6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_shard_files_are_not_adopted() {
|
||||
// The folder also holds the catalog; downloading it as a shard would
|
||||
// hand a catalog to the thumbnail merger.
|
||||
assert_eq!(shard_id("catalog.sqlite"), None);
|
||||
assert_eq!(shard_id("shard-0000.sqlite-wal"), None);
|
||||
assert_eq!(shard_id("notes.txt"), None);
|
||||
assert_eq!(shard_id("shard-abc.sqlite"), None);
|
||||
assert_eq!(parse_shard("catalog.sqlite"), None);
|
||||
assert_eq!(parse_shard("shard-0000.sqlite-wal"), None);
|
||||
assert_eq!(parse_shard("notes.txt"), None);
|
||||
assert_eq!(parse_shard("shard-abc.sqlite"), None);
|
||||
assert_eq!(parse_shard("shard-a1b2c3-notanid.sqlite"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user