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,202 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+ require 'fileutils'
5
+
6
+ module EasySync
7
+ # Loads ~/.easy_sync/config.yml, writing a commented sample on first run.
8
+ # The file is flat: its keys are the settings below.
9
+ class Config
10
+ HOME_DIR = File.join(Dir.home, '.easy_sync')
11
+ DEFAULT_FILENAME = 'config.yml'
12
+
13
+ DEFAULTS = {
14
+ # Each top-level folder of a share is placed on its own (kept on the
15
+ # same drive as the rest of the share while it fits); loose top-level
16
+ # files form one more unit. See docs/fine-placement.md.
17
+ sources: [{ path: '/Volumes/photos' }, { path: '/Volumes/tv' }, { path: '/Volumes/movies' }],
18
+ mount_root: '/Volumes',
19
+ reserve: '2gb', # headroom placement always leaves on a drive: APFS metadata, the .easy_sync copies, rsync temp files
20
+ keep_awake: true, # hold off idle sleep (caffeinate) for the length of a sync, on macOS
21
+ keep_logs: 20, # run logs kept under log_dir
22
+ purge: true, # remove files from the backup once they have been gone from the NAS long enough
23
+ grace_days: 7, # ...at least this many days
24
+ grace_runs: 2, # ...and confirmed missing on at least this many separate runs
25
+ scrub_stale_days: 30, # a drive is overdue for `scrub` once it's been this long since it was last fully checked
26
+ scrub_jobs: 4, # `scrub --all`/named targets scrub this many drives at once by default
27
+ # The tripwire (docs/tripwire.md): stop a sync that would overwrite or
28
+ # lose far more existing files on the drives than the library ever
29
+ # changes normally. Report-only until tripwire_enforce is true.
30
+ tripwire_enforce: false,
31
+ tripwire_run_files: 500, # whole run: stop everything at this many changed files (0 turns it off)
32
+ tripwire_folder_files: 50, # one folder: at least this many changed files...
33
+ tripwire_folder_ratio: 0.25, # ...and at least this share of its files on the drive
34
+ # Names skipped when choosing folders to place AND passed to every rsync as
35
+ # --exclude, so they are never copied at any depth (Synology recycle bins and
36
+ # thumbnail dirs, Synology Drive's .sync, macOS metadata, SMB leftovers).
37
+ exclude_folders: ['#recycle', '@eaDir', '.DS_Store', '.sync', '.TemporaryItems', '.Trashes',
38
+ '.smbdelete*', '.com.apple.timemachine.supported*', '.Spotlight-V100', '.fseventsd'],
39
+ rsync_args: []
40
+ }.freeze
41
+
42
+ PATH_KEYS = %i[manifest_path dashboard_path lock_path log_dir mount_root].freeze
43
+
44
+ # Comments written next to each key when the file is (re)written.
45
+ KEY_COMMENTS = {
46
+ sources: 'NAS shares, as mounted on this Mac. Manage with: easy_sync add-source / remove-source / sources',
47
+ mount_root: 'where the backup drives appear',
48
+ manifest_path: nil, dashboard_path: nil,
49
+ lock_path: 'refuses a second concurrent sync',
50
+ log_dir: 'one log per sync run', keep_logs: nil,
51
+ reserve: 'headroom placement always leaves on a drive',
52
+ keep_awake: 'caffeinate for the length of a sync',
53
+ purge: 'delete from the drives only after...',
54
+ grace_days: '...this many days missing on the NAS',
55
+ grace_runs: '...confirmed on this many separate runs',
56
+ scrub_stale_days: 'a drive is overdue for `scrub` after this many days unchecked',
57
+ scrub_jobs: '`scrub --all`/named targets scrub this many drives at once by default',
58
+ tripwire_enforce: 'false: the tripwire only reports what would have stopped a sync',
59
+ tripwire_run_files: 'stop a sync that would change this many existing files (0: off)',
60
+ tripwire_folder_files: 'hold back a folder with at least this many changed files...',
61
+ tripwire_folder_ratio: '...that are at least this share of its files',
62
+ exclude_folders: 'never placed, and excluded from every rsync at any depth',
63
+ rsync_args: 'extra arguments appended to every rsync'
64
+ }.freeze
65
+
66
+ SAMPLE = <<~YAML
67
+ # easy_sync configuration. Add your NAS shares with:
68
+ # easy_sync add-source /Volumes/<share>
69
+ :sources: []
70
+ :mount_root: "/Volumes" # where the backup drives appear
71
+ :manifest_path: "~/.easy_sync/manifest.sqlite3"
72
+ :dashboard_path: "~/.easy_sync/dashboard.html"
73
+ :lock_path: "~/.easy_sync/jbod.lock" # refuses a second concurrent sync
74
+ :log_dir: "~/.easy_sync/logs" # one log per sync run
75
+ :keep_logs: 20
76
+ :reserve: "2gb" # headroom placement always leaves on a drive
77
+ :keep_awake: true # caffeinate for the length of a sync
78
+ :purge: true # delete from the drives only after...
79
+ :grace_days: 7 # ...this many days missing on the NAS
80
+ :grace_runs: 2 # ...confirmed on this many separate runs
81
+ :scrub_stale_days: 30 # a drive is overdue for `scrub` after this many days unchecked
82
+ :scrub_jobs: 4 # `scrub --all`/named targets scrub this many drives at once by default
83
+ :tripwire_enforce: false # false: the tripwire only reports what would have stopped a sync
84
+ :tripwire_run_files: 500 # stop a sync that would change this many existing files (0: off)
85
+ :tripwire_folder_files: 50 # hold back a folder with at least this many changed files...
86
+ :tripwire_folder_ratio: 0.25 # ...that are at least this share of its files
87
+ :exclude_folders: ["#recycle", "@eaDir", ".DS_Store", ".sync", ".TemporaryItems", ".Trashes",
88
+ ".smbdelete*", ".com.apple.timemachine.supported*", ".Spotlight-V100", ".fseventsd"]
89
+ :rsync_args: [] # extra arguments appended to every rsync
90
+ YAML
91
+
92
+ def self.default_path
93
+ File.join(HOME_DIR, DEFAULT_FILENAME)
94
+ end
95
+
96
+ # DEFAULTS with the home-relative paths resolved now rather than at load
97
+ # time, so HOME_DIR is honoured wherever it points (tests redirect it).
98
+ def self.defaults
99
+ DEFAULTS.merge(manifest_path: File.join(HOME_DIR, 'manifest.sqlite3'),
100
+ dashboard_path: File.join(HOME_DIR, 'dashboard.html'),
101
+ lock_path: File.join(HOME_DIR, 'jbod.lock'),
102
+ log_dir: File.join(HOME_DIR, 'logs'))
103
+ end
104
+
105
+ # Loads the config at +path+, writing the sample first if it is missing.
106
+ # Returns [config, status] where status is :generated or nil.
107
+ def self.load(path = default_path)
108
+ status = nil
109
+ unless File.exist?(path)
110
+ write_sample(path)
111
+ status = :generated
112
+ end
113
+ data = YAML.safe_load_file(path, permitted_classes: [Symbol], symbolize_names: true) || {}
114
+ [new(data, path: path), status]
115
+ end
116
+
117
+ def self.write_sample(path)
118
+ FileUtils.mkdir_p(File.dirname(path))
119
+ File.write(path, SAMPLE)
120
+ end
121
+
122
+ # Renders +data+ as commented YAML: sources first, then the known keys in
123
+ # order, then anything else. Comments come from KEY_COMMENTS.
124
+ def self.dump(data)
125
+ out = +"# easy_sync configuration. Managed by `easy_sync add-source` and friends;\n# editing by hand is fine too.\n"
126
+ order = [:sources] + DEFAULTS.keys.reject { |k| k == :sources } + (data.keys - DEFAULTS.keys - [:sources])
127
+ order.uniq.each do |key|
128
+ next unless data.key?(key)
129
+
130
+ value = data[key]
131
+ comment = KEY_COMMENTS[key]
132
+ if key == :sources
133
+ sources = Array(value)
134
+ out << "#{(sources.empty? ? ':sources: []' : ':sources:').ljust(40)}# #{comment}\n"
135
+ sources.each do |src|
136
+ out << "- :path: #{(src.is_a?(Hash) ? src[:path] : src).to_s.inspect}\n"
137
+ end
138
+ elsif (value.is_a?(Array) || value.is_a?(Hash)) && !value.empty?
139
+ # Collections always go in block form under the key; a one-element
140
+ # array rendered inline is not valid YAML.
141
+ out << "#{":#{key}:".ljust(40)}#{comment ? "# #{comment}" : ''}".rstrip << "\n"
142
+ YAML.dump(value).sub(/\A---\s?/, '').lines.each { |l| out << " #{l.chomp}\n" unless l.strip.empty? }
143
+ else
144
+ rendered = value.is_a?(Array) || value.is_a?(Hash) ? (value.is_a?(Array) ? '[]' : '{}') \
145
+ : YAML.dump(value).sub(/\A---\s?/, '').lines.map(&:chomp).reject { |l| l == '...' }.join
146
+ line = ":#{key}: #{rendered}"
147
+ out << (comment ? "#{line.ljust(40)}# #{comment}\n" : "#{line}\n")
148
+ end
149
+ end
150
+ out
151
+ end
152
+
153
+ # Writes the current data back to +path+ (or the path it was loaded from).
154
+ def save(to = path)
155
+ raise Error, 'no config path to save to' unless to
156
+
157
+ FileUtils.mkdir_p(File.dirname(to))
158
+ File.write(to, self.class.dump(data))
159
+ to
160
+ end
161
+
162
+ # -- sources, as the CLI edits them --------------------------------
163
+
164
+ def source_entries
165
+ Array(data[:sources]).map { |e| { path: (e.is_a?(Hash) ? e[:path] : e).to_s } }
166
+ end
167
+
168
+ def add_source(path)
169
+ raise Error, "#{path} is already a source" if source_entries.any? { |e| e[:path] == path }
170
+
171
+ data[:sources] = source_entries + [{ path: path }]
172
+ end
173
+
174
+ def remove_source(path)
175
+ before = source_entries
176
+ data[:sources] = before.reject { |e| e[:path] == path }
177
+ raise Error, "#{path} is not a source" if data[:sources].size == before.size
178
+ end
179
+
180
+ attr_reader :data, :path
181
+
182
+ def initialize(data, path: nil)
183
+ @data = data
184
+ @path = path
185
+ end
186
+
187
+ def [](key) = settings[key]
188
+
189
+ # Merged settings with `~` expanded in every path, so a config copied
190
+ # from the README ("~/.easy_sync/...") never creates a literal "~" directory.
191
+ def settings
192
+ merged = self.class.defaults.merge(data)
193
+ PATH_KEYS.each { |k| merged[k] = File.expand_path(merged[k]) if merged[k].is_a?(String) }
194
+ merged[:sources] = Array(merged[:sources]).map do |e|
195
+ { path: File.expand_path((e.is_a?(Hash) ? e[:path] : e).to_s) }
196
+ end
197
+ merged[:config_path] = path # so a copy of the config can travel with the drives
198
+ merged[:reserve_bytes] = Jbod::Placement.parse_size(merged[:reserve])
199
+ merged
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module EasySync
4
+ class Error < StandardError; end
5
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ module EasySync
6
+ module Jbod
7
+ # Reads Backblaze Personal's own local state files, read-only, to say
8
+ # whether each drive's contents have reached Backblaze: the same numbers
9
+ # its menu-bar icon shows. Backblaze backs up *from the drives*, so a
10
+ # drive powered off before its upload finishes leaves that data offsite
11
+ # only on the NAS until the next time it's connected.
12
+ #
13
+ # Files used (all under DATA_DIR, world-readable):
14
+ # - bzvolumes.xml: each volume Backblaze knows, by GUID, with its mount
15
+ # point (hex-encoded, with a trailing slash).
16
+ # - bzreports/bzstat_remainingbackup.xml: files and bytes still to upload,
17
+ # per volume GUID.
18
+ # - bzfilelists/<GUID>_*filelist.dat: rewritten each time Backblaze scans
19
+ # that volume, so its mtime is when it last looked for new files.
20
+ # - bzreports/bzstat_lastbackupcompleted.xml: when a backup pass last
21
+ # finished.
22
+ # The XML is one self-closing tag per line, so attributes are read with a
23
+ # regex rather than taking on an XML parser dependency.
24
+ class Backblaze
25
+ DATA_DIR = '/Library/Backblaze.bzpkg/bzdata'
26
+
27
+ Volume = Struct.new(:guid, :mount_point, :remaining_files, :remaining_bytes, :scanned_at, keyword_init: true)
28
+
29
+ # +state+ is :uploading (files left), :waiting (nothing left, but
30
+ # Backblaze hasn't scanned the drive since easy_sync last copied data to
31
+ # it, so the zero is stale), :done, or :unknown (Backblaze doesn't know
32
+ # this volume).
33
+ DriveState = Struct.new(:state, :files, :bytes, :scanned_at, keyword_init: true) do
34
+ def done? = state == :done
35
+
36
+ def label
37
+ case state
38
+ when :done then 'up to date'
39
+ when :uploading
40
+ "uploading, #{files.to_s.gsub(/(\d)(?=(\d{3})+\z)/, '\1,')} file#{'s' if files != 1} " \
41
+ "(#{Placement.format_bytes(bytes)}) left"
42
+ when :waiting then 'not scanned since the last sync'
43
+ else 'not in Backblaze'
44
+ end
45
+ end
46
+ end
47
+
48
+ # serial => DriveState for +drives+ (Drive structs). A drive not
49
+ # mounted now is looked up at its usual mount point, which is where
50
+ # Backblaze last saw it.
51
+ def drive_states(drives, mounted:, mount_root:, last_copied_at:)
52
+ drives.to_h do |d|
53
+ mount = mounted[d.serial_number]&.mount_point || File.join(mount_root, d.friendly_name)
54
+ [d.serial_number, drive_state(mount, last_copied_at[d.serial_number])]
55
+ end
56
+ end
57
+
58
+ attr_reader :last_completed_at
59
+
60
+ # nil when Backblaze isn't installed or its state can't be read: callers
61
+ # then show nothing at all about it.
62
+ def self.read(dir = DATA_DIR)
63
+ return nil unless File.file?(File.join(dir, 'bzvolumes.xml'))
64
+
65
+ new(dir)
66
+ rescue SystemCallError
67
+ nil
68
+ end
69
+
70
+ def initialize(dir)
71
+ @dir = dir
72
+ remaining = tags('bzreports/bzstat_remainingbackup.xml', 'bzvolume').to_h { |a| [a['bzVolumeGuid'], a] }
73
+ @volumes = tags('bzvolumes.xml', 'bzvolume').to_h do |a|
74
+ guid = a['bzVolumeGuid']
75
+ left = remaining[guid] || {}
76
+ mount = [a['mountPointPathHex'].to_s].pack('H*').chomp('/')
77
+ [mount, Volume.new(guid: guid, mount_point: mount, remaining_files: left['pervol_remaining_files_numfiles'].to_i,
78
+ remaining_bytes: left['pervol_remaining_files_numbytes'].to_i, scanned_at: scanned_at(guid))]
79
+ end
80
+ millis = tags('bzreports/bzstat_lastbackupcompleted.xml', 'lastbackupcompleted').first&.fetch('gmt_millis', nil)
81
+ @last_completed_at = millis && Time.at(millis.to_i / 1000.0).utc
82
+ end
83
+
84
+ def volume(mount_point) = @volumes[mount_point.to_s.chomp('/')]
85
+
86
+ # +last_copied_at+ is when easy_sync last copied data onto the drive
87
+ # (ISO 8601, or nil if never).
88
+ def drive_state(mount_point, last_copied_at)
89
+ vol = volume(mount_point) or return DriveState.new(state: :unknown)
90
+
91
+ state = if vol.remaining_files.positive? then :uploading
92
+ elsif vol.scanned_at.nil? || (last_copied_at && vol.scanned_at < Time.parse(last_copied_at)) then :waiting
93
+ else :done
94
+ end
95
+ DriveState.new(state: state, files: vol.remaining_files, bytes: vol.remaining_bytes, scanned_at: vol.scanned_at)
96
+ end
97
+
98
+ private
99
+
100
+ def tags(relative, name)
101
+ path = File.join(@dir, relative)
102
+ return [] unless File.file?(path)
103
+
104
+ File.read(path).scan(/<#{name}\s([^>]*?)\/?>/).map { |(attrs)| attrs.scan(/(\w+)="([^"]*)"/).to_h }
105
+ end
106
+
107
+ def scanned_at(guid)
108
+ list = Dir.glob(File.join(@dir, 'bzfilelists', "#{guid}_*filelist.dat")).first
109
+ list && File.mtime(list).utc
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module EasySync
6
+ module Jbod
7
+ # `easy_sync benchmark`: measures one mounted drive's sequential write and
8
+ # read speed by writing a test file into its .easy_sync folder, reading it
9
+ # back, and deleting it. A falling rate on one drive is an early failure
10
+ # sign SMART won't necessarily show, so every result is kept (the last
11
+ # Manifest::BENCHMARKS_KEPT per drive) and compared against the drive's own
12
+ # earlier runs. Method and baseline numbers: docs/performance.md.
13
+ #
14
+ # The only thing it ever writes on the drive is TEST_FILE, inside
15
+ # DRIVE_DIR (which rsync, scrub and clean never look at). It is removed
16
+ # again whatever happens, and a leftover from a killed run is removed
17
+ # before the next one starts.
18
+ class Benchmarker
19
+ TEST_FILE = 'benchmark.tmp'
20
+ NOCACHE_CMD = 48 # F_NOCACHE, macOS only; best effort elsewhere
21
+ CHUNK_SIZE = 8 * 1024 * 1024
22
+ # Written round-robin. Generated before the clock starts, so the timing
23
+ # is the drive's, not Random's (~2.7 GB/s here, enough to cost ~7% of a
24
+ # 200 MB/s write if done inline). Spinning drives don't compress or
25
+ # dedupe, so repeating 64 MB of random data is as good as fresh data.
26
+ POOL_CHUNKS = 8
27
+ # A drive's own earlier runs vary by about ±7% (docs/performance.md), so
28
+ # only a drop well past that, against the median of at least
29
+ # MIN_HISTORY earlier runs, is called out.
30
+ SLOWER_THRESHOLD = 0.85
31
+ MIN_HISTORY = 3
32
+
33
+ Result = Struct.new(:drive, :bytes, :write_seconds, :read_seconds, :used_bytes, :error, keyword_init: true) do
34
+ def ok? = error.nil?
35
+ def write_mb_s = mb_per_s(write_seconds)
36
+ def read_mb_s = mb_per_s(read_seconds)
37
+
38
+ private
39
+
40
+ def mb_per_s(seconds) = seconds.to_f.positive? ? (bytes.to_f / seconds) / (1024 * 1024) : 0.0
41
+ end
42
+
43
+ # How one run compares with the same drive's earlier ones. +earlier+
44
+ # is how many earlier runs the medians come from; the medians are nil
45
+ # when there are none.
46
+ Comparison = Struct.new(:earlier, :write_median, :read_median, keyword_init: true) do
47
+ def write_change(mb_s) = change(mb_s, write_median)
48
+ def read_change(mb_s) = change(mb_s, read_median)
49
+
50
+ def enough? = earlier >= MIN_HISTORY
51
+
52
+ # :write and/or :read, for whichever fell below SLOWER_THRESHOLD of its median.
53
+ def slower(write_mb_s, read_mb_s)
54
+ return [] unless enough?
55
+
56
+ { write: [write_mb_s, write_median], read: [read_mb_s, read_median] }
57
+ .select { |_, (now, median)| now < median * SLOWER_THRESHOLD }.keys
58
+ end
59
+
60
+ private
61
+
62
+ def change(mb_s, median) = median&.positive? ? (mb_s / median) - 1 : nil
63
+ end
64
+
65
+ def self.compare(earlier_runs)
66
+ median = lambda do |values|
67
+ sorted = values.sort
68
+ mid = sorted.size / 2
69
+ sorted.size.odd? ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2.0
70
+ end
71
+ return Comparison.new(earlier: 0) if earlier_runs.empty?
72
+
73
+ Comparison.new(earlier: earlier_runs.size, write_median: median.call(earlier_runs.map(&:write_mb_s)),
74
+ read_median: median.call(earlier_runs.map(&:read_mb_s)))
75
+ end
76
+
77
+ def initialize(timer: -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) })
78
+ @timer = timer
79
+ end
80
+
81
+ # +mounted_drive+ is a MountedDrive struct. Returns a Result; a failure
82
+ # (drive unplugged mid-run, disk full, I/O error) is reported in its
83
+ # +error+ rather than raised, so one bad drive doesn't stop the others.
84
+ def run(mounted_drive, size:)
85
+ path = File.join(mounted_drive.mount_point, DRIVE_DIR, TEST_FILE)
86
+ result = Result.new(drive: mounted_drive.friendly_name, bytes: size, used_bytes: mounted_drive.used_bytes)
87
+ remove(path)
88
+ pool = Array.new(POOL_CHUNKS) { Random.bytes(CHUNK_SIZE) }
89
+ result.write_seconds = timed { write_test_file(path, size, pool) }
90
+ result.read_seconds = timed { read_test_file(path) }
91
+ result
92
+ rescue SystemCallError, IOError => e
93
+ result.error = File.file?(File.join(mounted_drive.mount_point, MARKER_FILE)) ? e.message : 'unmounted mid-run'
94
+ result
95
+ ensure
96
+ remove(path)
97
+ end
98
+
99
+ private
100
+
101
+ def timed
102
+ started = @timer.call
103
+ yield
104
+ @timer.call - started
105
+ end
106
+
107
+ # The closing fsync is inside the timing: without it this measures how
108
+ # fast macOS accepts writes into RAM, not how fast the drive stores them.
109
+ def write_test_file(path, size, pool)
110
+ FileUtils.mkdir_p(File.dirname(path))
111
+ File.open(path, 'wb') do |io|
112
+ nocache(io)
113
+ written = 0
114
+ i = 0
115
+ while written < size
116
+ chunk = pool[i % pool.size]
117
+ chunk = chunk.byteslice(0, size - written) if size - written < chunk.bytesize
118
+ written += io.write(chunk)
119
+ i += 1
120
+ end
121
+ io.fsync
122
+ end
123
+ end
124
+
125
+ # F_NOCACHE alone still serves pages already in the cache; evicting
126
+ # first makes the read come off the platter (see PageCache).
127
+ def read_test_file(path)
128
+ buffer = String.new(capacity: CHUNK_SIZE)
129
+ File.open(path, 'rb') do |io|
130
+ nocache(io)
131
+ PageCache.evict(io)
132
+ nil while io.read(CHUNK_SIZE, buffer)
133
+ end
134
+ end
135
+
136
+ def nocache(io)
137
+ io.fcntl(NOCACHE_CMD, 1)
138
+ rescue SystemCallError, NotImplementedError
139
+ nil # best effort: not every filesystem/platform supports F_NOCACHE
140
+ end
141
+
142
+ def remove(path)
143
+ File.delete(path) if File.exist?(path)
144
+ rescue SystemCallError
145
+ nil
146
+ end
147
+ end
148
+ end
149
+ end
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ module EasySync
6
+ module Jbod
7
+ # `easy_sync clean`: removes from the drives, right away, anything whose
8
+ # name matches exclude_folders. Such entries only exist because they were
9
+ # copied before the exclusion did (or before the exclusion was added), so
10
+ # there is no reason to wait out the deletion grace period for them. Walks
11
+ # only the destinations of placed folders, so a drive's own .easy_sync and
12
+ # anything not managed here is never touched.
13
+ class Cleaner
14
+ Result = Struct.new(:removed, :bytes, :would_remove, keyword_init: true) do
15
+ def initialize(**)
16
+ super
17
+ self.removed ||= []
18
+ self.would_remove ||= []
19
+ self.bytes ||= 0
20
+ end
21
+ end
22
+
23
+ def initialize(manifest, excludes:, out: $stdout)
24
+ @manifest = manifest
25
+ @excludes = Array(excludes)
26
+ @out = out
27
+ end
28
+
29
+ # +mounted+ are MountedDrive structs. Returns a Result.
30
+ def run(mounted, dry_run: false)
31
+ result = Result.new
32
+ mounted.each do |drive|
33
+ @manifest.folders_on(drive.serial_number).each do |folder|
34
+ base = File.join(drive.mount_point, folder.folder_path)
35
+ next unless Dir.exist?(base)
36
+
37
+ matches_under(base, descend: !folder.root?).each do |path|
38
+ rel = path.delete_prefix("#{base}/")
39
+ size = size_of(path)
40
+ if dry_run
41
+ @out.puts " would remove #{folder.folder_path}/#{rel} (#{Placement.format_bytes(size)}) from #{drive.friendly_name}"
42
+ result.would_remove << [folder.folder_path, rel]
43
+ else
44
+ kind = File.directory?(path) ? 'dir' : 'file'
45
+ FileUtils.rm_rf(path)
46
+ @out.puts " removed #{folder.folder_path}/#{rel} (#{Placement.format_bytes(size)}) from #{drive.friendly_name}"
47
+ result.removed << [folder.folder_path, rel]
48
+ result.bytes += size
49
+ @manifest.record_cleaned(folder_path: folder.folder_path, relative_path: rel, kind: kind,
50
+ drive_serial: drive.serial_number)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ # Junk that was already gone from the drive (removed by hand, say) may
56
+ # still have pending rows; those need no grace period either.
57
+ @manifest.forget_pending_matching(@excludes) unless dry_run
58
+ result
59
+ end
60
+
61
+ private
62
+
63
+ # Deepest matches are not needed: removing a matching directory takes
64
+ # its contents with it, so stop descending at the first match. A
65
+ # root-files unit (descend: false) owns only its top level.
66
+ def matches_under(dir, descend: true)
67
+ found = []
68
+ Dir.each_child(dir) do |name|
69
+ path = File.join(dir, name)
70
+ if excluded?(name)
71
+ found << path
72
+ elsif descend && File.directory?(path) && !File.symlink?(path)
73
+ found.concat(matches_under(path))
74
+ end
75
+ end
76
+ found.sort
77
+ rescue SystemCallError
78
+ found
79
+ end
80
+
81
+ def excluded?(name)
82
+ @excludes.any? { |pat| File.fnmatch?(pat, name, File::FNM_DOTMATCH) }
83
+ end
84
+
85
+ def size_of(path)
86
+ return File.size(path) unless File.directory?(path)
87
+
88
+ Dir.glob(File.join(path, '**', '*'), File::FNM_DOTMATCH).sum { |f| File.file?(f) ? File.size(f) : 0 }
89
+ end
90
+
91
+ end
92
+ end
93
+ end