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,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,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
|