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,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.
|
data/docs/performance.md
ADDED
|
@@ -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`.
|