easy_sync 0.0.5 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +5 -5
  2. data/.gitignore +8 -0
  3. data/.rspec +2 -0
  4. data/.ruby-version +1 -0
  5. data/CHANGELOG.md +85 -0
  6. data/CLAUDE.md +133 -0
  7. data/Gemfile +2 -0
  8. data/README.md +607 -21
  9. data/Rakefile +6 -8
  10. data/bin/easy_sync +8 -1
  11. data/docs/fine-placement.md +174 -0
  12. data/docs/hardware-notes.md +71 -0
  13. data/docs/integrity-scan.md +435 -0
  14. data/docs/parallel-scrub.md +280 -0
  15. data/docs/performance.md +146 -0
  16. data/docs/tripwire.md +232 -0
  17. data/easy_sync.gemspec +25 -16
  18. data/lib/easy_sync/cli.rb +1246 -0
  19. data/lib/easy_sync/config.rb +202 -0
  20. data/lib/easy_sync/error.rb +5 -0
  21. data/lib/easy_sync/jbod/backblaze.rb +113 -0
  22. data/lib/easy_sync/jbod/benchmarker.rb +149 -0
  23. data/lib/easy_sync/jbod/cleaner.rb +93 -0
  24. data/lib/easy_sync/jbod/dashboard.rb +611 -0
  25. data/lib/easy_sync/jbod/keep_awake.rb +33 -0
  26. data/lib/easy_sync/jbod/manifest.rb +1068 -0
  27. data/lib/easy_sync/jbod/mirror.rb +204 -0
  28. data/lib/easy_sync/jbod/models.rb +165 -0
  29. data/lib/easy_sync/jbod/page_cache.rb +56 -0
  30. data/lib/easy_sync/jbod/placement.rb +71 -0
  31. data/lib/easy_sync/jbod/planner.rb +76 -0
  32. data/lib/easy_sync/jbod/purger.rb +161 -0
  33. data/lib/easy_sync/jbod/restorer.rb +142 -0
  34. data/lib/easy_sync/jbod/run_lock.rb +98 -0
  35. data/lib/easy_sync/jbod/run_log.rb +57 -0
  36. data/lib/easy_sync/jbod/runner.rb +615 -0
  37. data/lib/easy_sync/jbod/scrub_pool.rb +136 -0
  38. data/lib/easy_sync/jbod/scrubber.rb +321 -0
  39. data/lib/easy_sync/jbod/share_scan.rb +26 -0
  40. data/lib/easy_sync/jbod/sync_eta.rb +68 -0
  41. data/lib/easy_sync/jbod/tripwire.rb +80 -0
  42. data/lib/easy_sync/jbod/volume_info.rb +283 -0
  43. data/lib/easy_sync/jbod.rb +35 -0
  44. data/lib/easy_sync/shell.rb +62 -0
  45. data/lib/easy_sync/templates/dashboard.html.erb +419 -0
  46. data/lib/easy_sync/version.rb +3 -1
  47. data/lib/easy_sync.rb +7 -3
  48. metadata +84 -45
  49. data/Gemfile.lock +0 -55
  50. data/Guardfile +0 -28
  51. data/lib/easy_sync/rsync.rb +0 -60
  52. data/lib/easy_sync/sync_logger.rb +0 -20
  53. data/lib/easy_sync/sync_runner.rb +0 -44
  54. data/spec/easy_sync/rsync_spec.rb +0 -25
  55. data/spec/easy_sync/sync_runner_spec.rb +0 -54
  56. data/spec/spec_helper.rb +0 -33
  57. data/spec/support.rb +0 -89
@@ -0,0 +1,435 @@
1
+ # Integrity scan: `easy_sync scrub` (built and verified on real hardware)
2
+
3
+ This was the build spec and is now checked against the implementation:
4
+ `Jbod::Scrubber`, `Jbod::PageCache`, the `file_checksums` table,
5
+ `Mirror#refetch`, and the `sync`/`status`/dashboard/`restore` integrations
6
+ described below all exist and are covered by specs. The real-hardware
7
+ checklist at the bottom passed on 2026-09-21; results are recorded there. If
8
+ anything here disagrees with the code, the code wins; fix this doc.
9
+
10
+ ## Problem
11
+
12
+ rsync checks data while it copies it. Nothing checks it afterwards. If a bit
13
+ flips on a backup drive a year later, the file keeps the same size and mtime,
14
+ so rsync's quick check skips it on every future sync. APFS checksums metadata,
15
+ not file contents. The bad copy stays there, unnoticed.
16
+
17
+ This matters here because **the offsite backup (Backblaze Personal) is taken
18
+ from the drives, not from the NAS.** Backblaze sees a rotted file as a changed
19
+ file and uploads it. The NAS still has the good version, but nothing tells you
20
+ to go and get it.
21
+
22
+ What is already covered, and so is out of scope:
23
+
24
+ - **The NAS.** Synology's monthly Btrfs scrub detects rot and repairs it from
25
+ redundancy.
26
+ - **Legitimate changes on the NAS**, including ransomware or a bad app write
27
+ that sync then copies over the good backup. That is a different feature:
28
+ `docs/tripwire.md`. `scrub` treats any change in size or mtime as
29
+ legitimate on purpose.
30
+
31
+ ## How it works (summary)
32
+
33
+ 1. `easy_sync scrub` picks one mounted drive, the one that has gone longest
34
+ without a full check.
35
+ 2. It walks that drive's assigned folders on local disk. It adds a row for
36
+ each new file, drops rows for files that are gone, and resets the row of
37
+ any file whose size or mtime changed (a legitimate re-sync).
38
+ 3. It hashes files with SHA-256 in frontier order: files that were flagged
39
+ and then refetched come first, then files never hashed, then the oldest
40
+ hashed. It compares each hash to the stored one.
41
+ 4. A mismatch marks the row `corrupt`. A read error marks it `unreadable`.
42
+ `scrub` never changes anything on the drive.
43
+ 5. On the next `sync` of that folder, after the normal copy pass succeeds,
44
+ sync re-copies the flagged files from the NAS with `rsync -I` (ignore
45
+ times). rsync writes each one to a temp file and renames it over the bad
46
+ copy. Nothing is deleted.
47
+ 6. The next `scrub` of that drive re-hashes the refetched files. If the hash
48
+ matches the original baseline, the row goes back to `ok` ("repaired"). If
49
+ it still does not match, the row becomes `unresolved`: it is reported and
50
+ never refetched again automatically.
51
+
52
+ `sync` never hashes anything. The only change to sync is step 5, and it runs
53
+ only for folders that have flagged files.
54
+
55
+ ## Decisions (settled, don't re-open)
56
+
57
+ - **The command is `scrub`, not `verify`.** `verify-drive` already exists and
58
+ does something unrelated: it records a clean SpinRite pass and resets the
59
+ reallocated-sector baseline (`cli.rb` `verify_drive`). Two commands called
60
+ `verify` would be confusing.
61
+ - **One drive at a time.** Each drive is scrubbed completely, in sequence.
62
+ There is no interleaving across drives and no budget split between them.
63
+ Drives are connected now and then rather than kept mounted, so the normal
64
+ use is: connect the drives, run `scrub --all`, leave it. At about 200 MB/s,
65
+ the fullest drive today (backup-01-8tb, 5.8 TB used) takes about 8 hours,
66
+ and the whole fleet (about 35 TB) takes about 2 days.
67
+ - **`scrub` discovers files by walking, not `sync`.** A walk of one local
68
+ APFS drive takes seconds to minutes, next to hours of hashing. The same walk
69
+ handles the first run and every later one. It also cleans up after purge,
70
+ clean, reassign and re-sync without those code paths knowing about checksums.
71
+ (An earlier draft had `sync` add rows from its `--itemize-changes` output.
72
+ That was dropped because a failed or interrupted copy pass would leave
73
+ fully-copied files without rows. They then match on the next run, rsync
74
+ never lists them again, and they are never picked up.)
75
+ - **Repair by overwriting, never by deleting.** `rsync -I --files-from`
76
+ replaces the flagged file atomically. The invariant "only `Purger`
77
+ deletes" stays true. There is never a moment when the file is missing.
78
+ - **SHA-256, computed in Ruby.** Do not use rsync's `%C` checksums. They
79
+ only cover files rsync transferred, they need `--checksum-choice=md5`, and
80
+ MD5 and xxh128 are not sound choices here. SHA-256 runs at about 2.5 GB/s
81
+ on Apple Silicon, so the disk is always the bottleneck.
82
+ - **Read past the page cache.** Set `F_NOCACHE` on each file descriptor
83
+ (`io.fcntl(48, 1)` on macOS, best effort: rescue `SystemCallError` and
84
+ carry on), **and** evict the file's pages first (`Jbod::PageCache.evict`:
85
+ mmap + `msync(MS_SYNC | MS_INVALIDATE)` via Fiddle, best effort).
86
+ Without both, a file sync just wrote is hashed from RAM and the platter is
87
+ never checked. The Mac has 24 GB of RAM, so this is the normal case, not a
88
+ corner case. `F_NOCACHE` alone was measured **not** to be enough: it keeps
89
+ a read from *adding* pages to the cache, but pages already there are still
90
+ served from RAM (a just-synced file scrubbed at 2,084 MB/s from a USB
91
+ drive; after eviction, 145 MB/s).
92
+ - **Trust on first use.** The first hash of a file becomes its baseline. A
93
+ file that was already bad when it was copied gets a bad baseline, and
94
+ `scrub` cannot know. Btrfs scrub on the NAS is the check for that side.
95
+ - **Staleness threshold is 30 days** (`scrub_stale_days: 30`). It matches
96
+ Backblaze's rule that a drive not connected for 30 days drops out of the
97
+ current backup, so there is one number to remember.
98
+ - **Not in v1:** scrubbing drives in parallel, duplicate detection (a GROUP
99
+ BY on `digest` later), scheduling (launchd), SnapRAID. SnapRAID has no
100
+ checksum-only mode, and its parity would need a 7.28 TB drive the fleet
101
+ cannot spare.
102
+
103
+ ## Schema
104
+
105
+ Add this in `Manifest#migrate!` using `CREATE TABLE IF NOT EXISTS`. Don't
106
+ gate it on `user_version` (see CLAUDE.md: the real manifest is stamped 5).
107
+
108
+ ```sql
109
+ CREATE TABLE IF NOT EXISTS file_checksums (
110
+ drive_serial TEXT NOT NULL REFERENCES drives(serial_number),
111
+ folder_path TEXT NOT NULL, -- folders.folder_path, e.g. "movies/Metropolis (1927)"
112
+ relative_path TEXT NOT NULL, -- path inside the folder
113
+ size_bytes INTEGER NOT NULL,
114
+ mtime INTEGER NOT NULL, -- File.lstat.mtime.to_i
115
+ digest TEXT, -- hex SHA-256 baseline; NULL = never hashed
116
+ verified_at TEXT, -- last time the hash matched (or was first set)
117
+ status TEXT NOT NULL DEFAULT 'ok', -- ok | corrupt | unreadable | unresolved
118
+ failed_at TEXT, -- when status last left 'ok'
119
+ refetched_at TEXT, -- set by sync after a successful refetch
120
+ PRIMARY KEY (drive_serial, folder_path, relative_path)
121
+ );
122
+ CREATE INDEX IF NOT EXISTS idx_file_checksums_frontier
123
+ ON file_checksums(drive_serial, status, verified_at);
124
+ ```
125
+
126
+ The key includes `drive_serial` because `reassign` and `replace-drive` move a
127
+ folder between drives. Rows always describe one physical copy.
128
+
129
+ The file on disk is at `File.join(mount_point, folder_path, relative_path)`.
130
+ That is the same join `Runner#sync_folder` uses to build its destination.
131
+
132
+ ## Component 1: `Jbod::Scrubber` (`lib/easy_sync/jbod/scrubber.rb`)
133
+
134
+ `Scrubber.new(manifest, excludes:, clock:, out:, deadline: nil, dry_run: false)`,
135
+ then `#run(mounted_drive) -> Result`. Each call handles one drive.
136
+
137
+ ### 1a. Reconcile (walk)
138
+
139
+ For each folder in `manifest.folders_on(serial)`:
140
+
141
+ - `root = File.join(mount_point, folder_path)`. If it doesn't exist yet
142
+ (assigned but never synced), skip it.
143
+ - Use `Find.find(root)` to find regular files only (check with `File.lstat`;
144
+ skip symlinks). Prune any entry whose basename matches an `exclude_folders`
145
+ pattern (`File.fnmatch(pat, name, File::FNM_DOTMATCH)`).
146
+ - Compare with the folder's existing rows:
147
+ - **File with no row:** insert it with `digest` NULL.
148
+ - **Row with no file:** delete the row.
149
+ - **Size or mtime differs:** it was legitimately re-synced. Set the new
150
+ size and mtime, set `digest`, `verified_at`, `failed_at` and
151
+ `refetched_at` to NULL, and set `status` to `'ok'`.
152
+
153
+ Then delete this drive's rows whose `folder_path` is no longer assigned to
154
+ this drive (the folder was reassigned, removed or purged).
155
+
156
+ Write in one transaction per folder.
157
+
158
+ ### 1b. Work queue (frontier)
159
+
160
+ ```sql
161
+ SELECT * FROM file_checksums
162
+ WHERE drive_serial = ?
163
+ AND (status = 'ok'
164
+ OR (status IN ('corrupt','unreadable') AND refetched_at IS NOT NULL))
165
+ ORDER BY
166
+ CASE WHEN status <> 'ok' THEN 0 -- 1: confirm refetched repairs
167
+ WHEN digest IS NULL THEN 1 -- 2: never hashed
168
+ ELSE 2 END, -- 3: re-check, oldest first
169
+ verified_at, folder_path, relative_path
170
+ ```
171
+
172
+ Two kinds of row are skipped: `corrupt` or `unreadable` rows still waiting for
173
+ sync to refetch them (re-hashing them proves nothing new), and `unresolved`
174
+ rows.
175
+
176
+ ### 1c. Hash each file and record the result
177
+
178
+ Stop before starting the next file if `deadline` has passed. A file already
179
+ being hashed runs to completion.
180
+
181
+ Before each file, check that the drive's marker
182
+ (`<mount>/.easy_sync/drive.json`) still exists. If it doesn't, the drive was
183
+ unmounted: stop this drive, report it, and don't touch the row.
184
+
185
+ Hash with `Digest::SHA256`, reading in 8 MB chunks, with `F_NOCACHE` set and
186
+ the file's cached pages evicted first (see "Read past the page cache").
187
+ Then update the row:
188
+
189
+ | row before | read result | row after | reported as |
190
+ |---|---|---|---|
191
+ | `ok`, digest NULL | read OK | digest set, `verified_at`=now | new baseline |
192
+ | `ok`, digest set | matches | `verified_at`=now | ok |
193
+ | `ok`, digest set | differs | `status`=`corrupt`, `failed_at`=now; digest **kept** | **CORRUPT** |
194
+ | any | `Errno::EIO` | `status`=`unreadable`, `failed_at`=now | **UNREADABLE** |
195
+ | `corrupt`/`unreadable` + refetched, digest set | matches | `status`=`ok`, `verified_at`=now, clear `failed_at`/`refetched_at` | repaired |
196
+ | `unreadable` + refetched, digest NULL | read OK | digest set, `status`=`ok`, clear flags | repaired |
197
+ | `corrupt`/`unreadable` + refetched | differs, or EIO again | `status`=`unresolved` | **UNRESOLVED** |
198
+ | any | `Errno::ENOENT` | delete the row | (not reported) |
199
+
200
+ Keep the digest on `corrupt`. It is the known-good value that confirms the
201
+ repair later.
202
+
203
+ Commit at least every 30 seconds and on exit, including Ctrl-C (`Interrupt`).
204
+ An interrupted run loses at most the files since the last commit, and the next
205
+ run picks up where it stopped.
206
+
207
+ ### 1d. Result
208
+
209
+ Per drive, return: files new, ok, repaired, corrupt, unreadable, unresolved,
210
+ removed and changed (from the walk), plus bytes read, elapsed time and MB/s.
211
+ Also say whether the run completed or stopped (deadline, unmount or
212
+ interrupt).
213
+
214
+ `dry_run: true` walks the drive and prints what it would do (row counts to
215
+ add, drop and reset, files and bytes to hash, and an estimate at 150 MB/s).
216
+ It writes nothing.
217
+
218
+ ## Component 2: the `scrub` CLI command (`cli.rb`)
219
+
220
+ ```
221
+ easy_sync scrub [NAME...] [--all] [--for DURATION] [--dry-run] [--no-keep-awake]
222
+ ```
223
+
224
+ - **No arguments:** scrub one drive, the stalest of the mounted, non-retired
225
+ drives (see "Staleness" below).
226
+ - **`NAME...`:** scrub the named drives in the order given. An unknown name
227
+ or unmounted drive is an error; a retired drive is refused.
228
+ - **`--all`:** scrub every mounted, non-retired drive, stalest first, one after
229
+ another.
230
+ - **`--for 8h`:** one wall-clock deadline for the whole invocation (accept
231
+ `m`, `h` and `d`). With no flag, it runs to completion.
232
+ - Hold `RunLock` for the whole run. `sync` and `scrub` never overlap: they
233
+ share the manifest, and sync's refetch step reads the flags. Change the
234
+ `AlreadyRunning` message from "another easy_sync sync" to "another
235
+ easy_sync run".
236
+ - Keep the Mac awake exactly as `sync` does (`KeepAwake`, honouring
237
+ `keep_awake` and `--no-keep-awake`).
238
+ - Write output to a log file too, through `RunLog`. Give `RunLog.open` a
239
+ `prefix:` argument (default `'sync'`) and prune each prefix separately, so
240
+ that `scrub-*.log` files never push out `sync-*.log` files.
241
+ - Exit with a non-zero status if any file ends the run `corrupt`,
242
+ `unreadable` or `unresolved`.
243
+ - Add a line to `USAGE`, and a README section.
244
+
245
+ **Staleness.** A drive's `scrubbed_through` is the oldest `verified_at`
246
+ across its `ok` rows. It is NULL if any `ok` row has a NULL digest, or if the
247
+ drive has no `ok` rows. This is the moment since which every healthy file on
248
+ the drive has been checked. Flagged and `unresolved` rows are left out: the
249
+ hash queue skips them, so their `verified_at` never advances, and counting
250
+ them would pin the drive as stalest (and overdue) forever. They show up as
251
+ findings instead. Findings on a retired drive are not reported, since
252
+ `scrub` refuses retired drives and could never clear them.
253
+
254
+ - To pick drives: order by `scrubbed_through` ascending, NULLs first, ties
255
+ broken by `friendly_name`.
256
+ - A drive is overdue if `scrubbed_through` is NULL or older than
257
+ `scrub_stale_days`, and it has at least one folder with `last_synced_at`
258
+ set. An empty new drive is not overdue.
259
+
260
+ Compute this with a query; no new column is needed. Files synced since the
261
+ drive was last scrubbed don't count until the next walk adds them. They are
262
+ the least likely files to be rotten, so that is acceptable.
263
+
264
+ ## Component 3: refetch in `sync`
265
+
266
+ `Mirror#refetch(source, destination, relative_paths) -> Shell result`:
267
+
268
+ ```
269
+ rsync -a -I --stats --from0 --files-from=<tmpfile> <source>/ <destination>/
270
+ ```
271
+
272
+ Write `<tmpfile>` NUL-separated, using `Tempfile`. Don't pass `--partial`. An
273
+ interrupted refetch should leave the old file in place, still flagged, not a
274
+ partial file.
275
+
276
+ In `Runner#sync_folder`, after the copy pass succeeds:
277
+
278
+ 1. Select rows for `(target.serial_number, folder.key)` with
279
+ `status IN ('corrupt','unreadable') AND refetched_at IS NULL`.
280
+ 2. If there are none, do nothing. This is the only cost `sync` pays.
281
+ 3. In dry-run, print `N flagged files would be refetched` and stop.
282
+ 4. Otherwise call `refetch`. If it succeeds, set `refetched_at = now` on those
283
+ rows and add them to the run report ("refetched N files flagged by
284
+ scrub"). If it fails, warn and leave the rows as they are, so the next
285
+ sync tries again.
286
+
287
+ If the copy pass fails, don't refetch. A folder skipped for any reason
288
+ (drive not mounted, source not mounted, drive full) leaves its flags as they
289
+ are.
290
+
291
+ ## Component 4: reporting
292
+
293
+ - **`status`:** add one line per drive that is overdue (`never scrubbed` or
294
+ `scrubbed N days ago`). Add a count of rows that are `corrupt`, `unreadable`
295
+ or `unresolved`, with a pointer to `scrub`.
296
+ - **Dashboard:** show "scrubbed N days ago" or "never scrubbed" on each drive
297
+ tile, with an overdue note in the same style as the existing unmounted note.
298
+ **Tile colour stays SMART-only.** Add a "Scrub findings" section next to
299
+ "Pending deletions". It lists every non-`ok` row: the drive, the path, its
300
+ status, and "awaiting refetch", "refetched, awaiting re-check" or
301
+ "unresolved: compare with the NAS copy".
302
+ - **`restore`:** before copying a folder back to the NAS, warn and list any
303
+ flagged files in it. A rotted file must not quietly go back to the NAS.
304
+
305
+ ## Required doc updates when this ships
306
+
307
+ - **CLAUDE.md:** replace the integrity bullets under "Open items" with a short
308
+ description of `scrub`. The invariant "only `Purger` deletes" stays as it
309
+ is. Add: "refetch overwrites flagged files via `rsync -I`; `scrub` is
310
+ read-only on the drive."
311
+ - **README:** document the `scrub` command and the `scrub_stale_days` config
312
+ key. Add `scrub_stale_days: 30` to the defaults in `Config`.
313
+
314
+ ## Tests
315
+
316
+ Specs use real files under `temp_dir` for the walk and the hashing, since
317
+ nothing shells out there. rsync (the refetch) and drive detection go through
318
+ `FakeShell` and the existing `VolumeInfo` fakes. To simulate rot in a spec,
319
+ overwrite one byte and then put the mtime back with `File.utime`, so size
320
+ and mtime are unchanged.
321
+
322
+ Scrubber:
323
+ - The first run gives every file a baseline. A second run with no changes
324
+ reports all files as `ok` and hashes nothing new.
325
+ - Rot (one byte changed, mtime restored) → `corrupt`, and the digest is not
326
+ changed.
327
+ - A file changed legitimately (new mtime) → reset and re-baselined, not
328
+ `corrupt`.
329
+ - File deleted → row removed. New file added → row added.
330
+ - Folder reassigned to another drive → the old drive's rows are removed on
331
+ the next scrub of the old drive.
332
+ - An `exclude_folders` match (e.g. `.DS_Store`) is never added. Symlinks are
333
+ skipped.
334
+ - Frontier order: refetched rows first, then NULL digests, then oldest
335
+ `verified_at`.
336
+ - The deadline stops the run between files, and the next run continues from
337
+ where it stopped.
338
+ - Marker disappears mid-run → stops, and the row being processed is untouched.
339
+ - `Errno::EIO` (stub the read) → `unreadable`.
340
+ - A refetched file that matches its baseline → `ok` (repaired). One that
341
+ still differs → `unresolved`, and sync never refetches it again.
342
+ - Dry-run leaves the manifest unchanged. Compare a full dump of
343
+ `file_checksums` from before and after. Asserting "no error" is not
344
+ enough (see CLAUDE.md on the dry-run incident).
345
+
346
+ CLI:
347
+ - With no arguments it picks the stalest drive, and a drive that was never
348
+ scrubbed beats every other drive.
349
+ - `--all` goes through the drives stalest first. Named drives run in the
350
+ order given. A retired drive is refused.
351
+ - A second process is blocked by `RunLock`.
352
+ - The exit status is non-zero when there are findings.
353
+
354
+ Sync refetch:
355
+ - A flagged row causes exactly one `rsync -a -I ... --files-from` call after
356
+ the copy pass, and `refetched_at` is set.
357
+ - No flagged rows → no extra rsync call.
358
+ - The copy pass fails → no refetch, and the flags are unchanged.
359
+ - The refetch fails → `refetched_at` stays NULL.
360
+ - Dry-run → no refetch call, and a "would refetch" line is printed.
361
+
362
+ ## Verify on real hardware before merging
363
+
364
+ Use the `jbod-test` drives with a scratch `--config` (see CLAUDE.md):
365
+
366
+ 1. Sync a small source tree, then `scrub` it. Every file should get a baseline.
367
+ 2. Corrupt one file on the drive:
368
+ `printf '\xff' | dd of=FILE bs=1 seek=1000 conv=notrunc`, then
369
+ `touch -r COPY_OF_ORIGINAL FILE`.
370
+ 3. `scrub` → it should report CORRUPT and exit non-zero.
371
+ 4. `sync` → it should report "refetched 1 file". Check that the drive copy is
372
+ byte-identical to the NAS copy again (`cmp`).
373
+ 5. `scrub` → it should report the file as repaired.
374
+ 6. Unplug the drive during a scrub. It should stop cleanly, and the next run
375
+ should pick up where it stopped.
376
+ 7. Record the measured scrub MB/s for one real fleet drive in this doc. That
377
+ checks the ~200 MB/s estimate and shows whether `F_NOCACHE` took effect:
378
+ a spinning drive that reads at GB/s means the flag didn't take.
379
+
380
+ ### Results (2026-09-21, all seven steps passed)
381
+
382
+ 1. Synced 4 files (200 MB) to `jbod-test-1`; `scrub` gave all 4 a baseline.
383
+ It read at **2,084 MB/s**: served from the page cache, not the drive.
384
+ `F_NOCACHE` does not evict pages already cached (it only stops new ones
385
+ being added), and a just-synced file is exactly that case. Fixed by adding
386
+ `Jbod::PageCache.evict`; the same files, deliberately made hot in the
387
+ cache first, then scrubbed at **145 MB/s**.
388
+ 2–3. One byte flipped at offset 1000, mtime restored: `scrub` reported
389
+ 1 CORRUPT, exited 1, digest kept.
390
+ 4. `sync --dry-run` said "1 flagged file would be refetched" and wrote
391
+ nothing; `sync` refetched it, and `cmp` against the NAS copy was
392
+ identical (size and mtime unchanged).
393
+ 5. `scrub` reported 1 repaired; every row back to `ok` with flags cleared.
394
+ 6. Cable pulled mid-scrub of a 52 GB folder: the run stopped cleanly as
395
+ "unmounted" (no hang, no crash), the file being read was left untouched
396
+ (not flagged unreadable), and the 2 files already hashed kept their
397
+ baselines (committed on exit). After replugging, the next `scrub` started
398
+ with exactly the file that was interrupted. Found and fixed: the log said
399
+ "ran to completion" after an early stop; it now says "stopped early".
400
+ 7. Scrub speed, cold reads:
401
+
402
+ | drive | interface | MB/s | read |
403
+ |---|---|---|---|
404
+ | `backup-01-8tb` (fleet, spinning) | SATA in the ThunderBay 8 | **201.4** | 35.7 GB (`--for 2m`) |
405
+ | `jbod-test-1` | USB | 151.7 | 52.3 GB |
406
+
407
+ 201 MB/s matches the ~200 MB/s estimate, so the full-fleet timings in
408
+ "Decisions" stand. `--for` also stopped correctly on real hardware; with
409
+ ~9 GB movie files, a 2-minute limit ran about 3 minutes, since a file
410
+ already being hashed always finishes.
411
+
412
+ ### First full pass of the real fleet (2026-09-22)
413
+
414
+ `scrub --all` ran 02:55-14:26 UTC (~11.5 h): 137,805 files, all `ok`.
415
+ backup-06-8tb's ~125k files finished last. `--all` works through every
416
+ mounted, non-retired drive stalest first, `scrub_jobs` drives at once
417
+ (default 4; the real config sets 8). `easy_sync scrub --for 8h` fits an
418
+ overnight window.
419
+
420
+ ### Moving a folder loses its checksums
421
+
422
+ The new drive has no checksums for a moved folder yet, and the old drive's
423
+ rows stay until that drive is scrubbed again (`prune_checksums`). After
424
+ `synology` moved from backup-06-8tb to backup-07-6tb (2026-09-23), both
425
+ drives were scrubbed on 2026-09-24: backup-07-6tb then held `synology`'s
426
+ 122,908 rows, all `ok`, and backup-06-8tb's stale copies were pruned.
427
+
428
+ ## Separate and smaller: warn before Backblaze drops a drive (built)
429
+
430
+ Backblaze Personal drops a drive from the current backup after 30 days
431
+ disconnected, although version history keeps it for a year (verified: the
432
+ old Drobo volume stayed browsable after it left the current backup). The
433
+ dashboard warns at 21 days, which leaves 9 days to act, from
434
+ `drives.last_seen_at`, and `eject` prints the date to reconnect by. Both
435
+ appear only when Backblaze is installed (`Jbod::Backblaze.read`).