Skip to content

Commit af63146

Browse files
committed
fix(emule): allow identical content at multiple paths in the seed index
Twin of the shared_files fix, for emule_shared_files.ed2k_hash. It was UNIQUE with `upsert ... ON CONFLICT(ed2k_hash) DO UPDATE SET path=...`, so duplicate-content files didn't error but ping-ponged: the single row kept getting repointed between the copies, and since the backfill picks candidates by path, the two paths re-hashed (MD4) each other's slot every sweep. Key the seed index by path too: - Schema: drop UNIQUE on ed2k_hash, add UNIQUE on path, index ed2k_hash. - upsert conflicts on path (one row per file; re-seeding a path refreshes its hash). - get_by_hash returns the oldest matching row deterministically. - Replace delete_by_hash (removed all copies) with delete_by_path, which reports whether it was the last copy so callers drop the content from the upload whitelist only when nothing seeds it anymore. - The source republisher publishes each hash once per round.
1 parent b128dc4 commit af63146

3 files changed

Lines changed: 86 additions & 16 deletions

File tree

rucio-daemon/src/db/emule_shared_files.rs

Lines changed: 59 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,14 @@ pub async fn upsert(
4242
hashset: &[u8],
4343
now: u64,
4444
) -> Result<()> {
45+
// Keyed by path (one row per file): identical content at two paths gets two
46+
// rows, both seeded. Re-seeding the same path refreshes it (the content there
47+
// may have changed, so ed2k_hash is updated too).
4548
sqlx::query(
4649
"INSERT INTO emule_shared_files (ed2k_hash, name, size, path, mtime, hashset, added_at) \
4750
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) \
48-
ON CONFLICT(ed2k_hash) DO UPDATE SET \
49-
name = excluded.name, size = excluded.size, path = excluded.path, \
51+
ON CONFLICT(path) DO UPDATE SET \
52+
ed2k_hash = excluded.ed2k_hash, name = excluded.name, size = excluded.size, \
5053
mtime = excluded.mtime, hashset = excluded.hashset",
5154
)
5255
.bind(ed2k_hash.as_slice())
@@ -132,20 +135,40 @@ pub async fn rename_path(db: &Db, old_path: &str, new_path: &str, new_name: &str
132135
Ok(affected > 0)
133136
}
134137

135-
/// Remove a shared file by its ed2k hash. Returns `true` if a row was deleted.
136-
pub async fn delete_by_hash(db: &Db, ed2k_hash: &[u8]) -> Result<bool> {
137-
let res = sqlx::query("DELETE FROM emule_shared_files WHERE ed2k_hash = ?1")
138-
.bind(ed2k_hash)
138+
/// Stop seeding the file at exactly `path`. Returns `true` when that was the
139+
/// **last** path holding the content (no other row shares its ed2k hash), so the
140+
/// caller also drops it from the upload whitelist / lets its Kad source expire —
141+
/// a duplicate copy elsewhere keeps it seeded.
142+
pub async fn delete_by_path(db: &Db, path: &str) -> Result<bool> {
143+
let row = sqlx::query("SELECT ed2k_hash FROM emule_shared_files WHERE path = ?1")
144+
.bind(path)
145+
.fetch_optional(db)
146+
.await?;
147+
148+
let Some(hash) = row.map(|r| r.get::<Vec<u8>, _>("ed2k_hash")) else {
149+
return Ok(false);
150+
};
151+
152+
sqlx::query("DELETE FROM emule_shared_files WHERE path = ?1")
153+
.bind(path)
139154
.execute(db)
140155
.await?;
141-
Ok(res.rows_affected() > 0)
156+
157+
let remaining: i64 =
158+
sqlx::query_scalar("SELECT COUNT(*) FROM emule_shared_files WHERE ed2k_hash = ?1")
159+
.bind(hash.as_slice())
160+
.fetch_one(db)
161+
.await?;
162+
Ok(remaining == 0)
142163
}
143164

144165
/// Look up a shared file by its ed2k hash — used to warn the user that content
145-
/// they're about to download is already present (and where).
166+
/// they're about to download is already present (and where). Content can be
167+
/// seeded from several paths; returns the oldest row deterministically.
146168
pub async fn get_by_hash(db: &Db, ed2k_hash: &[u8]) -> Result<Option<EmuleSharedFile>> {
147169
let row = sqlx::query(
148-
"SELECT ed2k_hash, name, size, path, mtime, hashset FROM emule_shared_files WHERE ed2k_hash = ?1",
170+
"SELECT ed2k_hash, name, size, path, mtime, hashset FROM emule_shared_files \
171+
WHERE ed2k_hash = ?1 ORDER BY id ASC LIMIT 1",
149172
)
150173
.bind(ed2k_hash)
151174
.fetch_optional(db)
@@ -320,4 +343,31 @@ mod tests {
320343
assert_eq!(cands.len(), 1);
321344
assert_eq!(cands[0].path, "/tmp/b.bin");
322345
}
346+
347+
#[tokio::test]
348+
async fn duplicate_content_at_two_paths_seeds_once() {
349+
let (db, _dir) = test_db().await;
350+
let hash = [5u8; 16];
351+
// Same content, two paths: both seed (no UNIQUE(ed2k_hash) collision, no
352+
// path ping-pong).
353+
upsert(&db, &hash, "dup", 10, "/dl/a", 1, b"", 1)
354+
.await
355+
.unwrap();
356+
upsert(&db, &hash, "dup", 10, "/dl/b", 1, b"", 2)
357+
.await
358+
.unwrap();
359+
assert_eq!(list(&db).await.unwrap().len(), 2);
360+
// get_by_hash resolves deterministically to the oldest row.
361+
assert_eq!(
362+
get_by_hash(&db, &hash).await.unwrap().unwrap().path,
363+
"/dl/a"
364+
);
365+
366+
// Removing one copy is not the last → keep seeding the content.
367+
assert!(!delete_by_path(&db, "/dl/a").await.unwrap());
368+
assert!(get_by_hash(&db, &hash).await.unwrap().is_some());
369+
// Removing the last copy → caller should stop serving it.
370+
assert!(delete_by_path(&db, "/dl/b").await.unwrap());
371+
assert!(get_by_hash(&db, &hash).await.unwrap().is_none());
372+
}
323373
}

rucio-daemon/src/db/schema.sql

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -237,15 +237,20 @@ CREATE INDEX IF NOT EXISTS idx_dl_chunks_status ON download_chunks(download_id,
237237
-- ---------------------------------------------------------------------------
238238
CREATE TABLE IF NOT EXISTS emule_shared_files (
239239
id INTEGER PRIMARY KEY,
240-
ed2k_hash BLOB NOT NULL UNIQUE, -- 16 bytes MD4, canonical identifier
240+
ed2k_hash BLOB NOT NULL, -- 16 bytes MD4, content id (NOT unique: identical content may live at several paths)
241241
name TEXT NOT NULL,
242242
size INTEGER NOT NULL,
243-
path TEXT NOT NULL, -- absolute path of the final file on disk
243+
path TEXT NOT NULL UNIQUE, -- absolute path of the final file on disk, the per-row key
244244
mtime INTEGER NOT NULL, -- file mtime in Unix seconds (change signal)
245245
hashset BLOB NOT NULL DEFAULT X'', -- ed2k part-hash set, 16 bytes per part (empty for single-part files)
246246
added_at INTEGER NOT NULL
247247
);
248248

249+
-- Look up seeded files by content hash (get_by_hash, the republish sweep).
250+
-- ed2k_hash is no longer UNIQUE, so it needs its own index; the per-path lookups
251+
-- (backfill join, watcher) use the implicit unique index on path.
252+
CREATE INDEX IF NOT EXISTS idx_emule_shared_files_ed2k_hash ON emule_shared_files(ed2k_hash);
253+
249254
-- ---------------------------------------------------------------------------
250255
-- metrics
251256
-- Single-row table holding cumulative lifetime counters.

rucio-daemon/src/emule.rs

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ pub async fn load_shared_files(db: &Db, active_downloads: &ActiveDownloads) {
195195
let unchanged =
196196
disk_size == row.size && crate::api::shares::file_mtime_secs(&path) == row.mtime;
197197
if !unchanged {
198-
let _ = crate::db::emule_shared_files::delete_by_hash(db, &row.ed2k_hash).await;
198+
let _ = crate::db::emule_shared_files::delete_by_path(db, &row.path).await;
199199
dropped += 1;
200200
continue;
201201
}
@@ -291,8 +291,12 @@ pub fn spawn_shared_files_watcher(
291291
if unchanged {
292292
continue; // genuine no-op (or our own completion) — keep sharing
293293
}
294-
let _ = crate::db::emule_shared_files::delete_by_hash(&db, &row.ed2k_hash).await;
295-
if let Ok(hash) = <[u8; 16]>::try_from(row.ed2k_hash.as_slice()) {
294+
// Drop just this path; only stop serving the content if no other
295+
// copy still seeds it.
296+
let was_last = crate::db::emule_shared_files::delete_by_path(&db, &path_str)
297+
.await
298+
.unwrap_or(false);
299+
if was_last && let Ok(hash) = <[u8; 16]>::try_from(row.ed2k_hash.as_slice()) {
296300
active_downloads.write().await.remove(&hash);
297301
}
298302
info!(path = %path.display(), "eMule shared file changed/removed — stopped sharing");
@@ -326,8 +330,13 @@ async fn handle_emule_rename(
326330

327331
if !unchanged {
328332
// Content changed alongside the rename → stop seeding the stale file.
329-
let _ = crate::db::emule_shared_files::delete_by_hash(db, &row.ed2k_hash).await;
330-
active_downloads.write().await.remove(&hash);
333+
// Only forget the content if this was its last seeded copy.
334+
let was_last = crate::db::emule_shared_files::delete_by_path(db, &old_str)
335+
.await
336+
.unwrap_or(false);
337+
if was_last {
338+
active_downloads.write().await.remove(&hash);
339+
}
331340
info!(path = %new.display(), "eMule shared file changed on rename — stopped sharing");
332341
return;
333342
}
@@ -424,10 +433,16 @@ pub fn spawn_source_republisher(
424433
count = files.len(),
425434
"Republishing eMule shared files as Kad sources"
426435
);
436+
// Identical content seeded from several paths shares one hash;
437+
// publish each hash once per round.
438+
let mut published = std::collections::HashSet::new();
427439
for row in files {
428440
let Ok(bytes) = <[u8; 16]>::try_from(row.ed2k_hash.as_slice()) else {
429441
continue;
430442
};
443+
if !published.insert(bytes) {
444+
continue;
445+
}
431446
let hash = rucio_emule::ed2k::Ed2kHash::from_bytes(bytes);
432447
let stored = kad.publish_source(hash, row.size.max(0) as u64).await;
433448
debug!(

0 commit comments

Comments
 (0)