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,280 @@
1
+ # Parallel scrub: `easy_sync scrub --jobs N` (built and verified on real hardware)
2
+
3
+ The build spec for scrubbing several drives at once. Every decision below was
4
+ already argued out; implement it as written, and if the code forces a change,
5
+ update this doc in the same PR. Read `docs/integrity-scan.md` first: this only
6
+ changes *how many* drives `scrub` works on at a time, never what it does to
7
+ one drive.
8
+
9
+ ## Why
10
+
11
+ A full scrub of the fleet (~35 TB) takes about 2 days, because `scrub --all`
12
+ does one drive at a time and a drive reads at 170-200 MB/s. The drives are
13
+ not sharing a bottleneck. Measured on 2026-09-21 with Scrubber's own read
14
+ path (8 MB `File#read` chunks, `F_NOCACHE`), cold files, all in the ThunderBay 8:
15
+
16
+ | drives at once | per-drive MB/s | aggregate MB/s |
17
+ |---|---|---|
18
+ | 1 | 169-191 | ~180 |
19
+ | 2 | 169, 202 | ~350 |
20
+ | 4 (7-9 GB files each) | 184, 180, 173, 172 | **641** (90% of the sum of solo speeds) |
21
+
22
+ Each bay has its own AHCI controller on a 40 Gb/s Thunderbolt link
23
+ (docs/performance.md, "Enclosure bandwidth"), so 8-way should scale too, but
24
+ only 4-way is measured. SHA-256 runs at ~2,800 MB/s on one core, so CPU is
25
+ never the limit.
26
+
27
+ This is the opposite of `sync`, which is sequential on purpose (one NAS, one
28
+ network link; CLAUDE.md invariants). Do not use this doc as a reason to
29
+ parallelize `sync`.
30
+
31
+ ## Decisions (do not re-litigate)
32
+
33
+ 1. **Threads, not processes.** The work is I/O-bound; MRI releases the GVL
34
+ during blocking `File#read`, so threads overlap the reads. Processes would
35
+ mean result marshaling and redoing the process-group signal forwarding
36
+ CLAUDE.md warns about. No new gems.
37
+ 2. **SQLite: WAL + one connection per worker.** Not a write queue. Details
38
+ in "Manifest" below.
39
+ 3. **One drive per worker.** Never two workers on the same drive (two
40
+ readers on one spindle just seek against each other). Rows are
41
+ partitioned by `drive_serial`, so workers never touch each other's rows.
42
+ 4. **Default 4 jobs**, the largest count actually measured. `--jobs 1` must
43
+ behave exactly like today's sequential `scrub --all`.
44
+
45
+ ## Behavior
46
+
47
+ easy_sync scrub [NAME ...] [--all] [--jobs N] [--for DURATION] [--dry-run] [--no-keep-awake]
48
+
49
+ - `--jobs N` (N >= 1; reject 0/negative/non-integer with an `Error`).
50
+ Default comes from config `scrub_jobs` (default 4), added to
51
+ `Config::DEFAULTS`, `KEY_COMMENTS` and `SAMPLE` the same way
52
+ `scrub_stale_days` was.
53
+ - Plain `scrub` (no names, no `--all`) still picks one drive, so jobs is
54
+ irrelevant there. Named targets and `--all` go through the pool.
55
+ - Work order is unchanged: targets are resolved exactly as
56
+ `CLI#resolve_scrub_targets` does today (stalest first for `--all`, given
57
+ order for names) and put on a `Queue`. `min(jobs, targets.size)` worker
58
+ threads each pop the next target, scrub it, and pop again until the queue
59
+ is empty. With 8 drives and 4 jobs, the 4 stalest start first and the rest
60
+ start as slots free up.
61
+ - `--for`: the deadline is shared. A worker that finds `clock.now >=
62
+ deadline` before popping stops without starting a new drive (a new drive
63
+ would otherwise run its whole walk phase before the hash loop notices the
64
+ deadline). A drive already mid-hash stops at its next file, as today.
65
+ - A drive that gets unmounted stops only its own worker's drive; that worker
66
+ moves on to the next target.
67
+ - `--dry-run` goes through the same pool (it only reads), so output lines
68
+ must be attributable to a drive (see "Output").
69
+ - Exit code, the "Scrub finished" / "Scrub stopped early (...)" final line,
70
+ and `findings`/`stopped` aggregation are unchanged; build them from the
71
+ collected `Result`s in **target order**, not completion order, so the log
72
+ summary is deterministic.
73
+
74
+ ## Code changes
75
+
76
+ ### `Jbod::ScrubPool` (new, `lib/easy_sync/jbod/scrub_pool.rb`)
77
+
78
+ Owns the threads so `CLI#scrub` stays readable and the threading is testable
79
+ without the CLI. Suggested shape:
80
+
81
+ ScrubPool.new(jobs:, open_manifest:, scrubber_options:, lock:, clock:, deadline:, out:)
82
+ #run(targets) -> [Result, ...] in target order
83
+
84
+ - `open_manifest` is a lambda returning a new `Manifest` (the CLI passes
85
+ `-> { Jbod::Manifest.open(settings[:manifest_path]) }`).
86
+ - **Open all worker manifests on the calling thread before starting any
87
+ thread.** `Manifest#initialize` runs `migrate!` and the WAL pragma; doing
88
+ that from N threads at once invites lock errors on the first run.
89
+ - Each worker: own `Manifest`, own `Scrubber.new(manifest, **scrubber_options)`.
90
+ `Scrubber` itself does not know it is in a pool.
91
+ - Close every worker manifest in an `ensure` after joining.
92
+ - Track the set of drives currently being scrubbed (a `Set` under a `Mutex`);
93
+ after every add/remove call `lock.note(*active_names)` (see RunLock).
94
+ - **Ctrl-C.** `Interrupt` arrives on the main thread, which is blocked in
95
+ `join`. Rescue it there, `t.raise(Interrupt)` every live worker (this
96
+ interrupts a blocked `File#read`, matching today's "stop mid-file, leave
97
+ that row untouched" behavior), join them all (swallowing their
98
+ `Interrupt`), then re-raise so `CLI#scrub`'s existing `rescue Interrupt`
99
+ logs the interrupted line. Waiting for workers to reach a file boundary is
100
+ wrong: one file can be 58 GB (several minutes).
101
+ - Any other exception in a worker: let it propagate out of `join`/`value`
102
+ after stopping the other workers the same way (so they flush). Don't
103
+ swallow it.
104
+
105
+ ### `Scrubber#hash_phase`: buffer outcomes, flush in short transactions
106
+
107
+ Today `hash_phase` opens a transaction, keeps it open, and commits and
108
+ reopens every `COMMIT_INTERVAL` (30 s). In WAL, a connection holds the single
109
+ write lock from its first write until commit, so one worker would hold the
110
+ lock ~30 s at a time and every other worker's write would time out. So:
111
+
112
+ - Collect outcomes in an array (`[args, kwargs]` for each `checksum_hashed`
113
+ call that `record_hash`/`record_read_error`/the vanished branch make today)
114
+ instead of writing immediately.
115
+ - Flush every `COMMIT_INTERVAL` and in `ensure`: one
116
+ `db.transaction(:immediate) { pending.each { ... checksum_hashed ... } }`,
117
+ then clear the array. Lock held for milliseconds.
118
+ - Wrap the `ensure` flush in `Thread.handle_interrupt(Interrupt => :never)`
119
+ so a `Thread#raise` from the pool can't abort it half-way. Outcomes already
120
+ in the buffer are for files that were fully read and are valid to record.
121
+ - Keep "one `@clock.now` read per file" (the deadline spec counts calls);
122
+ flushing must not read the clock.
123
+ - Print the progress line right after each interval flush, as now.
124
+
125
+ This is the only `Scrubber` change besides output prefixes. It applies to
126
+ `--jobs 1` too; the single-drive path must keep passing every existing spec
127
+ unchanged.
128
+
129
+ ### `Manifest`
130
+
131
+ - In `Manifest.open` (not `new`, so `:memory:` specs are untouched), before
132
+ `migrate!`: `db.execute('PRAGMA journal_mode = WAL')` unless path is
133
+ `':memory:'`, and on every connection `db.busy_timeout = 30_000`
134
+ (sqlite3 2.9.6 has `Database#busy_timeout=`). WAL is sticky in the file,
135
+ so the real manifest switches the first time any command opens it.
136
+ Consequence: `manifest.sqlite3-wal` and `-shm` appear next to it; nothing
137
+ copies the manifest file directly (`backup_to` uses the online backup API,
138
+ which is WAL-safe), so that is cosmetic.
139
+ - Change every `db.transaction do` in `Manifest` to
140
+ `db.transaction(:immediate) do`. A deferred transaction that reads and then
141
+ writes can fail in WAL with `SQLITE_BUSY` *without* the busy handler
142
+ retrying (another connection committed after its read snapshot). Immediate
143
+ takes the write lock at `BEGIN`, where `busy_timeout` does apply. Scrub uses
144
+ `reconcile_checksums` (walk phase) this way, but changing all of them is
145
+ simpler than auditing which ones can race and costs nothing single-threaded.
146
+
147
+ ### `RunLock#note`: plural
148
+
149
+ Today the lock file is `pid\nkind\n` plus one optional third line (the drive
150
+ `scrub` is on). Change `note(text)` to `note(*names)`, writing each name on
151
+ its own line from line 3 on (friendly names are not validated, but a newline
152
+ in one is not realistic). `Status#current` becomes an `Array` of names
153
+ (empty when none). Update callers:
154
+
155
+ - `CLI#print_scrub_status`: `run.current.include?(d.friendly_name)`.
156
+ - `Dashboard#scrubbing?`: same.
157
+ - `CLI#print_run_status`: for a scrub, append `on backup-01-8tb, backup-03-8tb`
158
+ when `current` is non-empty.
159
+ - Existing `run_lock_spec` note/current specs move to the array form.
160
+
161
+ Keep: `note` preserves the lock file's mtime (the run's start time) and is a
162
+ no-op unless this process holds the lock.
163
+
164
+ ### Output
165
+
166
+ - `RunLog#puts` and `#print` wrap their two writes in a `Mutex`, so lines
167
+ from different threads never interleave mid-line.
168
+ - Every line a `Scrubber` prints must name its drive. Progress lines, the
169
+ per-drive summary and `stopped:` lines already do. Prefix the two
170
+ `WARNING:` lines (walk "could not walk it completely" and hash_one
171
+ "skipped, left as it was") with `"#{result.drive}: "`, and the dry-run
172
+ line already starts with the drive.
173
+ - Header line gains `· jobs N` when N > 1.
174
+
175
+ ### CLI / docs
176
+
177
+ - `CLI#scrub`: parse `--jobs`, build the `ScrubPool`, keep the RunLock /
178
+ RunLog / KeepAwake / final-line / exit-code code as is around it.
179
+ - `COMMAND_GROUPS` usage line and README: document `--jobs N`, the default,
180
+ and that it is scrub-only. README config example gains `scrub_jobs: 4`.
181
+ - CLAUDE.md: replace the "first full pass ... about 2 days" wording in Open
182
+ items with the measured parallel number once the hardware check below is
183
+ done; add the 4-way scaling result under "Things learned on real hardware".
184
+ - docs/performance.md "Enclosure bandwidth": add the table from "Why" above.
185
+ - Flip this doc's title to "(built, not yet verified on real hardware)" when
186
+ code lands, and to "(built and verified on real hardware)" with a Results
187
+ section after the checklist passes, as integrity-scan.md did.
188
+
189
+ ## Tests
190
+
191
+ `ScrubPool` and anything using more than one connection needs a
192
+ **file-backed** manifest (`Manifest.open(File.join(temp_dir, 'm.sqlite3'))`):
193
+ every `:memory:` connection is its own separate database. Real files, no
194
+ FakeShell, like `scrubber_spec`.
195
+
196
+ - Manifest: `Manifest.open` on a file reports `journal_mode` `wal` and a
197
+ 30 s busy timeout; `:memory:` still works.
198
+ - Scrubber: outcomes are written in batches (stub/spy the transaction: with
199
+ a ticking clock crossing `COMMIT_INTERVAL` once, exactly two flushes; with a
200
+ static clock, one flush at the end); a run interrupted by `Interrupt`
201
+ raised from inside `File.open` for file N still records files 1..N-1 and
202
+ leaves file N untouched. All existing scrubber specs pass unmodified.
203
+ - ScrubPool, 3 drives on disk (reuse `write_marker`-style setup):
204
+ - `jobs: 2` baselines every file on all 3 drives and returns results in
205
+ target order.
206
+ - never more than `jobs` `Scrubber#run` calls in flight at once, and never
207
+ two on the same drive (instrument `run` with a counter under a mutex).
208
+ - past the deadline, no new drive is started.
209
+ - `lock.note` receives the active set as drives start and finish
210
+ (spy on `note`).
211
+ - `Interrupt` on the calling thread stops all workers and re-raises; rows
212
+ for completed files are present.
213
+ - an exception in one worker propagates, and other workers' completed
214
+ rows are still flushed.
215
+ - CLI: `--jobs 0`/`--jobs x` rejected; `--jobs 2 --all` scrubs everything;
216
+ `status` shows "scrubbing now" for every name in a multi-line lock file;
217
+ `print_run_status` lists the active drives.
218
+ - Dashboard: two tiles show "scrubbing now" when `current` has two names.
219
+ - RunLog: many threads calling `puts` concurrently produce only whole lines.
220
+
221
+ ## Verify on real hardware before merging
222
+
223
+ Use a scratch `--config` whose `manifest_path`, `log_dir`, `lock_path` and
224
+ `dashboard_path` point into a scratch directory, so the real
225
+ `~/.easy_sync/` is never touched (CLAUDE.md, "Verify live"). Scrub only reads
226
+ the drives, so the real fleet can be the target. The scratch manifest needs
227
+ the fleet registered: copy the real manifest into the scratch dir first
228
+ (`sqlite3 ~/.easy_sync/manifest.sqlite3 ".backup <scratch>/manifest.sqlite3"`),
229
+ then use `--for` so each step is bounded.
230
+
231
+ 1. `scrub --all --jobs 4 --for 5m`: aggregate throughput (sum of bytes read /
232
+ wall time from the per-drive summary lines) within ~15% of 4x one drive.
233
+ `iostat -w 5` shows 4 disks busy.
234
+ 2. While step 1 runs: `status --config <scratch>` lists all 4 drives as
235
+ "scrubbing now"; `dashboard --config <scratch>` shows 4 tiles likewise.
236
+ A 5th drive starts as soon as one finishes (use a small drive, e.g.
237
+ backup-08-2tb, among the first 4 to see it).
238
+ 3. `--jobs 8 --for 5m`: record the 8-way aggregate in docs/performance.md
239
+ whether or not it scales. If it doesn't, leave the default at 4.
240
+ 4. Ctrl-C mid-run: every worker stops within a few seconds (not at the end of
241
+ its current file), the log says "interrupted", and a follow-up
242
+ `scrub --all --jobs 4 --for 2m` resumes with no row reset and no findings.
243
+ 5. `sqlite3 <scratch>/manifest.sqlite3 "PRAGMA integrity_check;"` -> `ok`,
244
+ and `PRAGMA journal_mode;` -> `wal`.
245
+ 6. The WAL switch touches `sync`'s database too: run `sync` against the
246
+ `jbod-test` drives with a scratch config (CLAUDE.md, "Verify live") and
247
+ confirm it completes and `status`/dashboard read back normally.
248
+ 7. No output line in the scrub log is interleaved or lacks a drive name.
249
+
250
+ ## Results (2026-09-21, against the real ThunderBay 8 fleet)
251
+
252
+ All 7 checks above passed on a scratch config against the real fleet (all 8
253
+ drives, none of the two `jbod-test` drives, which are retired):
254
+
255
+ 1. `--jobs 4 --for 5m`: 4 drives (backup-01/02/03/04) read concurrently,
256
+ ~782 MB/s aggregate (docs/performance.md), `iostat -w 2` showed exactly 4
257
+ physical disks busy the whole time.
258
+ 2. `status --config <scratch>` (run from a second process, reading the same
259
+ lock file) listed all 4 as "scrubbing now" in one line; the dashboard
260
+ showed 4 tiles the same way.
261
+ 3. `--jobs 8 --for 3m`: all 8 active drives ran at once (`iostat` confirmed
262
+ 8 disks busy), ~1175 MB/s aggregate - default stays 4 (the largest
263
+ *measured before this pass*; 8 works too, but nothing changes the
264
+ default without more runs to be sure it's not the two 2 TB/3 TB drives
265
+ inflating the average).
266
+ 4. `kill -INT` on the process 8s in: exited in well under a second, log said
267
+ "Scrub interrupted (Ctrl-C)", the lock file was removed, and a follow-up
268
+ `--jobs 4` resumed on the same 4 drives with new baselines only for files
269
+ not yet reached - no repeats, no reset.
270
+ 5. `PRAGMA integrity_check` -> `ok`, `PRAGMA journal_mode` -> `wal`, after
271
+ every run above including the Ctrl-C'd one.
272
+ 6. A real `sync` (fresh scratch manifest, `jbod-test-1` registered with its
273
+ real marker serial, a two-folder scratch NAS source) placed and copied
274
+ both folders onto `jbod-test-1`, `status` and the dashboard read the WAL
275
+ manifest back with no errors. Cleaned up afterward (test folders and the
276
+ scratch `.easy_sync/manifest.sqlite3`/`config.yml` copies removed from the
277
+ drive; its real `drive.json` marker was left untouched).
278
+ 7. Confirmed by grep across every log produced above: every line either
279
+ starts with a drive name or is one of the run's own fixed header/footer
280
+ lines.
@@ -0,0 +1,146 @@
1
+ # Measured performance
2
+
3
+ These numbers were measured, not estimated. Re-measure rather than eyeball.
4
+ The fleet is 8 drives, 44.59 TB (4 × 7.28, 2 × 5.46, 1 × 2.73, 1 × 1.82 TB),
5
+ against a library of about 31.9 TB (tv 17.7, movies 12.3, synology 1.9,
6
+ pro 0.04).
7
+
8
+ ## Sync throughput: 62.9 MB/s aggregate
9
+
10
+ The source is the `sync_runs` table of the real manifest, for the first
11
+ full-library run (2026-09-13 to 2026-09-17). That covers 154 folder copies of
12
+ 5 GB or more: 11.3 TB in 49.9 hours of transfer time. Runs under 5 GB are
13
+ left out so that per-folder setup time doesn't skew the rate.
14
+
15
+ ```sql
16
+ SELECT folder_path, bytes_transferred,
17
+ strftime('%s',finished_at) - strftime('%s',started_at) AS secs
18
+ FROM sync_runs
19
+ WHERE exit_status = 0 AND bytes_transferred >= 5e9
20
+ AND strftime('%s',finished_at) > strftime('%s',started_at);
21
+ ```
22
+
23
+ | statistic | MB/s |
24
+ |---|---|
25
+ | **aggregate (total bytes / total time)** | **62.9** |
26
+ | median folder | 67.7 |
27
+ | p25 – p75 | 57.8 – 79.8 |
28
+ | p10 – p90 | 50.2 – 87.6 |
29
+ | min / max | 16.6 / 98.3 |
30
+
31
+ - **Use the aggregate for planning.** It is the only figure that predicts
32
+ wall-clock time. The median looks better because slow folders take up more
33
+ of the clock.
34
+ - **Don't read the rate off an rsync log.** The rate `--info=progress2` prints
35
+ is a cumulative average (bytes ÷ elapsed). It changes slowly and hides how
36
+ much the real rate varies.
37
+ - **The floor for any change to `sync`: the aggregate must stay at or above
38
+ 50 MB/s.** It is a floor on the aggregate because 10.7% of transfer time
39
+ already runs below 50. Hashing inside the copy path costs 25–33%, which
40
+ would bring the aggregate down to 42–47 MB/s. That is why `scrub` is a
41
+ separate command (see `docs/integrity-scan.md`).
42
+ - **Time Machine skews the numbers.** It backs up to a sparsebundle on the
43
+ same Synology (DS1019) that serves the media shares, so it competes for the
44
+ same array and SMB link. It was running during part of this run. Run
45
+ `sudo tmutil disable` before re-measuring, and check it first whenever a
46
+ sync looks slow.
47
+ - **The NAS read side is not measured yet**, and it is the part that limits
48
+ speed. It needs a quiet NAS: no sync and no Time Machine running.
49
+
50
+ ## Drives are not the limit
51
+
52
+ Sync throughput per target drive: backup-01-8tb 66.5, backup-03-8tb 66.8,
53
+ backup-04-8tb 66.4, backup-06-8tb 68.8, backup-02-6tb 67.8 MB/s. They are within 2.4 MB/s of each other,
54
+ because none of them is the constraint.
55
+
56
+ Each drive was also measured directly: 8 GB sequential, buffer cache bypassed
57
+ with `fcntl(F_NOCACHE)`, closing `fsync` included in the timing. The "used"
58
+ column is how full the drive was when it was measured.
59
+
60
+ | drive | model | used then | write MB/s | read MB/s |
61
+ |---|---|---|---|---|
62
+ | backup-04-8tb | ST8000VN004 | 2.2 TB | **203** | 213 |
63
+ | backup-06-8tb | ST8000VN004 | 4.1 TB | 182 | 190 |
64
+ | backup-01-8tb | ST8000VN004 | 2.5 TB | 178 | 199 |
65
+ | backup-07-6tb | HDWE160 | empty | 143 | 179 |
66
+ | backup-08-2tb | ST2000LM015 | empty | 116 | 126 |
67
+ | backup-05-3tb | WD30EFRX | empty | 115 | 128 |
68
+
69
+ The 8 TB figures are the mean of three runs; the others are single runs.
70
+ Runs vary by about ±7%, so smaller differences than that aren't real.
71
+ backup-04 really is about 14% faster than the identical backup-01, across
72
+ three runs. Even the slowest drive writes at 1.8× the sync rate.
73
+
74
+ `easy_sync benchmark` measures each drive this way (8 GB by default) and
75
+ keeps the last 25 runs per drive, so these numbers can be re-measured and
76
+ tracked rather than read off this table once.
77
+
78
+ How to benchmark a drive so the result is real:
79
+
80
+ - With 24 GB of RAM, a test smaller than RAM measures the buffer cache, not
81
+ the disk.
82
+ - macOS `dd` has no `oflag=direct`. `io.fcntl(48, 1)` (`F_NOCACHE`) on the
83
+ file descriptor is the only way to bypass the cache.
84
+ - A spinning drive that benchmarks in GB/s means the flag didn't take effect.
85
+ - Use random data, and include the closing `fsync` in the timing.
86
+ - Never benchmark a drive that a sync is writing to at the same time.
87
+
88
+ ## Enclosure bandwidth
89
+
90
+ The OWC ThunderBay 8 is Thunderbolt, not USB-C. Each bay has its own AHCI
91
+ controller at 6 Gb/s (about 600 MB/s), on a 40 Gb/s link (about 5,000 MB/s).
92
+ `system_profiler SPSerialATADataType SPThunderboltDataType` shows the layout.
93
+ All 8 drives reading at about 190 MB/s together would use about 1,500 MB/s,
94
+ roughly 30% of the link. So scrubbing several drives in parallel would not be
95
+ limited by bandwidth, if that is ever wanted.
96
+
97
+ Measured on 2026-09-21 with `scrub --jobs N` (docs/parallel-scrub.md), Scrubber's
98
+ own read path (8 MB `File#read` chunks, `F_NOCACHE`), cold files, all in the
99
+ ThunderBay 8:
100
+
101
+ | drives at once | per-drive MB/s | aggregate MB/s |
102
+ |---|---|---|
103
+ | 1 | 169-191 | ~180 |
104
+ | 2 | 169, 202 | ~350 |
105
+ | 4 (7-9 GB files each) | 184, 180, 173, 172 | **641** (90% of the sum of solo speeds) |
106
+
107
+ Re-measured on 2026-09-21 against the real ThunderBay 8 fleet (all 8 active
108
+ drives, `--jobs 4`/`--jobs 8 --for 3m`, resuming from wherever each drive's
109
+ last check left off - so file sizes and cache state vary by drive):
110
+
111
+ | drives at once | per-drive MB/s | aggregate MB/s |
112
+ |---|---|---|
113
+ | 4 | 205, 181, 194, 202 | ~782 |
114
+ | 8 | 120, 154, 147, 147, 162, 135, 172, 137 | **~1175** (6.5x one drive; confirmed with `iostat -w`) |
115
+
116
+ 8-way scales as well as 4-way did, with the enclosure's bandwidth headroom
117
+ above easily covering it. CPU is never the limit (see "Hash speed" below).
118
+
119
+ ## `easy_sync benchmark` (sequential write/read per drive)
120
+
121
+ Checked on `jbod-test-1` on 2026-09-22: a 1.5 GB test file wrote at
122
+ 86-99 MB/s and read at ~163 MB/s. That is close to scrub's ~150 MB/s on the
123
+ same drive, so the page cache was bypassed. Ctrl-C removed the test file.
124
+
125
+ First real-fleet `benchmark --all`, 2026-09-22 14:54-15:08, 8 GB file,
126
+ write/read in MiB/s (as `benchmark` and `scrub` report):
127
+
128
+ | drive | write | read |
129
+ |---|---|---|
130
+ | backup-01-8tb | 224 | 247 |
131
+ | backup-07-6tb | 180 | 196 |
132
+ | backup-03-8tb | 158 | 163 |
133
+ | backup-04-8tb | 136 | 154 |
134
+ | backup-02-6tb | 137 | 142 |
135
+ | backup-06-8tb | 130 | 136 |
136
+ | backup-08-2tb | 107 | 123 |
137
+ | backup-05-3tb | 106 | 112 |
138
+
139
+ Each drive had one run then; a SLOWER flag needs 3 earlier runs. The 8 GB
140
+ table under "Drives are not the limit" doesn't say whether it used MB or
141
+ MiB (~5% apart), so compare the two loosely.
142
+
143
+ ## Hash speed (Apple Silicon)
144
+
145
+ SHA-256 2514 MB/s, SHA-1 2486, MD5 763. Apple Silicon speeds up SHA but not
146
+ MD5. All of these are faster than any drive can read.
data/docs/tripwire.md ADDED
@@ -0,0 +1,232 @@
1
+ # Tripwire: stop a sync that would overwrite the backup wholesale (built, report-only)
2
+
3
+ Built 2026-09-24 (`Jbod::Tripwire`, `Mirror#check`, Runner phase 1b). It
4
+ ships **report-only** (`tripwire_enforce: false`, Rollout step 1). See
5
+ "As built" at the end for where the code differs from this design, and for
6
+ what was checked on real hardware.
7
+
8
+ ## The gap
9
+
10
+ A mirror keeps one copy. If ransomware or a bad app rewrites files on the
11
+ NAS, the next `sync` copies the damage over the good backup. Nothing
12
+ covers that today:
13
+
14
+ - The deletion grace period only protects files that disappear. rsync
15
+ still overwrites files that changed.
16
+ - `scrub` trusts the NAS. It treats a new size or mtime as a legitimate
17
+ re-sync and makes the new hash the baseline, and its repair path fetches
18
+ the file again from the NAS (docs/integrity-scan.md, "What is already
19
+ covered").
20
+ - Synology snapshots and Backblaze's 1-year version history can recover
21
+ after the fact. But Backblaze uploads from the drives, so every
22
+ overwritten file on a drive also becomes the current version offsite.
23
+
24
+ The tripwire stops the copy *before* it starts when a run would change far
25
+ more existing files than the library ever changes normally. A media library
26
+ adds files, and now and then one is replaced (a better rip, a re-tag). It
27
+ almost never rewrites hundreds of existing files at once. Ransomware always
28
+ does.
29
+
30
+ This replaces an earlier design (keeping every replaced file on the drive
31
+ for `grace_days`). That design was dropped: Synology snapshots and
32
+ Backblaze history already cover recovery, and it cost drive space on every
33
+ run. The tripwire is cheap and prevents the damage instead of storing it.
34
+
35
+ ## What counts as a change
36
+
37
+ rsync's itemized output already classifies every file. In a dry run
38
+ (`-n --itemize-changes`):
39
+
40
+ | Line | Meaning | Counts? |
41
+ |---|---|---|
42
+ | `>f+++++++++ path` | new file | no: nothing on the drive is lost |
43
+ | `>f.st...... path`, `>f..t...... path`, any other `>f` | existing file would be overwritten | **yes, replaced** |
44
+ | `*deleting path` | on the drive, gone from the NAS | **yes, missing** |
45
+ | `cd+++`, `.d..t`, symlinks, etc. | directories/metadata | no |
46
+
47
+ Missing files count as well, because most ransomware writes
48
+ `photo.jpg.locked` and deletes `photo.jpg`. That shows up as one new file
49
+ plus one deletion, with nothing replaced. Counting the deletion means a
50
+ rename counts once. Deletions are still protected by the grace period. The
51
+ tripwire just stops the new junk being copied and says why.
52
+
53
+ A file whose size and mtime stay the same is never copied by rsync's quick
54
+ check, so it can't reach the drive. Content-only ransomware that preserves
55
+ both is harmless to the backup.
56
+
57
+ ## Where it runs: one probe, moved before the copy
58
+
59
+ Today each folder gets a copy pass, then a read-only deletion probe
60
+ (`Mirror#probe`, `rsync -an --itemize-changes --delete [--delete-excluded]`).
61
+ That probe's output already has every line the table above needs. The
62
+ change is to run it *before* the copy instead of after it, for every
63
+ planned folder, as a new phase between placement and copying:
64
+
65
+ 1. **Phase 1 (unchanged):** measure, place, record `source_inventory`.
66
+ 2. **Phase 1b, check:** for every planned folder that has synced before
67
+ (`sync_runs` has a successful row), run the probe with `--stats` added.
68
+ Keep per folder: replaced count, missing list (already parsed by
69
+ `parse_extraneous`), and the files-on-drive count (see Thresholds).
70
+ A folder placed this run has nothing on the drive yet, so it isn't checked.
71
+ 3. **Decide.** Apply the thresholds below. If the whole run trips, stop:
72
+ no copy phase and no purge. If only some folders trip, skip those and
73
+ carry on with the rest.
74
+ 4. **Phase 2, copy:** as today, minus the post-copy probe. After a
75
+ folder's copy succeeds, feed its phase-1b missing list to
76
+ `note_missing`. The old rule stays: a folder whose copy failed starts no
77
+ deletion clocks.
78
+
79
+ **Cost:** the same two walks per folder as today, in a different order,
80
+ so the 50 MB/s aggregate floor (docs/performance.md) should not move. The
81
+ difference is that about 2,700 probes (~0.15 s each for an unchanged folder,
82
+ so ~7 min) now run before the first byte is copied. Verify both numbers on
83
+ the real fleet before merging.
84
+
85
+ **Staleness:** the missing list can be up to a copy phase old (days, on a
86
+ first sync) by the time it is recorded. That is harmless.
87
+ `reconcile_pending` already removes a pending deletion when the file turns
88
+ up again. A file deleted during the copy is found on the next run. Purger
89
+ still needs `grace_days` and `grace_runs`.
90
+
91
+ **Probe failure:** a folder whose check probe fails is not copied this run
92
+ (warn, status `skipped_check_failed`). Nothing can vouch for it, and a
93
+ failing probe over SMB almost always means the copy would fail too. This
94
+ is a change from today, where a failed post-copy probe only warns.
95
+
96
+ ## Thresholds
97
+
98
+ Three config keys. Defaults are guesses to be tuned from real runs (see
99
+ Rollout):
100
+
101
+ :tripwire_run_files: 500 # whole run: stop everything at this many changed files
102
+ :tripwire_folder_files: 50 # one folder: needs at least this many changed files...
103
+ :tripwire_folder_ratio: 0.25 # ...AND at least this share of its files on the drive
104
+
105
+ - **Files on the drive** for a folder = the NAS regular-file count from
106
+ the probe's `--stats` ("Number of files: N (reg: R, ...)"), minus new
107
+ files, plus missing files. No extra walk of the drive.
108
+ - **Per folder:** trips when changed ≥ `tripwire_folder_files` AND
109
+ changed / files on drive ≥ `tripwire_folder_ratio`. The ratio stops a big
110
+ share with a routine 60-file edit from tripping. The minimum stops a
111
+ 3-file folder with one replaced file from tripping.
112
+ - **Whole run:** trips when the sum of changed files across all checked
113
+ folders ≥ `tripwire_run_files`. This is the check that catches ransomware
114
+ spread across thousands of single-file movie folders, none of which
115
+ trips on its own. Because every folder is checked before any copy
116
+ starts, the run stops before *anything* is overwritten.
117
+ - `tripwire_run_files: 0` turns the tripwire off entirely (report-only
118
+ logging still runs).
119
+
120
+ ## When it trips
121
+
122
+ - **Tripped folder:** not copied, no deletion clocks, no refetch of scrub
123
+ flags. `mark_folder_status(key, 'tripped')`. The trip is sticky for
124
+ free: nothing was copied, so the next run sees the same changes and
125
+ trips again until you accept them.
126
+ - **Tripped run:** nothing copied, **Purger does not run** (ransomware that
127
+ deletes could otherwise ride out the grace period while you're away),
128
+ and every planned folder is marked `tripped`. `copy_state_to_drives` and
129
+ the dashboard write still happen, so the trip shows up.
130
+ - **Output:** per tripped folder, the counts plus up to 10 sample paths.
131
+ Seeing `*.locked` or unreadable names is usually enough to tell ransomware
132
+ from a re-tag. `sync` exits non-zero.
133
+ - **Dashboard:** the verdict goes red ("Sync stopped: 2,314 files would
134
+ change on the NAS side") above everything else. The tripped folders are
135
+ listed with their counts and samples. Store what the dashboard needs in
136
+ a `tripwire_trips` table (run_started_at, folder_path, replaced, missing,
137
+ files_on_drive, samples JSON, accepted_at). Add it with
138
+ `CREATE TABLE IF NOT EXISTS`, per the manifest rule in CLAUDE.md.
139
+
140
+ ## Accepting a legitimate bulk change
141
+
142
+ easy_sync sync --accept-changes # accept every trip this run
143
+ easy_sync sync --accept-changes music/Jazz # accept only these folders
144
+
145
+ The acceptance applies to that run only. It is never saved, so a later
146
+ unrelated trip still stops. `--accept-changes` with a folder that doesn't
147
+ trip is fine (no-op). The accepted trip is still recorded (`accepted_at`
148
+ set), so the dashboard's history shows that a big change went through and
149
+ who allowed it (the flag).
150
+
151
+ ## Dry run
152
+
153
+ `--dry-run` runs phase 1b in full (the probes are read-only already) and
154
+ prints what would trip. Per CLAUDE.md, it writes nothing: no
155
+ `tripwire_trips` row, no folder status. Running `sync --dry-run` is how you
156
+ look before accepting.
157
+
158
+ ## Rollout
159
+
160
+ 1. **Report-only first.** Build the probe move and the counting, and log the
161
+ per-folder and per-run counts on every sync, but enforce nothing
162
+ (`tripwire_enforce: false` until step 2). Run the real fleet for a couple
163
+ of weeks and read the largest legitimate counts. Set the defaults
164
+ comfortably above them.
165
+ 2. **Enforce.** Flip the default, and keep the report-only key for anyone
166
+ whose library churns.
167
+
168
+ ## Edge cases to test
169
+
170
+ - Ransomware-style rename across 1,000 single-file folders: no folder
171
+ trips, the run does, nothing is copied, Purger does not run.
172
+ - One folder with 80 of 100 files replaced: that folder trips, the others
173
+ sync, Purger runs for them.
174
+ - A share-wide re-tag of 5,000 music files: trips; `--accept-changes`
175
+ syncs it; the following run is quiet.
176
+ - Root-files unit (`root_only`): its probe has no `--delete-excluded`, so
177
+ subfolders are never counted as missing, same as today.
178
+ - A folder placed this run: not checked, copied normally.
179
+ - Probe fails for one folder: that folder is skipped, the rest continue.
180
+ - `--dry-run` with a trip: prints it, manifest byte-for-byte unchanged.
181
+ - Interrupted during phase 1b: nothing copied, nothing recorded beyond
182
+ phase 1 (as today).
183
+ - Real hardware: measure phase 1b's duration and the run's aggregate MB/s
184
+ against docs/performance.md. Then simulate ransomware on a scratch
185
+ source (rename + rewrite a few hundred files) against `jbod-test-1`.
186
+
187
+ ## As built
188
+
189
+ Differences from the design above, all found while building it:
190
+
191
+ - **Files already pending don't count again.** A file missing from the NAS
192
+ stays on the drive for `grace_days`, and every run's probe reports it
193
+ again. As designed, an accepted bulk delete or rename would trip again on
194
+ every run until the purge, about a week later. Found live on `jbod-test-1`.
195
+ `Check#known` holds the folder's `pending_deletions` paths, and only
196
+ newly missing files count.
197
+ - **Excluded junk doesn't count.** `--delete-excluded` reports
198
+ already-copied junk (`.DS_Store`, `#recycle`) as missing. Adding a pattern
199
+ to `exclude_folders` would otherwise trip on the next run. Paths with any
200
+ component matching `exclude_folders` are left out of the count. They are
201
+ still fed to the purge as before.
202
+ - **"Synced before"** means the folder's `last_synced_at` is set. It is only
203
+ set by a successful sync_run, the same condition as in the design.
204
+ - `tripwire_trips` has a `scope` column: `folder` when the folder tripped on
205
+ its own, `run` when it only counted towards a run-wide trip.
206
+ - `--accept-changes FOLDER` also leaves that folder out of the run total. A
207
+ name that isn't in the sync gets a warning.
208
+ - Dashboard: an unaccepted trip from the latest run shows as a critical
209
+ issue plus a Tripwire section (counts and sample paths). It stays until a
210
+ later run syncs. A stopped run also replaces the headline ("Sync stopped:
211
+ N files would change on the NAS side"). Accepted trips are listed under
212
+ "Accepted bulk changes". Folder statuses: `tripped`, `skipped_check_failed`.
213
+ - A stopped run still prints and records the pending-deletion count. Only
214
+ the purge is skipped.
215
+
216
+ Checked on `jbod-test-1` (2026-09-24, scratch config, enforce on,
217
+ 200-file album + ten single-file movie folders):
218
+
219
+ | Step | Result |
220
+ |---|---|
221
+ | 120 album files rewritten as `*.locked`, originals removed; `sync --dry-run` | prints the trip with 10 samples, exit 0, manifest `.dump` identical |
222
+ | same, `sync` | album held back (`tripped`), other 10 synced, exit 1, no `.locked` file on the drive, no deletion clock started |
223
+ | `sync --accept-changes tripsrc/Album` | copied, 120 pending deletions, trip recorded with `accepted_at` |
224
+ | `tripwire_run_files: 8`, one file rewritten in each movie folder | whole run stopped: nothing copied or purged, all 11 `tripped`, dashboard headline "Sync stopped: 10 files would change" (the 120 already-pending files not counted), exit 1 |
225
+ | `sync --accept-changes`, then a plain `sync` | copied; the following run reports 0 changed files |
226
+
227
+ **Still to do before enforcing (Rollout):** measure phase 1b's duration on
228
+ the real fleet (~2,700 probes, estimated ~7 min) and confirm the sync
229
+ aggregate stays at or above 50 MB/s (docs/performance.md). Then read the
230
+ largest legitimate counts from a couple of weeks of logs (`grep -A3
231
+ 'tripwire would have tripped' ~/.easy_sync/logs/*` and each run's `Checked in`
232
+ line) before setting `tripwire_enforce: true`.