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,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasySync
|
|
4
|
+
module Jbod
|
|
5
|
+
# Runs `scrub` against several drives at once: one thread per drive, one
|
|
6
|
+
# drive per thread (never two readers on the same spindle). The work is
|
|
7
|
+
# I/O-bound (MRI releases the GVL during blocking File#read), so threads
|
|
8
|
+
# are enough - no processes, no new gems, none of the process-group
|
|
9
|
+
# signal-forwarding CLAUDE.md warns sync needs. See docs/parallel-scrub.md
|
|
10
|
+
# for the full design and the measured numbers.
|
|
11
|
+
class ScrubPool
|
|
12
|
+
# +open_manifest+ is a lambda returning a fresh Manifest (one per
|
|
13
|
+
# worker: SQLite in WAL, one connection per thread). +scrubber_options+
|
|
14
|
+
# are passed to every worker's Scrubber.new besides manifest/clock/out.
|
|
15
|
+
# +lock+ is the RunLock the caller already holds; workers call
|
|
16
|
+
# +lock.note+ with the full set of drives currently being scrubbed
|
|
17
|
+
# every time that set changes. +deadline+, if given, is a Time shared
|
|
18
|
+
# by every worker (the same one passed to scrubber_options[:deadline]).
|
|
19
|
+
def initialize(jobs:, open_manifest:, lock:, scrubber_options: {}, clock: Time, deadline: nil, out: $stdout)
|
|
20
|
+
@jobs = jobs
|
|
21
|
+
@open_manifest = open_manifest
|
|
22
|
+
@lock = lock
|
|
23
|
+
@scrubber_options = scrubber_options
|
|
24
|
+
@clock = clock
|
|
25
|
+
@deadline = deadline
|
|
26
|
+
@out = out
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# +targets+ are MountedDrive structs. Returns their Scrubber::Result,
|
|
30
|
+
# one per target, in target order (not completion order).
|
|
31
|
+
def run(targets)
|
|
32
|
+
return [] if targets.empty?
|
|
33
|
+
|
|
34
|
+
queue = Queue.new
|
|
35
|
+
targets.each_with_index { |target, i| queue << [i, target] }
|
|
36
|
+
results = Array.new(targets.size)
|
|
37
|
+
active = {}
|
|
38
|
+
active_mutex = Mutex.new
|
|
39
|
+
worker_count = [@jobs, targets.size].min
|
|
40
|
+
|
|
41
|
+
# Opened here, on the calling thread, before any worker starts:
|
|
42
|
+
# Manifest#initialize runs migrate!, and doing that from several
|
|
43
|
+
# threads at once on a brand new manifest invites SQLite lock errors.
|
|
44
|
+
manifests = Array.new(worker_count) { @open_manifest.call }
|
|
45
|
+
|
|
46
|
+
threads = manifests.map do |worker_manifest|
|
|
47
|
+
scrubber = Scrubber.new(worker_manifest, clock: @clock, out: @out, deadline: @deadline, **@scrubber_options)
|
|
48
|
+
Thread.new do
|
|
49
|
+
# #wait_for/#cancel already surface a worker's exception (or the
|
|
50
|
+
# Interrupt cancellation itself raises) through the caller;
|
|
51
|
+
# Ruby's default per-thread stderr report would just be noise on
|
|
52
|
+
# top of that.
|
|
53
|
+
Thread.current.report_on_exception = false
|
|
54
|
+
work(scrubber, queue, results, active, active_mutex)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
begin
|
|
59
|
+
wait_for(threads)
|
|
60
|
+
ensure
|
|
61
|
+
manifests.each(&:close)
|
|
62
|
+
end
|
|
63
|
+
results.compact
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def work(scrubber, queue, results, active, active_mutex)
|
|
69
|
+
loop do
|
|
70
|
+
break if @deadline && @clock.now >= @deadline
|
|
71
|
+
|
|
72
|
+
index, target = begin
|
|
73
|
+
queue.pop(true)
|
|
74
|
+
rescue ThreadError # empty
|
|
75
|
+
break
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
note_active(active, active_mutex) { active[target.friendly_name] = true }
|
|
79
|
+
begin
|
|
80
|
+
results[index] = scrubber.run(target)
|
|
81
|
+
ensure
|
|
82
|
+
note_active(active, active_mutex) { active.delete(target.friendly_name) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def note_active(active, mutex)
|
|
88
|
+
mutex.synchronize do
|
|
89
|
+
yield
|
|
90
|
+
@lock.note(*active.keys)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Polls rather than blocking on Thread#join in target order, so a
|
|
95
|
+
# worker that dies out of order (its target was smaller, or it simply
|
|
96
|
+
# failed sooner) is noticed - and its siblings cancelled - right away
|
|
97
|
+
# instead of only once every earlier thread happens to finish too.
|
|
98
|
+
#
|
|
99
|
+
# On Ctrl-C (Interrupt arrives here, since this is what the main thread
|
|
100
|
+
# is blocked in) or an exception raised inside a worker: cancel every
|
|
101
|
+
# other worker with Thread#raise(Interrupt) so each stops at its next
|
|
102
|
+
# file boundary and flushes what it already has, join them all
|
|
103
|
+
# (swallowing the Interrupt that cancellation itself causes), then
|
|
104
|
+
# re-raise the original problem - never silently.
|
|
105
|
+
def wait_for(threads)
|
|
106
|
+
loop do
|
|
107
|
+
threads.each do |t|
|
|
108
|
+
next if t.alive?
|
|
109
|
+
|
|
110
|
+
begin
|
|
111
|
+
t.join
|
|
112
|
+
rescue Exception => e
|
|
113
|
+
cancel(threads)
|
|
114
|
+
raise e
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
break if threads.all? { |t| !t.alive? }
|
|
118
|
+
|
|
119
|
+
sleep 0.02
|
|
120
|
+
end
|
|
121
|
+
rescue Interrupt => e
|
|
122
|
+
cancel(threads)
|
|
123
|
+
raise e
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def cancel(threads)
|
|
127
|
+
threads.each { |t| t.raise(Interrupt) if t.alive? }
|
|
128
|
+
threads.each do |t|
|
|
129
|
+
t.join
|
|
130
|
+
rescue Exception
|
|
131
|
+
nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'find'
|
|
4
|
+
require 'digest'
|
|
5
|
+
|
|
6
|
+
module EasySync
|
|
7
|
+
module Jbod
|
|
8
|
+
# `easy_sync scrub`: reads every tracked file on one mounted drive back off
|
|
9
|
+
# the platter and compares it to its SHA-256 baseline, so bit rot is caught
|
|
10
|
+
# before it gets uploaded to the offsite backup as a "change". Read-only on
|
|
11
|
+
# the drive - it only ever writes to the manifest. See
|
|
12
|
+
# docs/integrity-scan.md for the full design.
|
|
13
|
+
class Scrubber
|
|
14
|
+
NOCACHE_CMD = 48 # F_NOCACHE, macOS only; best effort elsewhere
|
|
15
|
+
CHUNK_SIZE = 8 * 1024 * 1024
|
|
16
|
+
COMMIT_INTERVAL = 30 # seconds; also committed on exit, including Ctrl-C
|
|
17
|
+
|
|
18
|
+
Result = Struct.new(:drive, :baselined, :ok, :repaired, :corrupt, :unreadable, :unresolved, :removed, :changed,
|
|
19
|
+
:bytes_read, :elapsed_seconds, :stopped_reason, keyword_init: true) do
|
|
20
|
+
def initialize(**)
|
|
21
|
+
super
|
|
22
|
+
%i[baselined ok repaired corrupt unreadable unresolved removed changed].each { |m| self[m] ||= 0 }
|
|
23
|
+
self.bytes_read ||= 0
|
|
24
|
+
self.elapsed_seconds ||= 0.0
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def completed? = stopped_reason.nil?
|
|
28
|
+
def findings? = corrupt.positive? || unreadable.positive? || unresolved.positive?
|
|
29
|
+
def mb_per_s = elapsed_seconds.to_f.zero? ? 0.0 : (bytes_read.to_f / elapsed_seconds) / (1024 * 1024)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def initialize(manifest, excludes: [], clock: Time, out: $stdout, deadline: nil, dry_run: false)
|
|
33
|
+
@manifest = manifest
|
|
34
|
+
@excludes = Array(excludes)
|
|
35
|
+
@clock = clock
|
|
36
|
+
@out = out
|
|
37
|
+
@deadline = deadline
|
|
38
|
+
@dry_run = dry_run
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# +mounted_drive+ is a MountedDrive struct. Returns a Result.
|
|
42
|
+
def run(mounted_drive)
|
|
43
|
+
drive_serial = mounted_drive.serial_number
|
|
44
|
+
mount_point = mounted_drive.mount_point
|
|
45
|
+
started = @clock.now
|
|
46
|
+
result = Result.new(drive: mounted_drive.friendly_name)
|
|
47
|
+
folders = @manifest.folders_on(drive_serial)
|
|
48
|
+
|
|
49
|
+
if @dry_run
|
|
50
|
+
dry_walk(drive_serial, mount_point, folders, result)
|
|
51
|
+
else
|
|
52
|
+
walk(drive_serial, mount_point, folders, result)
|
|
53
|
+
hash_phase(drive_serial, mount_point, result, started) unless result.stopped_reason
|
|
54
|
+
end
|
|
55
|
+
result.elapsed_seconds = @clock.now - started
|
|
56
|
+
report(result) unless @dry_run
|
|
57
|
+
result
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
# -- 1a: reconcile (walk) --------------------------------------------
|
|
63
|
+
|
|
64
|
+
# A folder is only reconciled from a walk that completed: reconciling a
|
|
65
|
+
# partial list would delete the rows (and baselines) of every file the
|
|
66
|
+
# walk never reached, and re-baseline them later from whatever is on
|
|
67
|
+
# disk by then - hiding any rot in between.
|
|
68
|
+
def walk(drive_serial, mount_point, folders, result)
|
|
69
|
+
folders.each do |folder|
|
|
70
|
+
root = File.join(mount_point, folder.folder_path)
|
|
71
|
+
next unless Dir.exist?(root)
|
|
72
|
+
|
|
73
|
+
found = walk_folder(root, root_only: folder.root?)
|
|
74
|
+
unless marker_present?(mount_point)
|
|
75
|
+
result.stopped_reason = :unmounted
|
|
76
|
+
return
|
|
77
|
+
end
|
|
78
|
+
if found.nil?
|
|
79
|
+
@out.puts "WARNING: #{folder.folder_path}: could not walk it completely; its tracked files were left as they were"
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
_added, removed, changed = @manifest.reconcile_checksums(drive_serial, folder.folder_path, found)
|
|
84
|
+
result.removed += removed
|
|
85
|
+
result.changed += changed
|
|
86
|
+
end
|
|
87
|
+
@manifest.prune_checksums(drive_serial, folders.map(&:folder_path))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# {relative_path => [size_bytes, mtime]} for every regular file under
|
|
91
|
+
# +root+, skipping symlinks and anything matching an exclude pattern
|
|
92
|
+
# (pruned, so an excluded directory's contents are never even visited).
|
|
93
|
+
# nil if the walk could not finish (a directory vanished or could not be
|
|
94
|
+
# read); a single file vanishing mid-walk is just left out. A root-files
|
|
95
|
+
# unit (+root_only+) owns only the files directly in +root+: every
|
|
96
|
+
# subdirectory there is some other folder's.
|
|
97
|
+
def walk_folder(root, root_only: false)
|
|
98
|
+
found = {}
|
|
99
|
+
Find.find(root, ignore_error: false) do |path|
|
|
100
|
+
next if path == root
|
|
101
|
+
|
|
102
|
+
name = File.basename(path)
|
|
103
|
+
next Find.prune if excluded?(name) || (root_only && File.directory?(path) && !File.symlink?(path))
|
|
104
|
+
|
|
105
|
+
begin
|
|
106
|
+
stat = File.lstat(path)
|
|
107
|
+
rescue Errno::ENOENT
|
|
108
|
+
next
|
|
109
|
+
end
|
|
110
|
+
next Find.prune if stat.symlink?
|
|
111
|
+
|
|
112
|
+
found[path.delete_prefix("#{root}/")] = [stat.size, stat.mtime.to_i] if stat.file?
|
|
113
|
+
end
|
|
114
|
+
found
|
|
115
|
+
rescue SystemCallError
|
|
116
|
+
nil
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def excluded?(name)
|
|
120
|
+
@excludes.any? { |pat| File.fnmatch(pat, name, File::FNM_DOTMATCH) }
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# -- 1b/1c: work queue, hash, record ---------------------------------
|
|
124
|
+
|
|
125
|
+
# In WAL (Jbod::ScrubPool runs several of these against one manifest at
|
|
126
|
+
# once), a connection holds the single write lock from its first write
|
|
127
|
+
# until commit; keeping a transaction open for a whole COMMIT_INTERVAL
|
|
128
|
+
# would make every other worker's write wait that long. So outcomes are
|
|
129
|
+
# buffered here and only actually written in the short transactions
|
|
130
|
+
# #flush opens - milliseconds, not seconds.
|
|
131
|
+
def hash_phase(drive_serial, mount_point, result, started)
|
|
132
|
+
rows = @manifest.checksum_frontier(drive_serial)
|
|
133
|
+
return if rows.empty?
|
|
134
|
+
|
|
135
|
+
total_files = rows.size
|
|
136
|
+
total_bytes = rows.sum { |r| r.size_bytes.to_i }
|
|
137
|
+
done = 0
|
|
138
|
+
pending = []
|
|
139
|
+
|
|
140
|
+
last_commit = @clock.now
|
|
141
|
+
begin
|
|
142
|
+
rows.each do |row|
|
|
143
|
+
# One clock read per file: reused for the deadline check, the
|
|
144
|
+
# commit-interval check and the timestamp written to the row, so
|
|
145
|
+
# a test (or a real clock) only has to advance once per file.
|
|
146
|
+
t = @clock.now
|
|
147
|
+
if @deadline && t >= @deadline
|
|
148
|
+
result.stopped_reason = :deadline
|
|
149
|
+
break
|
|
150
|
+
end
|
|
151
|
+
unless marker_present?(mount_point)
|
|
152
|
+
result.stopped_reason = :unmounted
|
|
153
|
+
break
|
|
154
|
+
end
|
|
155
|
+
if hash_one(drive_serial, mount_point, row, result, at: t.utc.iso8601, pending: pending) == :unmounted
|
|
156
|
+
result.stopped_reason = :unmounted
|
|
157
|
+
break
|
|
158
|
+
end
|
|
159
|
+
done += 1
|
|
160
|
+
if t - last_commit >= COMMIT_INTERVAL
|
|
161
|
+
flush(pending)
|
|
162
|
+
progress(result, done, total_files, total_bytes, t - started)
|
|
163
|
+
last_commit = t
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
ensure
|
|
167
|
+
# A Thread#raise(Interrupt) from Jbod::ScrubPool cancelling this
|
|
168
|
+
# worker must not land here and abort a flush half-way: everything
|
|
169
|
+
# in +pending+ was fully read and is valid to record.
|
|
170
|
+
Thread.handle_interrupt(Interrupt => :never) { flush(pending) }
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def flush(pending)
|
|
175
|
+
return if pending.empty?
|
|
176
|
+
|
|
177
|
+
@manifest.db.transaction(:immediate) { pending.each { |args, kwargs| @manifest.checksum_hashed(*args, **kwargs) } }
|
|
178
|
+
pending.clear
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# One line per commit, so a multi-day scrub leaves a readable trail in
|
|
182
|
+
# the log instead of being silent for hours between drive summaries.
|
|
183
|
+
def progress(result, done, total_files, total_bytes, elapsed)
|
|
184
|
+
pct = total_bytes.positive? ? ((100.0 * result.bytes_read) / total_bytes).round : 100
|
|
185
|
+
rate = elapsed.positive? ? result.bytes_read / elapsed : 0
|
|
186
|
+
remaining = total_bytes - result.bytes_read
|
|
187
|
+
eta = rate.positive? && remaining.positive? ? Placement.format_duration(remaining / rate) : '0s'
|
|
188
|
+
@out.puts " #{result.drive}: #{done}/#{total_files} files, #{Placement.format_bytes(result.bytes_read)} " \
|
|
189
|
+
"of #{Placement.format_bytes(total_bytes)} (#{pct}%), #{format('%.1f', rate / (1024 * 1024))} MB/s, " \
|
|
190
|
+
"ETA #{eta}"
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def marker_present?(mount_point)
|
|
194
|
+
File.file?(File.join(mount_point, MARKER_FILE))
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Returns :unmounted, leaving the row untouched, when the read failed
|
|
198
|
+
# because the drive went away mid-file: that says nothing about the
|
|
199
|
+
# file. Any other unexpected error skips just this file.
|
|
200
|
+
def hash_one(drive_serial, mount_point, row, result, at:, pending:)
|
|
201
|
+
path = File.join(mount_point, row.folder_path, row.relative_path)
|
|
202
|
+
begin
|
|
203
|
+
hex, bytes = sha256_of(path)
|
|
204
|
+
rescue SystemCallError => e
|
|
205
|
+
return :unmounted unless marker_present?(mount_point)
|
|
206
|
+
|
|
207
|
+
case e
|
|
208
|
+
when Errno::ENOENT
|
|
209
|
+
pending << [[drive_serial, row.folder_path, row.relative_path], { outcome: :vanished }]
|
|
210
|
+
when Errno::EIO
|
|
211
|
+
record_read_error(drive_serial, row, result, at: at, pending: pending)
|
|
212
|
+
else
|
|
213
|
+
@out.puts "WARNING: #{row.label}: #{e.message}; skipped, left as it was"
|
|
214
|
+
end
|
|
215
|
+
return
|
|
216
|
+
end
|
|
217
|
+
result.bytes_read += bytes
|
|
218
|
+
record_hash(drive_serial, row, hex, result, at: at, pending: pending)
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def sha256_of(path)
|
|
222
|
+
digest = Digest::SHA256.new
|
|
223
|
+
bytes = 0
|
|
224
|
+
File.open(path, 'rb') do |io|
|
|
225
|
+
begin
|
|
226
|
+
io.fcntl(NOCACHE_CMD, 1)
|
|
227
|
+
rescue SystemCallError, NotImplementedError
|
|
228
|
+
nil # best effort: not every filesystem/platform supports F_NOCACHE
|
|
229
|
+
end
|
|
230
|
+
PageCache.evict(io)
|
|
231
|
+
while (chunk = io.read(CHUNK_SIZE))
|
|
232
|
+
digest.update(chunk)
|
|
233
|
+
bytes += chunk.bytesize
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
[digest.hexdigest, bytes]
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
# A row reaches the frontier with status <> 'ok' only when it has
|
|
240
|
+
# already been refetched by sync (see Manifest#checksum_frontier), so
|
|
241
|
+
# that alone tells us this hash is confirming a repair, not a first
|
|
242
|
+
# check.
|
|
243
|
+
def record_hash(drive_serial, row, digest, result, at:, pending:)
|
|
244
|
+
key = [drive_serial, row.folder_path, row.relative_path]
|
|
245
|
+
repair_check = !row.ok?
|
|
246
|
+
if row.digest.nil?
|
|
247
|
+
# A flagged row with no digest was unreadable on its very first
|
|
248
|
+
# read; its first good read is a repair, which also clears the flags.
|
|
249
|
+
outcome = repair_check ? :repaired : :baseline
|
|
250
|
+
pending << [key, { outcome: outcome, digest: digest, at: at }]
|
|
251
|
+
repair_check ? (result.repaired += 1) : (result.baselined += 1)
|
|
252
|
+
elsif digest == row.digest
|
|
253
|
+
outcome = repair_check ? :repaired : :confirmed
|
|
254
|
+
pending << [key, { outcome: outcome, digest: digest, at: at }]
|
|
255
|
+
repair_check ? (result.repaired += 1) : (result.ok += 1)
|
|
256
|
+
elsif repair_check
|
|
257
|
+
pending << [key, { outcome: :unresolved }]
|
|
258
|
+
result.unresolved += 1
|
|
259
|
+
else
|
|
260
|
+
pending << [key, { outcome: :corrupt, at: at }]
|
|
261
|
+
result.corrupt += 1
|
|
262
|
+
end
|
|
263
|
+
end
|
|
264
|
+
|
|
265
|
+
def record_read_error(drive_serial, row, result, at:, pending:)
|
|
266
|
+
key = [drive_serial, row.folder_path, row.relative_path]
|
|
267
|
+
if row.ok?
|
|
268
|
+
pending << [key, { outcome: :unreadable, at: at }]
|
|
269
|
+
result.unreadable += 1
|
|
270
|
+
else
|
|
271
|
+
pending << [key, { outcome: :unresolved }]
|
|
272
|
+
result.unresolved += 1
|
|
273
|
+
end
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
# -- dry run ----------------------------------------------------------
|
|
277
|
+
|
|
278
|
+
# Walks and reports what a real run would add/drop/reset, plus the
|
|
279
|
+
# current hash queue (an approximation: it reads the frontier as it
|
|
280
|
+
# stands now, not as the walk above would leave it). Touches nothing.
|
|
281
|
+
def dry_walk(drive_serial, mount_point, folders, result)
|
|
282
|
+
added = removed = changed = 0
|
|
283
|
+
folders.each do |folder|
|
|
284
|
+
root = File.join(mount_point, folder.folder_path)
|
|
285
|
+
next unless Dir.exist?(root)
|
|
286
|
+
|
|
287
|
+
found = walk_folder(root) or next
|
|
288
|
+
existing = @manifest.checksum_rows(drive_serial, folder.folder_path).to_h { |r| [r.relative_path, r] }
|
|
289
|
+
found.each do |rel, (size, mtime)|
|
|
290
|
+
row = existing[rel]
|
|
291
|
+
if row.nil?
|
|
292
|
+
added += 1
|
|
293
|
+
elsif row.size_bytes != size || row.mtime != mtime
|
|
294
|
+
changed += 1
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
existing.each_key { |rel| removed += 1 unless found.key?(rel) }
|
|
298
|
+
end
|
|
299
|
+
|
|
300
|
+
frontier = @manifest.checksum_frontier(drive_serial)
|
|
301
|
+
bytes = frontier.sum { |r| r.size_bytes.to_i }
|
|
302
|
+
@out.puts "DRY RUN #{result.drive}: would add #{added}, drop #{removed}, reset #{changed} tracked file" \
|
|
303
|
+
"#{'s' if (added + removed + changed) != 1}; would hash #{frontier.size} file#{'s' if frontier.size != 1} " \
|
|
304
|
+
"(#{Placement.format_bytes(bytes)}), about #{Placement.format_duration(bytes / (150.0 * 1024 * 1024))} at 150 MB/s"
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
# -- reporting ----------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def report(result)
|
|
310
|
+
@out.puts "#{result.drive}: #{result.baselined} new baseline#{'s' if result.baselined != 1}, #{result.ok} ok, " \
|
|
311
|
+
"#{result.repaired} repaired, #{result.corrupt} CORRUPT, #{result.unreadable} UNREADABLE, " \
|
|
312
|
+
"#{result.unresolved} UNRESOLVED, #{result.removed} removed, #{result.changed} changed " \
|
|
313
|
+
"(#{Placement.format_bytes(result.bytes_read)} read, #{format('%.1f', result.mb_per_s)} MB/s)"
|
|
314
|
+
case result.stopped_reason
|
|
315
|
+
when :deadline then @out.puts " stopped: --for deadline reached; the next scrub of #{result.drive} continues from here"
|
|
316
|
+
when :unmounted then @out.puts " stopped: #{result.drive} was unmounted mid-run"
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
end
|
|
320
|
+
end
|
|
321
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasySync
|
|
4
|
+
module Jbod
|
|
5
|
+
# What a share's top level holds, by the one rule `sync` uses to decide
|
|
6
|
+
# its units: each visible, non-excluded subfolder is one unit; visible,
|
|
7
|
+
# non-excluded loose files together are one more.
|
|
8
|
+
module ShareScan
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def subfolders(path, excludes)
|
|
12
|
+
entries(path, excludes).select { |n| File.directory?(File.join(path, n)) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def loose_files(path, excludes)
|
|
16
|
+
entries(path, excludes).select { |n| File.file?(File.join(path, n)) }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def entries(path, excludes)
|
|
20
|
+
Dir.children(path).sort.reject do |n|
|
|
21
|
+
n.start_with?('.') || Array(excludes).any? { |pat| File.fnmatch?(pat, n) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module EasySync
|
|
6
|
+
module Jbod
|
|
7
|
+
# Estimates time remaining for a sync run in progress, from what it has
|
|
8
|
+
# actually done so far this run: folders it copied real bytes for (their
|
|
9
|
+
# rate extrapolates to every not-yet-synced folder still queued) plus
|
|
10
|
+
# folders it merely re-verified (their average time extrapolates to every
|
|
11
|
+
# already-synced folder not yet touched this run, unless that folder has
|
|
12
|
+
# a verify of its own from an earlier run, which is used instead: a
|
|
13
|
+
# share's folders run together, so the first few verifies of a run can
|
|
14
|
+
# be one slow share's (many small files over SMB) and say nothing about
|
|
15
|
+
# thousands of single-file movie folders still to come). The two behave nothing
|
|
16
|
+
# alike - an unchanged folder verifies in under a second, a first-time
|
|
17
|
+
# folder moves real bytes over the network - so averaging them together
|
|
18
|
+
# would be meaningless; this keeps them separate instead. Shared by
|
|
19
|
+
# `status` and the dashboard so the two never drift apart.
|
|
20
|
+
class SyncEta
|
|
21
|
+
# +status+ is :waiting_for_first_folder (nothing has finished this run
|
|
22
|
+
# yet), :waiting_for_first_transfer (only verifies so far, no rate to
|
|
23
|
+
# extrapolate from), or :estimate (the only one with usable +seconds+).
|
|
24
|
+
Estimate = Struct.new(:status, :never_synced_count, :never_synced_bytes, :to_reverify, :seconds,
|
|
25
|
+
keyword_init: true)
|
|
26
|
+
|
|
27
|
+
def self.for(manifest, started_at) = new(manifest, started_at).estimate
|
|
28
|
+
|
|
29
|
+
def initialize(manifest, started_at)
|
|
30
|
+
@manifest = manifest
|
|
31
|
+
@started_at = started_at
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# nil when the run has nothing left to estimate (every folder was
|
|
35
|
+
# already touched this run as of the moment it started).
|
|
36
|
+
def estimate
|
|
37
|
+
since = @started_at.utc.iso8601
|
|
38
|
+
runs = @manifest.sync_runs_since(since)
|
|
39
|
+
return Estimate.new(status: :waiting_for_first_folder) if runs.empty?
|
|
40
|
+
|
|
41
|
+
transfers, verifies = runs.partition { |r| r.bytes_transferred.to_i.positive? }
|
|
42
|
+
transfer_seconds = duration_of(transfers)
|
|
43
|
+
transfer_rate = transfer_seconds.positive? ? transfers.sum { |r| r.bytes_transferred.to_i } / transfer_seconds : nil
|
|
44
|
+
avg_verify_seconds = verifies.empty? ? duration_of(runs) / runs.size : duration_of(verifies) / verifies.size
|
|
45
|
+
|
|
46
|
+
folders = @manifest.folders
|
|
47
|
+
never_synced = folders.select { |f| f.last_synced_at.nil? }
|
|
48
|
+
reverify = folders.select { |f| f.last_synced_at && f.last_synced_at < since }
|
|
49
|
+
return nil if never_synced.empty? && reverify.empty?
|
|
50
|
+
|
|
51
|
+
if never_synced.any? && transfer_rate.nil?
|
|
52
|
+
return Estimate.new(status: :waiting_for_first_transfer, never_synced_count: never_synced.size,
|
|
53
|
+
never_synced_bytes: never_synced.sum { |f| f.size_bytes.to_i })
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
transfer_part = transfer_rate ? never_synced.sum { |f| f.size_bytes.to_i } / transfer_rate : 0
|
|
57
|
+
history = @manifest.last_verify_seconds(before: since)
|
|
58
|
+
reverify_part = reverify.sum { |f| history.fetch(f.folder_path, avg_verify_seconds) }
|
|
59
|
+
Estimate.new(status: :estimate, never_synced_count: never_synced.size, to_reverify: reverify.size,
|
|
60
|
+
seconds: transfer_part + reverify_part)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def duration_of(runs) = runs.sum { |r| Time.parse(r.finished_at) - Time.parse(r.started_at) }
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module EasySync
|
|
4
|
+
module Jbod
|
|
5
|
+
# Decides whether a sync would overwrite too much of the backup at once:
|
|
6
|
+
# far more existing files replaced or gone on the NAS than a media library
|
|
7
|
+
# ever changes normally, which is what ransomware looks like. Pure logic
|
|
8
|
+
# over the per-folder Mirror::Check results; Runner acts on the Decision.
|
|
9
|
+
# See docs/tripwire.md.
|
|
10
|
+
class Tripwire
|
|
11
|
+
# +scope+ is 'folder' when the folder tripped on its own, 'run' when it
|
|
12
|
+
# only counts towards a run-wide trip.
|
|
13
|
+
Trip = Struct.new(:folder_path, :replaced, :missing, :files_on_drive, :samples, :scope, :accepted,
|
|
14
|
+
keyword_init: true) do
|
|
15
|
+
def changed = replaced + missing
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# +trips+ includes accepted ones (they are still recorded). +blocked+
|
|
19
|
+
# is the folder keys not to copy this run; with +run_tripped+ nothing
|
|
20
|
+
# is copied at all. +run_changed+ is the changed-file total the run
|
|
21
|
+
# threshold was compared against (accepted folders left out).
|
|
22
|
+
Decision = Struct.new(:trips, :run_tripped, :run_changed, :total_changed, keyword_init: true) do
|
|
23
|
+
def blocked = trips.reject(&:accepted).map(&:folder_path)
|
|
24
|
+
def tripped? = !blocked.empty?
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
attr_reader :run_files, :folder_files, :folder_ratio
|
|
28
|
+
|
|
29
|
+
def self.from_settings(settings)
|
|
30
|
+
new(run_files: settings.fetch(:tripwire_run_files, 500), folder_files: settings.fetch(:tripwire_folder_files, 50),
|
|
31
|
+
folder_ratio: settings.fetch(:tripwire_folder_ratio, 0.25))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def initialize(run_files:, folder_files:, folder_ratio:)
|
|
35
|
+
@run_files = run_files.to_i
|
|
36
|
+
@folder_files = folder_files.to_i
|
|
37
|
+
@folder_ratio = folder_ratio.to_f
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# 0 turns the tripwire off entirely: nothing ever trips.
|
|
41
|
+
def enabled? = run_files.positive?
|
|
42
|
+
|
|
43
|
+
# +checks+ maps folder key => Mirror::Check. +accept+ is true (accept
|
|
44
|
+
# every trip this run), an array of folder keys, or nil.
|
|
45
|
+
def decide(checks, accept: nil)
|
|
46
|
+
accepted = ->(key) { accept == true || Array(accept).include?(key) }
|
|
47
|
+
total = checks.sum { |_, c| c.changed }
|
|
48
|
+
run_changed = checks.sum { |key, c| accepted.call(key) ? 0 : c.changed }
|
|
49
|
+
return Decision.new(trips: [], run_tripped: false, run_changed: run_changed, total_changed: total) unless enabled?
|
|
50
|
+
|
|
51
|
+
# Accepting everything still records the trip it let through.
|
|
52
|
+
run_trip = run_changed >= run_files || (accept == true && total >= run_files)
|
|
53
|
+
trips = checks.filter_map do |key, check|
|
|
54
|
+
alone = folder_trips?(check)
|
|
55
|
+
next unless alone || (run_trip && check.changed.positive?)
|
|
56
|
+
|
|
57
|
+
trip(key, check, scope: alone ? 'folder' : 'run', accepted: accepted.call(key))
|
|
58
|
+
end
|
|
59
|
+
Decision.new(trips: trips, run_tripped: run_changed >= run_files, run_changed: run_changed, total_changed: total)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Both halves: enough files that it isn't a 3-file folder with one
|
|
63
|
+
# re-tag, and a big enough share that it isn't routine churn in a
|
|
64
|
+
# huge one.
|
|
65
|
+
def folder_trips?(check)
|
|
66
|
+
changed = check.changed
|
|
67
|
+
return false if changed < folder_files || changed.zero?
|
|
68
|
+
|
|
69
|
+
changed.fdiv([check.files_on_drive, changed].max) >= folder_ratio
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
private
|
|
73
|
+
|
|
74
|
+
def trip(key, check, scope:, accepted:)
|
|
75
|
+
Trip.new(folder_path: key, replaced: check.replaced.size, missing: check.missing_files.size,
|
|
76
|
+
files_on_drive: check.files_on_drive, samples: check.samples, scope: scope, accepted: accepted)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|