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
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::
|
|
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.
|