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.
- checksums.yaml +5 -5
- data/.gitignore +8 -0
- data/.rspec +2 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +85 -0
- data/CLAUDE.md +133 -0
- data/Gemfile +2 -0
- data/README.md +607 -21
- data/Rakefile +6 -8
- data/bin/easy_sync +8 -1
- data/docs/fine-placement.md +174 -0
- data/docs/hardware-notes.md +71 -0
- data/docs/integrity-scan.md +435 -0
- data/docs/parallel-scrub.md +280 -0
- data/docs/performance.md +146 -0
- data/docs/tripwire.md +232 -0
- data/easy_sync.gemspec +25 -16
- data/lib/easy_sync/cli.rb +1246 -0
- data/lib/easy_sync/config.rb +202 -0
- data/lib/easy_sync/error.rb +5 -0
- data/lib/easy_sync/jbod/backblaze.rb +113 -0
- data/lib/easy_sync/jbod/benchmarker.rb +149 -0
- data/lib/easy_sync/jbod/cleaner.rb +93 -0
- data/lib/easy_sync/jbod/dashboard.rb +611 -0
- data/lib/easy_sync/jbod/keep_awake.rb +33 -0
- data/lib/easy_sync/jbod/manifest.rb +1068 -0
- data/lib/easy_sync/jbod/mirror.rb +204 -0
- data/lib/easy_sync/jbod/models.rb +165 -0
- data/lib/easy_sync/jbod/page_cache.rb +56 -0
- data/lib/easy_sync/jbod/placement.rb +71 -0
- data/lib/easy_sync/jbod/planner.rb +76 -0
- data/lib/easy_sync/jbod/purger.rb +161 -0
- data/lib/easy_sync/jbod/restorer.rb +142 -0
- data/lib/easy_sync/jbod/run_lock.rb +98 -0
- data/lib/easy_sync/jbod/run_log.rb +57 -0
- data/lib/easy_sync/jbod/runner.rb +615 -0
- data/lib/easy_sync/jbod/scrub_pool.rb +136 -0
- data/lib/easy_sync/jbod/scrubber.rb +321 -0
- data/lib/easy_sync/jbod/share_scan.rb +26 -0
- data/lib/easy_sync/jbod/sync_eta.rb +68 -0
- data/lib/easy_sync/jbod/tripwire.rb +80 -0
- data/lib/easy_sync/jbod/volume_info.rb +283 -0
- data/lib/easy_sync/jbod.rb +35 -0
- data/lib/easy_sync/shell.rb +62 -0
- data/lib/easy_sync/templates/dashboard.html.erb +419 -0
- data/lib/easy_sync/version.rb +3 -1
- data/lib/easy_sync.rb +7 -3
- metadata +84 -45
- data/Gemfile.lock +0 -55
- data/Guardfile +0 -28
- data/lib/easy_sync/rsync.rb +0 -60
- data/lib/easy_sync/sync_logger.rb +0 -20
- data/lib/easy_sync/sync_runner.rb +0 -44
- data/spec/easy_sync/rsync_spec.rb +0 -25
- data/spec/easy_sync/sync_runner_spec.rb +0 -54
- data/spec/spec_helper.rb +0 -33
- 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`).
|