| 9 |
9 |
|
//! - **Cloud eviction:** Setting `cloud_only=true` deletes the local blob while keeping the
|
| 10 |
10 |
|
//! metadata row. The hash lets SyncKit re-download the exact file from blob storage later.
|
| 11 |
11 |
|
|
|
12 |
+ |
use std::collections::BTreeSet;
|
| 12 |
13 |
|
use std::fs;
|
| 13 |
14 |
|
use std::io::{Read, Write};
|
| 14 |
15 |
|
use std::path::{Path, PathBuf};
|
|
16 |
+ |
use std::sync::Mutex;
|
| 15 |
17 |
|
|
| 16 |
18 |
|
use sha2::{Digest, Sha256};
|
| 17 |
19 |
|
use symphonia::core::formats::probe::Hint;
|
| 103 |
105 |
|
})
|
| 104 |
106 |
|
}
|
| 105 |
107 |
|
|
|
108 |
+ |
/// Buffer size for the streaming hash and copy passes.
|
|
109 |
+ |
///
|
|
110 |
+ |
/// 8 KiB was the default and meant roughly 130 read syscalls per megabyte, all
|
|
111 |
+ |
/// of them against a sequential read the kernel is already reading ahead for.
|
|
112 |
+ |
/// At import scale that is a syscall count and nothing else. Heap-allocated
|
|
113 |
+ |
/// rather than an array: 256 KiB is more than belongs on a rayon worker's
|
|
114 |
+ |
/// stack, and the allocation is once per file against reading the whole file.
|
|
115 |
+ |
const STREAM_BUF: usize = 256 * 1024;
|
|
116 |
+ |
|
| 106 |
117 |
|
/// Mark a stored blob read-only (best-effort, cross-platform). A failed chmod
|
| 107 |
118 |
|
/// must never fail an import, it only weakens the write-through guard.
|
| 108 |
119 |
|
fn set_blob_readonly(path: &Path) {
|
| 124 |
135 |
|
/// Deduplication via SHA-256: import streams the file through a hasher and skips
|
| 125 |
136 |
|
/// the copy if a blob with the same hash already exists.
|
| 126 |
137 |
|
///
|
| 127 |
|
- |
/// Thread-safe: all operations are stateless reads/writes against the filesystem
|
| 128 |
|
- |
/// (no interior mutability or locks).
|
|
138 |
+ |
/// Thread-safe. Every operation is a read/write against the filesystem; the one
|
|
139 |
+ |
/// piece of state is [`pending_dirs`](Self::flush_dirs), behind a mutex.
|
| 129 |
140 |
|
pub struct SampleStore {
|
| 130 |
141 |
|
root: PathBuf,
|
|
142 |
+ |
/// Shard directories written into whose fsync has been deferred to the end
|
|
143 |
+ |
/// of the batch. See [`flush_dirs`](Self::flush_dirs) for why.
|
|
144 |
+ |
pending_dirs: Mutex<BTreeSet<PathBuf>>,
|
| 131 |
145 |
|
}
|
| 132 |
146 |
|
|
| 133 |
147 |
|
impl SampleStore {
|
| 136 |
150 |
|
pub fn new(root: impl Into<PathBuf>) -> Result<Self> {
|
| 137 |
151 |
|
let root = root.into();
|
| 138 |
152 |
|
fs::create_dir_all(&root).map_err(|e| io_err(&root, e))?;
|
| 139 |
|
- |
Ok(Self { root })
|
|
153 |
+ |
Ok(Self {
|
|
154 |
+ |
root,
|
|
155 |
+ |
pending_dirs: Mutex::new(BTreeSet::new()),
|
|
156 |
+ |
})
|
| 140 |
157 |
|
}
|
| 141 |
158 |
|
|
| 142 |
159 |
|
/// Import a file into the store: hash it, copy to content-addressed path,
|
| 143 |
160 |
|
/// insert into DB. Returns the hex SHA-256 hash.
|
|
161 |
+ |
///
|
|
162 |
+ |
/// The one-shot entry point, so it flushes its own directory fsync before
|
|
163 |
+ |
/// returning: there is no batch end to defer to. [`import_hashed`] is the
|
|
164 |
+ |
/// batch path and defers; see [`flush_dirs`](Self::flush_dirs).
|
|
165 |
+ |
///
|
|
166 |
+ |
/// [`import_hashed`]: SampleStore::import_hashed
|
| 144 |
167 |
|
#[instrument(skip_all)]
|
| 145 |
168 |
|
pub fn import(&self, path: &Path, db: &Database) -> Result<String> {
|
| 146 |
169 |
|
let (hash, file_size) = hash_file(path)?;
|
| 147 |
170 |
|
let hash = SampleHash::from_trusted(hash);
|
| 148 |
|
- |
self.import_hashed(path, &hash, file_size, db)?;
|
|
171 |
+ |
let result = self.import_hashed(path, &hash, file_size, db);
|
|
172 |
+ |
self.flush_dirs();
|
|
173 |
+ |
result?;
|
| 149 |
174 |
|
Ok(hash.into_inner())
|
| 150 |
175 |
|
}
|
| 151 |
176 |
|
|
|
177 |
+ |
/// Fsync every shard directory written into since the last call, making the
|
|
178 |
+ |
/// renames that landed blobs in them durable. Best-effort throughout: not
|
|
179 |
+ |
/// every filesystem supports a directory fsync, and a failure here weakens
|
|
180 |
+ |
/// durability without touching correctness.
|
|
181 |
+ |
///
|
|
182 |
+ |
/// Called at the end of an import run rather than per file. Measured
|
|
183 |
+ |
/// 2026-07-29 (wiki `af-benchmarks`), the per-file version was about 27% of
|
|
184 |
+ |
/// import wall time: 40,000 files ran in 1336s with it disabled against
|
|
185 |
+ |
/// 1829s shipped, on the identical corpus, batch size and drive.
|
|
186 |
+ |
///
|
|
187 |
+ |
/// What the fsync was NOT buying, which is why the batch end is soon enough:
|
|
188 |
+ |
/// atomicity here comes from the temp-file write plus the rename plus the
|
|
189 |
+ |
/// content-address verification in [`copy_hashing`], not from this. All this
|
|
190 |
+ |
/// buys is crash durability of the dirent, and a blob whose dirent is lost
|
|
191 |
+ |
/// self-heals, `needs_write` treats a missing or size-mismatched blob as
|
|
192 |
+ |
/// re-writable on the next import. The DB row it pairs with is not durable
|
|
193 |
+ |
/// per-file either, the connection runs WAL + `synchronous=NORMAL`.
|
|
194 |
+ |
///
|
|
195 |
+ |
/// [`layout::migrate_to_sharded`] already worked this way, one fsync for a
|
|
196 |
+ |
/// sweep that renames every blob in the vault; this is the import path
|
|
197 |
+ |
/// catching up to it.
|
|
198 |
+ |
pub fn flush_dirs(&self) {
|
|
199 |
+ |
let pending = std::mem::take(&mut *self.pending_dirs());
|
|
200 |
+ |
for dir in pending {
|
|
201 |
+ |
if let Ok(d) = fs::File::open(&dir) {
|
|
202 |
+ |
let _ = d.sync_all();
|
|
203 |
+ |
}
|
|
204 |
+ |
}
|
|
205 |
+ |
}
|
|
206 |
+ |
|
|
207 |
+ |
/// Record that `dir` has had a blob renamed into it and wants an fsync at
|
|
208 |
+ |
/// the next [`flush_dirs`](Self::flush_dirs).
|
|
209 |
+ |
fn mark_dir_pending(&self, dir: &Path) {
|
|
210 |
+ |
self.pending_dirs().insert(dir.to_path_buf());
|
|
211 |
+ |
}
|
|
212 |
+ |
|
|
213 |
+ |
/// The pending-directory set, recovering from a poisoned lock rather than
|
|
214 |
+ |
/// panicking: the set is a to-do list of fsyncs, so a thread that died
|
|
215 |
+ |
/// holding it leaves nothing inconsistent behind, and refusing to import
|
|
216 |
+ |
/// afterwards would be the larger failure.
|
|
217 |
+ |
fn pending_dirs(&self) -> std::sync::MutexGuard<'_, BTreeSet<PathBuf>> {
|
|
218 |
+ |
self.pending_dirs
|
|
219 |
+ |
.lock()
|
|
220 |
+ |
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
|
221 |
+ |
}
|
|
222 |
+ |
|
| 152 |
223 |
|
/// Import a file whose SHA-256 hash and size were already computed (e.g. by a
|
| 153 |
224 |
|
/// parallel pre-hash pass). Does the serial side-effecting work, content-
|
| 154 |
225 |
|
/// addressed blob copy + DB insert + orphan cleanup, exactly as [`import`].
|
| 155 |
226 |
|
/// Splitting the pure hash out lets the import pipeline hash a batch in
|
| 156 |
227 |
|
/// parallel while keeping every store/DB mutation serial.
|
|
228 |
+ |
///
|
|
229 |
+ |
/// The batch path, so it defers its directory fsync: a caller running this
|
|
230 |
+ |
/// in a loop owes a [`flush_dirs`](Self::flush_dirs) at the end of the run.
|
|
231 |
+ |
/// Skipping it is not a correctness failure, only a dirent left as durable
|
|
232 |
+ |
/// as the OS makes it.
|
| 157 |
233 |
|
#[instrument(skip_all)]
|
| 158 |
234 |
|
pub fn import_hashed(
|
| 159 |
235 |
|
&self,
|
| 241 |
317 |
|
let _ = fs::remove_file(&tmp);
|
| 242 |
318 |
|
return Err(io_err(&dest, e));
|
| 243 |
319 |
|
}
|
| 244 |
|
- |
// Best-effort fsync of the directory so the rename itself is durable
|
| 245 |
|
- |
// (the new dirent survives a crash). Not all filesystems support
|
| 246 |
|
- |
// directory fsync; failure here only weakens durability, never the
|
| 247 |
|
- |
// import.
|
| 248 |
|
- |
if let Some(parent) = dest.parent()
|
| 249 |
|
- |
&& let Ok(d) = fs::File::open(parent)
|
| 250 |
|
- |
{
|
| 251 |
|
- |
let _ = d.sync_all();
|
|
320 |
+ |
// The rename wants a directory fsync to be durable (the new dirent
|
|
321 |
+ |
// has to survive a crash), but not one per file: that cost about
|
|
322 |
+ |
// 27% of import wall time and bought a guarantee no other part of
|
|
323 |
+ |
// this path makes. The directory is recorded and fsynced once at
|
|
324 |
+ |
// the end of the batch instead. See `flush_dirs`.
|
|
325 |
+ |
if let Some(parent) = dest.parent() {
|
|
326 |
+ |
self.mark_dir_pending(parent);
|
| 252 |
327 |
|
}
|
| 253 |
328 |
|
// Mark the canonical blob read-only. The VFS mirror exposes store
|
| 254 |
329 |
|
// blobs to DAWs/file managers via symlinks; a "save in place" would
|
| 515 |
590 |
|
let mut file = fs::File::open(&path).map_err(|e| io_err(&path, e))?;
|
| 516 |
591 |
|
|
| 517 |
592 |
|
let mut hasher = Sha256::new();
|
| 518 |
|
- |
let mut buf = [0u8; 8192];
|
|
593 |
+ |
let mut buf = vec![0u8; STREAM_BUF];
|
| 519 |
594 |
|
loop {
|
| 520 |
595 |
|
let n = file.read(&mut buf).map_err(|e| io_err(&path, e))?;
|
| 521 |
596 |
|
if n == 0 {
|
| 922 |
997 |
|
// Verify hash matches
|
| 923 |
998 |
|
let mut file = fs::File::open(new_path).map_err(|e| io_err(new_path, e))?;
|
| 924 |
999 |
|
let mut hasher = Sha256::new();
|
| 925 |
|
- |
let mut buf = [0u8; 8192];
|
|
1000 |
+ |
let mut buf = vec![0u8; STREAM_BUF];
|
| 926 |
1001 |
|
loop {
|
| 927 |
1002 |
|
let n = file.read(&mut buf).map_err(|e| io_err(new_path, e))?;
|
| 928 |
1003 |
|
if n == 0 {
|
| 980 |
1055 |
|
}
|
| 981 |
1056 |
|
|
| 982 |
1057 |
|
let mut hasher = Sha256::new();
|
| 983 |
|
- |
let mut buf = [0u8; 8192];
|
|
1058 |
+ |
let mut buf = vec![0u8; STREAM_BUF];
|
| 984 |
1059 |
|
loop {
|
| 985 |
1060 |
|
let n = file.read(&mut buf).map_err(|e| io_err(path, e))?;
|
| 986 |
1061 |
|
if n == 0 {
|
| 1001 |
1076 |
|
let mut input = fs::File::open(src).map_err(|e| io_err(src, e))?;
|
| 1002 |
1077 |
|
let mut output = fs::File::create(dst).map_err(|e| io_err(dst, e))?;
|
| 1003 |
1078 |
|
let mut hasher = Sha256::new();
|
| 1004 |
|
- |
let mut buf = [0u8; 8192];
|
|
1079 |
+ |
let mut buf = vec![0u8; STREAM_BUF];
|
| 1005 |
1080 |
|
loop {
|
| 1006 |
1081 |
|
let n = input.read(&mut buf).map_err(|e| io_err(src, e))?;
|
| 1007 |
1082 |
|
if n == 0 {
|
| 1392 |
1467 |
|
assert_eq!(count, 1);
|
| 1393 |
1468 |
|
}
|
| 1394 |
1469 |
|
|
|
1470 |
+ |
/// The two entry points make opposite promises about the directory fsync,
|
|
1471 |
+ |
/// and the split is the whole of the 27% the hoist bought: `import_hashed`
|
|
1472 |
+ |
/// is the batch path and leaves its shard directory owing a flush, while
|
|
1473 |
+ |
/// `import` is one-shot and owes nothing on return.
|
|
1474 |
+ |
///
|
|
1475 |
+ |
/// Pins the bookkeeping rather than the fsync, which is not observable. The
|
|
1476 |
+ |
/// regression it guards is a caller (or a future one) that loops
|
|
1477 |
+ |
/// `import_hashed` and never flushes, or an `import` that quietly starts
|
|
1478 |
+ |
/// deferring on behalf of callers that have no batch end.
|
|
1479 |
+ |
#[test]
|
|
1480 |
+ |
fn the_batch_path_defers_its_directory_fsync_and_the_one_shot_path_does_not() {
|
|
1481 |
+ |
let (dir, db, store) = setup();
|
|
1482 |
+ |
|
|
1483 |
+ |
let batched = create_test_file(&dir, "batched.wav", b"batched bytes");
|
|
1484 |
+ |
let (hash, size) = hash_file(&batched).unwrap();
|
|
1485 |
+ |
let hash = crate::SampleHash::from_trusted(hash);
|
|
1486 |
+ |
store.import_hashed(&batched, &hash, size, &db).unwrap();
|
|
1487 |
+ |
|
|
1488 |
+ |
assert_eq!(
|
|
1489 |
+ |
store.pending_dirs().len(),
|
|
1490 |
+ |
1,
|
|
1491 |
+ |
"the batch path leaves its shard directory owing an fsync",
|
|
1492 |
+ |
);
|
|
1493 |
+ |
// Deferring the fsync must not defer the blob: it is renamed into place
|
|
1494 |
+ |
// and readable now, which is why the batch end is soon enough.
|
|
1495 |
+ |
assert!(store.exists(&hash, "wav").unwrap());
|
|
1496 |
+ |
|
|
1497 |
+ |
store.flush_dirs();
|
|
1498 |
+ |
assert!(
|
|
1499 |
+ |
store.pending_dirs().is_empty(),
|
|
1500 |
+ |
"flush_dirs drains the set, so a second run does not re-sync the world",
|
|
1501 |
+ |
);
|
|
1502 |
+ |
|
|
1503 |
+ |
let one_shot = create_test_file(&dir, "one_shot.wav", b"one-shot bytes");
|
|
1504 |
+ |
store.import(&one_shot, &db).unwrap();
|
|
1505 |
+ |
assert!(
|
|
1506 |
+ |
store.pending_dirs().is_empty(),
|
|
1507 |
+ |
"the one-shot path has no batch end to defer to, so it flushes itself",
|
|
1508 |
+ |
);
|
|
1509 |
+ |
}
|
|
1510 |
+ |
|
| 1395 |
1511 |
|
#[test]
|
| 1396 |
1512 |
|
fn import_deduplicates() {
|
| 1397 |
1513 |
|
let (dir, db, store) = setup();
|