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