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
data/bin/easy_sync CHANGED
@@ -1,4 +1,11 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ unless RUBY_PLATFORM.include?('darwin')
5
+ warn 'easy_sync is a macOS-only tool: it relies on diskutil (APFS volume identity and lock state) ' \
6
+ 'and caffeinate. See the README.'
7
+ exit 1
8
+ end
2
9
 
3
10
  require 'easy_sync'
4
- EasySync::SyncRunner.new.run
11
+ exit EasySync::CLI.new(ARGV).run
@@ -0,0 +1,174 @@
1
+ # Fine placement: no `split` setting (built and verified on real hardware)
2
+
3
+ Every share is placed one top-level folder at a time, plus one unit for its
4
+ loose top-level files. The `split:` setting is gone, and `reassign ... --copy`
5
+ moves folders between drives by hand. If anything here disagrees with the
6
+ code, the code wins; fix this doc.
7
+
8
+ Shares that an earlier build placed whole were converted in place by an
9
+ `easy_sync split SHARE` command. It converted the last two on the real fleet
10
+ (`synology` and `pro`, 2026-09-23) and was removed on 2026-09-25, together
11
+ with `Runner`'s support for whole-share rows. The two `split` events it wrote
12
+ are still in `placement_history`.
13
+
14
+ ## Problem
15
+
16
+ Each source used to carry `split: true|false`:
17
+
18
+ - `false`: the whole share is one unit (`folders.folder_path` = `"synology"`).
19
+ - `true`: each top-level subfolder is a unit (`"synology/Movies"`), and loose
20
+ top-level files are silently not backed up.
21
+
22
+ That caused three problems:
23
+
24
+ 1. **Changing the setting could delete live data.** After a flip the old key
25
+ disappears from the listing. `Runner#reconcile_manifest` recorded it as a
26
+ whole-folder `missing_on_nas` deletion, and after `grace_days`/`grace_runs`
27
+ `Purger` ran `rm_rf <drive>/synology`. The new per-folder units sync to
28
+ `<drive>/synology/<sub>`, *inside* that path, so they would be deleted too.
29
+ Merging back had the same problem in reverse.
30
+ 2. **Users had to choose, and some couldn't.** `synology` had to stay whole
31
+ only because of loose root files, even though it is ~2 TB.
32
+ 3. **A huge unit freezes the sync ETA.** `SyncEta` only learns when a folder
33
+ finishes, so a 2 TB unit shows the same "remaining" figure for hours.
34
+
35
+ ## Decisions (settled, don't re-open)
36
+
37
+ - **No automatic moves, ever.** mergerfs and unRAID don't rebalance
38
+ automatically either: they place small units when data arrives, and
39
+ moving data is a manual tool (`mergerfs.balance`, unBALANCE). Here moving
40
+ is even more expensive: Backblaze backs up *from the drives*, so a moved
41
+ folder is uploaded again, and the drives are often offline. Placed folders
42
+ change drive only through `reassign`.
43
+ - **Always one unit per top-level folder, plus a root-files unit.** No
44
+ setting. A share can be any size, and every drive stays readable in Finder.
45
+ - **Keep a share together while it fits.** `Placement.choose(prefer:)` picks,
46
+ among drives already holding part of the share, the one with the most free
47
+ space that the unit fits on. Otherwise it picks the most free space
48
+ overall. This keeps the "one collection per drive" browsing the old whole
49
+ setting gave, and splits a share only when it has to.
50
+ - **The Purger guard stays even though a normal sync can no longer reach the
51
+ bug.** A hand-edited manifest, or a future bug, must not be able to
52
+ reproduce it.
53
+
54
+ ## Schema
55
+
56
+ `folders.scope TEXT NOT NULL DEFAULT 'tree'`, added by checking
57
+ `PRAGMA table_info` (CLAUDE.md: the real manifest is stamped version 5).
58
+
59
+ - `tree`: the folder and everything under it.
60
+ - `root`: a share's loose top-level files only. `folder_path` is the share
61
+ name; the share's subfolders are separate `tree` folders.
62
+
63
+ ## Components
64
+
65
+ **`Runner#units_of`** (`runner.rb`). Emit one unit per top-level subfolder, plus
66
+ `SourceFolder(root_only: true)` when the share has loose files or already
67
+ has a root row. A root row is emitted even once its files are gone, so their
68
+ removal is noticed. `Jbod::ShareScan` is the one rule for what counts as a
69
+ subfolder or a loose file (hidden and `exclude_folders` names never count).
70
+ A root unit is sized by summing its loose files, never by `du` of the share.
71
+
72
+ **`Mirror` `root_only`.** The copy pass adds `--exclude=/*/` (anchored,
73
+ directories only). The probe uses `--delete --exclude=/*/` **without**
74
+ `--delete-excluded`, so rsync protects the excluded subfolders and never
75
+ reports them. Checked against rsync 3.5.0 on scratch directories: the copy
76
+ moved only the two top-level files, and the probe reported only a stale
77
+ top-level file, not `TV/` (present on the destination only) or `Movies/`.
78
+
79
+ **`Purger`.**
80
+ - Before any whole-folder `rm_rf`, it skips the candidate if a live folder on
81
+ the same drive sits below its path, or if its path sits below a live
82
+ `tree` folder. The warning says `<live> is a live folder that overlaps it`.
83
+ The candidate is kept and checked again every run.
84
+ - A whole-folder candidate for a `root` folder deletes only the top-level
85
+ files, never `rm_rf`.
86
+
87
+ **`Scrubber` / `Cleaner` / `Restorer`.** For a `root` folder, each touches
88
+ only the top level: scrub hashes only top-level files, clean removes only
89
+ top-level junk, restore adds `--exclude=/*/`. `restore SHARE` includes the
90
+ root unit along with every folder under the share.
91
+
92
+ **`reassign FOLDER|SHARE DRIVE [--copy]`.** A share name moves every folder
93
+ of the share (root unit included) except those already on the target. The
94
+ capacity check covers the total. `--copy` (under `RunLock`, both drives
95
+ mounted) runs one local `rsync -a --partial` per folder, the same way
96
+ `copy_drive` does, adding `--exclude=/*/` for a root unit. It then calls
97
+ `reassign_folder`, so the old copy's cleanup still waits for an `ok` sync on
98
+ the new drive (`Purger#ready?`). A failed copy leaves that folder recorded
99
+ where it was.
100
+
101
+ **Removed:** `split:` in the config (a leftover key is ignored and dropped on
102
+ save), `add-source --split/--whole` and its guessing logic, `plan --apply`
103
+ and the split advice. `plan` now only reports sizes and whether every folder
104
+ fits. The dashboard's "loose files not backed up" alert is gone, since they
105
+ are backed up now.
106
+
107
+ ## Tests
108
+
109
+ `purger_spec` (overlap guard both ways, `-archive` names, other drives, root
110
+ units), `mirror_spec` (root-only argv), `placement_spec` (`prefer:`),
111
+ `runner_spec` (units, root unit, affinity within a run and across runs, a
112
+ folder gone from the NAS flagged alone), `scrubber/cleaner/restorer_spec`
113
+ (root units), `cli_spec` (`reassign --copy`, share form, failure, drive not
114
+ mounted).
115
+
116
+ ## Verify on real hardware before merging
117
+
118
+ Kept as the record of what was checked; steps 1-2 used `split`, which is
119
+ gone now.
120
+
121
+ On `jbod-test-1`/`jbod-test-2` with a scratch `--config` (CLAUDE.md, "Verify
122
+ live"), and a scratch share with `Movies/`, `TV/` and two loose root files:
123
+
124
+ 1. Make the share a whole placement on `jbod-test-1` (a `tree` row keyed by
125
+ the share), sync it.
126
+ 2. `split SHARE`: no rsync runs; rows for `SHARE/Movies`, `SHARE/TV`, and
127
+ `SHARE` becomes `root`; inodes unchanged.
128
+ 3. `sync`: ~0 bytes transferred; the root unit copies only loose files.
129
+ Delete a loose file on the NAS → only that file becomes pending. Delete
130
+ `TV/` on the NAS → only `SHARE/TV` becomes pending.
131
+ 4. A new share syncs as per-folder units, all on one drive while it fits.
132
+ 5. `reassign SHARE jbod-test-2 --copy`: local rsync only; the next sync
133
+ transfers ~0 bytes; the old copy is only removed after an `ok` sync and
134
+ the grace period.
135
+ 6. Hand-craft the original bug (a whole-folder pending over a live
136
+ subfolder), expire it: Purger skips it with a warning, and nothing is
137
+ deleted.
138
+
139
+ ## Results (2026-09-23, jbod-test-1/2, scratch config, all six steps passed)
140
+
141
+ The scratch "NAS" was a local tree (`fpshare`: `Movies/` 64 MB, `TV/` 32 MB,
142
+ two loose files; `fpshare/` placed whole on `jbod-test-1` through a `tree`
143
+ row). The drives' own `.easy_sync/` folders were backed up first and
144
+ restored afterwards, identical.
145
+
146
+ 1. The whole share synced as one unit, 100.7 MB; `sources` said
147
+ "placed whole; `easy_sync split fpshare` places its folders one by one".
148
+ 2. `split fpshare --dry-run` wrote nothing. `split fpshare` gave
149
+ `fpshare` (root, 20 B), `fpshare/Movies`, `fpshare/TV`, all on
150
+ jbod-test-1, with 0 pending deletions. Every file kept its inode.
151
+ 3. The next `sync` transferred **0 bytes** for all three units. The root
152
+ unit's rsync total was 20 B (only the loose files), so `--exclude=/*/`
153
+ behaves on the real drive as it did on scratch dirs. After deleting
154
+ `loose2.txt` and `TV/` on the NAS, exactly two candidates appeared:
155
+ (`fpshare/TV`, whole folder) and (`fpshare`, `loose2.txt`); the root
156
+ unit's probe did not report `TV/`. Once expired, the purge removed just
157
+ those two; `Movies/` and `loose1.txt` were untouched.
158
+ 4. `add-source fpnew` dropped the leftover `:split: false` from the config
159
+ on save; `plan` only reported. The sync placed `fpnew/A` on the drive
160
+ with the most free space and `fpnew/B` "with the rest of fpnew".
161
+ 5. `reassign fpshare jbod-test-2 --copy` ran two local rsyncs (the root
162
+ unit's copied only `loose1.txt`). The next sync transferred 0 bytes. The
163
+ old copies were removed from jbod-test-1 only after that `ok` sync and
164
+ the (backdated) grace period. That run left an empty `fpshare/`
165
+ directory behind, so `Purger` now removes a share directory once its
166
+ last folder is gone from the drive (specced).
167
+ 6. A stale, expired whole-folder candidate for `fpnew` over the live
168
+ `fpnew/A` and `fpnew/B`: sync warned "not purging fpnew (whole folder):
169
+ fpnew/A is a live folder that overlaps it on jbod-test-2; not deleting
170
+ (resolve by hand)", and both files survived. A first attempt with a
171
+ `tree` row for `fpnew` never reached Purger at all: the share is on the
172
+ NAS, so the sync cleared the candidate as reappeared. The original bug
173
+ path cannot occur through a normal sync any more; the guard is the
174
+ backstop.
@@ -0,0 +1,71 @@
1
+ # Things learned on real hardware
2
+
3
+ Do not re-derive these: each was found on real drives, rsync or macOS tools,
4
+ and the specs only encode the assumption, they cannot check it. Read the
5
+ relevant entry before changing code that touches that tool.
6
+
7
+ - rsync 3.5: `--exclude=/*/` (anchored, directories only) copies only a
8
+ source's top-level files, and a `--delete` probe with it but WITHOUT
9
+ `--delete-excluded` never reports the destination's subdirectories, even
10
+ ones missing from the source. That is how a share's root-files unit lives
11
+ in the same directory as the share's per-folder units.
12
+ - rsync 3.5 with `--delete --max-delete=0` does NOT name the files it skips; it
13
+ prints "N skipped" and exits 25. Deletions are found with a separate read-only
14
+ probe: `rsync -an --itemize-changes --delete --delete-excluded`.
15
+ - smartctl's exit status is inconsistent (exit 0 with "not supported" on some
16
+ devices, 2 on others); parse output, never trust the code. USB bridges often
17
+ hide SMART entirely: that is the 'unknown' state, not an error.
18
+ - `diskutil apfs list` shows a locked volume by name with "FileVault: Yes (Locked)"
19
+ and "Mount Point: Not Mounted"; the Name and FileVault lines are several lines
20
+ apart. `diskutil apfs lockVolume/unlockVolume` accept the volume name.
21
+ - A smartctl read can fail once on a drive that reads fine minutes before
22
+ and after (backup-07-6tb in the ThunderBay, 2026-09-23). `smart_health`
23
+ retries, and `Runner#check_health` keeps the last known status rather
24
+ than overwriting it with 'unknown'.
25
+ - Physical disk for smartctl: `diskutil info <mount>` -> "Part of Whole" ->
26
+ `diskutil info <that>` -> "APFS Physical Store".
27
+ - `caffeinate -i -w <pid>` exits on its own when the pid does; spawn it detached.
28
+ - The real `~/.easy_sync/manifest.sqlite3` is stamped `user_version` 5 (left over
29
+ from pre-2.0 builds) although 2.0's schema counts from 1. Add columns by
30
+ checking `PRAGMA table_info`, never by comparing version numbers: a
31
+ version-gated `ALTER TABLE` was skipped on the real manifest and crashed the
32
+ first sync after it (`no such column: model`).
33
+ - `du` over SMB: seconds for thousands of single-file movie folders, minutes for a
34
+ share with hundreds of thousands of files. rsync's read-only walk of an
35
+ unchanged folder is ~0.15 s.
36
+ - Headless Chrome refuses windows narrower than ~500 px; render phone widths
37
+ inside a 400 px iframe.
38
+ - `F_NOCACHE` does NOT make a read skip the page cache for pages already in
39
+ it; it only stops the read adding new ones. A just-synced file scrubbed at
40
+ 2 GB/s from a USB drive until `Jbod::PageCache.evict` (mmap +
41
+ `msync(MS_SYNC|MS_INVALIDATE)`, via Fiddle) dropped its pages first. Any
42
+ "read it off the platter" code needs both. Fiddle is a bundled gem in Ruby
43
+ 4.0, so it's declared in the gemspec.
44
+ - `diskutil eject disk4` (the whole physical disk, from `physical_disk_for`
45
+ minus its partition suffix) on an encrypted APFS USB drive prints
46
+ `Disk disk4 ejected`, exits 0, and the volume leaves `/Volumes` and the
47
+ disk leaves `diskutil list` (jbod-test-1/2, 2026-09-26). Getting it back
48
+ takes a replug. With a file held open on the volume, the eject fails and
49
+ macOS names the holder ("dissented by PID 38139 (/bin/sleep)"): `easy_sync
50
+ eject` reported "in use by pid 38139 (/bin/sleep)", exited 1, and the
51
+ drive stayed mounted (jbod-test-1, 2026-09-26).
52
+ - Backblaze Personal's state (read by `Jbod::Backblaze`, never written) is
53
+ world-readable under `/Library/Backblaze.bzpkg/bzdata`: `bzvolumes.xml`
54
+ maps a volume GUID to its mount point as hex with a trailing slash;
55
+ `bzreports/bzstat_remainingbackup.xml` has files/bytes left per GUID;
56
+ `bzfilelists/<GUID>______filelist.dat`'s mtime is that volume's last
57
+ scan (2026-09-26: 09:47-10:01 local for the fleet, all 0 left). A zero
58
+ counted before easy_sync's last copy onto the drive is stale, hence
59
+ the `waiting` state. spec_helper stubs `Backblaze::DATA_DIR` into the
60
+ temp dir: no spec may read the real install.
61
+ - Pulling a drive's cable mid-read: the marker file vanishes and reads fail;
62
+ scrub stops as "unmounted" without flagging the in-flight file. A
63
+ FileVault test drive came back mounted and unlocked on replug.
64
+ - Scrub reads a fleet drive (spinning SATA, ThunderBay) at ~201 MB/s;
65
+ `jbod-test-1` (USB) at ~150 MB/s.
66
+ - Forwarding a signal to a child process (Ctrl-C during a copy) must signal its
67
+ whole process group, not just its pid: `Open3.popen2e(*argv, pgroup: true)`,
68
+ then `Process.kill('INT', -wait.pid)` (negative pid = the group). Signalling
69
+ only the child's own pid left rsync's helper processes running and Ruby
70
+ blocked waiting on them; a plain `sh -c '...; sleep N'` child in specs won't
71
+ even forward the signal to its own `sleep` without this.