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,1068 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'json'
|
|
4
|
+
require 'sqlite3'
|
|
5
|
+
require 'fileutils'
|
|
6
|
+
require 'time'
|
|
7
|
+
|
|
8
|
+
module EasySync
|
|
9
|
+
module Jbod
|
|
10
|
+
# SQLite manifest: which folder lives on which drive, plus history.
|
|
11
|
+
class Manifest
|
|
12
|
+
SCHEMA_VERSION = 5
|
|
13
|
+
BENCHMARKS_KEPT = 25 # per drive: enough to see a trend, small enough to never matter
|
|
14
|
+
|
|
15
|
+
class DuplicateFolder < Error; end
|
|
16
|
+
class UnknownDrive < Error; end
|
|
17
|
+
class UnknownFolder < Error; end
|
|
18
|
+
|
|
19
|
+
# WAL is sticky in the file: once written, every future opener (this
|
|
20
|
+
# process or another) sees it too, so this only needs to run here, not
|
|
21
|
+
# on every #initialize. :memory: has no file to write it to and doesn't
|
|
22
|
+
# need it (Jbod::ScrubPool never runs against one).
|
|
23
|
+
def self.open(path)
|
|
24
|
+
FileUtils.mkdir_p(File.dirname(path)) unless path == ':memory:'
|
|
25
|
+
db = SQLite3::Database.new(path)
|
|
26
|
+
db.execute('PRAGMA journal_mode = WAL') unless path == ':memory:'
|
|
27
|
+
new(db)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
attr_reader :db
|
|
31
|
+
|
|
32
|
+
def initialize(db, clock: Time)
|
|
33
|
+
@db = db
|
|
34
|
+
@clock = clock
|
|
35
|
+
db.results_as_hash = true
|
|
36
|
+
db.execute('PRAGMA foreign_keys = ON')
|
|
37
|
+
# WAL means several connections (Jbod::ScrubPool's workers) hold the
|
|
38
|
+
# write lock only briefly, one at a time; without a busy_timeout a
|
|
39
|
+
# connection that loses the race gets SQLITE_BUSY immediately instead
|
|
40
|
+
# of waiting its turn.
|
|
41
|
+
db.busy_timeout = 30_000
|
|
42
|
+
migrate!
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def close = db.close
|
|
46
|
+
|
|
47
|
+
# Writes a consistent copy of the whole database to +path+ using
|
|
48
|
+
# SQLite's online backup API, safe while this connection is open.
|
|
49
|
+
def backup_to(path)
|
|
50
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
51
|
+
tmp = "#{path}.tmp"
|
|
52
|
+
File.delete(tmp) if File.exist?(tmp)
|
|
53
|
+
dest = SQLite3::Database.new(tmp)
|
|
54
|
+
begin
|
|
55
|
+
backup = SQLite3::Backup.new(dest, 'main', db, 'main')
|
|
56
|
+
backup.step(-1)
|
|
57
|
+
backup.finish
|
|
58
|
+
ensure
|
|
59
|
+
dest.close
|
|
60
|
+
end
|
|
61
|
+
File.rename(tmp, path)
|
|
62
|
+
path
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# -- drives ---------------------------------------------------------
|
|
66
|
+
|
|
67
|
+
def register_drive(serial_number:, friendly_name:, capacity_bytes:, volume_uuid: nil, model: nil, added_date: now)
|
|
68
|
+
db.execute(<<~SQL, [serial_number, friendly_name, capacity_bytes, added_date, volume_uuid, model])
|
|
69
|
+
INSERT INTO drives (serial_number, friendly_name, capacity_bytes, added_date, volume_uuid, model)
|
|
70
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
71
|
+
SQL
|
|
72
|
+
drive(serial_number)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Active drives by default; a retired drive is never placed on or written to.
|
|
76
|
+
def drives(include_retired: false)
|
|
77
|
+
sql = 'SELECT * FROM drives'
|
|
78
|
+
sql += ' WHERE retired_at IS NULL' unless include_retired
|
|
79
|
+
db.execute("#{sql} ORDER BY friendly_name").map { |row| row_to_drive(row) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# friendly_name is cosmetic (drives are matched by serial, never by
|
|
83
|
+
# name or mount path), so this is a plain rename.
|
|
84
|
+
def rename_drive(serial_number, new_name)
|
|
85
|
+
ensure_drive!(serial_number)
|
|
86
|
+
db.execute('UPDATE drives SET friendly_name = ? WHERE serial_number = ?', [new_name, serial_number])
|
|
87
|
+
drive(serial_number)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# friendly_name is UNIQUE, so exchanging two names needs a temporary
|
|
91
|
+
# third value to avoid colliding with the other row mid-swap.
|
|
92
|
+
def swap_drive_names(serial_a, serial_b)
|
|
93
|
+
a = drive(serial_a) or raise UnknownDrive, "no drive registered with serial #{serial_a}"
|
|
94
|
+
b = drive(serial_b) or raise UnknownDrive, "no drive registered with serial #{serial_b}"
|
|
95
|
+
db.transaction(:immediate) do
|
|
96
|
+
db.execute('UPDATE drives SET friendly_name = ? WHERE serial_number = ?', ["__renaming__#{serial_a}", serial_a])
|
|
97
|
+
db.execute('UPDATE drives SET friendly_name = ? WHERE serial_number = ?', [a.friendly_name, serial_b])
|
|
98
|
+
db.execute('UPDATE drives SET friendly_name = ? WHERE serial_number = ?', [b.friendly_name, serial_a])
|
|
99
|
+
end
|
|
100
|
+
[drive(serial_a), drive(serial_b)]
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Marks a drive retired. Its row and history stay so old placements
|
|
104
|
+
# remain answerable; #drives no longer returns it.
|
|
105
|
+
def retire_drive(serial_number, at: now)
|
|
106
|
+
ensure_drive!(serial_number)
|
|
107
|
+
db.execute('UPDATE drives SET retired_at = ? WHERE serial_number = ?', [at, serial_number])
|
|
108
|
+
drive(serial_number)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Every table with a drive_serial column. #forget_drive clears all of them.
|
|
112
|
+
DRIVE_TABLES = %w[folders placement_history sync_runs pending_deletions deletions
|
|
113
|
+
file_checksums drive_benchmarks smart_checks].freeze
|
|
114
|
+
|
|
115
|
+
# Rows that still refer to a drive, per table, nonzero only.
|
|
116
|
+
def drive_references(serial_number)
|
|
117
|
+
DRIVE_TABLES.to_h { |t| [t, db.get_first_value("SELECT COUNT(*) FROM #{t} WHERE drive_serial = ?", [serial_number])] }
|
|
118
|
+
.select { |_, n| n.positive? }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Deletes a retired drive and every row that refers to it, including
|
|
122
|
+
# the placement history and sync runs of folders that once lived on it.
|
|
123
|
+
# Only for a drive whose history is not worth keeping (a test drive):
|
|
124
|
+
# #retire_drive is the normal way out. Refuses a drive that is not
|
|
125
|
+
# retired or still holds folders. Returns what was deleted, as
|
|
126
|
+
# #drive_references.
|
|
127
|
+
def forget_drive(serial_number)
|
|
128
|
+
drive = drive(serial_number) or raise UnknownDrive, "no drive registered with serial #{serial_number}"
|
|
129
|
+
raise Error, "#{drive.friendly_name} is not retired; retire it first with `replace-drive`" unless drive.retired?
|
|
130
|
+
raise Error, "#{drive.friendly_name} still holds folders" if folders_on(serial_number).any?
|
|
131
|
+
|
|
132
|
+
removed = drive_references(serial_number)
|
|
133
|
+
db.transaction(:immediate) do
|
|
134
|
+
DRIVE_TABLES.each { |t| db.execute("DELETE FROM #{t} WHERE drive_serial = ?", [serial_number]) }
|
|
135
|
+
db.execute('DELETE FROM drives WHERE serial_number = ?', [serial_number])
|
|
136
|
+
end
|
|
137
|
+
removed
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
# Moves every folder on +from_serial+ to +to_serial+ (recording each), or
|
|
141
|
+
# when +to_serial+ is nil removes their rows so the next sync places them
|
|
142
|
+
# afresh. Returns the folder paths affected.
|
|
143
|
+
def move_all_folders(from_serial, to_serial, note:, at: now)
|
|
144
|
+
paths = folders_on(from_serial).map(&:folder_path)
|
|
145
|
+
paths.each do |path| # each call is its own transaction; SQLite cannot nest them
|
|
146
|
+
if to_serial
|
|
147
|
+
# schedule_cleanup: false - the old drive is retired right after
|
|
148
|
+
# this (see #replace_drive), so its leftover data is already the
|
|
149
|
+
# operator's problem, not something Purger should chase.
|
|
150
|
+
reassign_folder(path, to_serial, note: note, at: at, schedule_cleanup: false)
|
|
151
|
+
else
|
|
152
|
+
clear_pending(path)
|
|
153
|
+
remove_folder(path, note: note, at: at)
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
paths
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def drive(serial_number)
|
|
160
|
+
row = db.get_first_row('SELECT * FROM drives WHERE serial_number = ?', [serial_number])
|
|
161
|
+
row && row_to_drive(row)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def drive_by_name(friendly_name)
|
|
165
|
+
row = db.get_first_row('SELECT * FROM drives WHERE friendly_name = ?', [friendly_name])
|
|
166
|
+
row && row_to_drive(row)
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def update_drive_usage(serial_number, used_bytes:, free_bytes:, capacity_bytes: nil, seen_at: now)
|
|
170
|
+
ensure_drive!(serial_number)
|
|
171
|
+
db.execute(<<~SQL, [used_bytes, free_bytes, seen_at, capacity_bytes, serial_number])
|
|
172
|
+
UPDATE drives
|
|
173
|
+
SET last_used_bytes = ?, last_free_bytes = ?, last_seen_at = ?,
|
|
174
|
+
capacity_bytes = COALESCE(?, capacity_bytes)
|
|
175
|
+
WHERE serial_number = ?
|
|
176
|
+
SQL
|
|
177
|
+
drive(serial_number)
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# power_on_hours is only ever COALESCEd in, never cleared: not every
|
|
181
|
+
# health source reports it (the diskutil fallback can't), and a health
|
|
182
|
+
# check that happens not to find it shouldn't erase a value read before.
|
|
183
|
+
def update_drive_health(serial_number, status:, detail:, power_on_hours: nil, checked_at: now)
|
|
184
|
+
ensure_drive!(serial_number)
|
|
185
|
+
db.execute(<<~SQL, [status, detail, checked_at, power_on_hours, serial_number])
|
|
186
|
+
UPDATE drives
|
|
187
|
+
SET smart_status = ?, smart_detail = ?, smart_checked_at = ?,
|
|
188
|
+
power_on_hours = COALESCE(?, power_on_hours)
|
|
189
|
+
WHERE serial_number = ?
|
|
190
|
+
SQL
|
|
191
|
+
drive(serial_number)
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
# -- SMART trend history ---------------------------------------------
|
|
195
|
+
|
|
196
|
+
# Records one reading's reallocated-sector count for trend comparison.
|
|
197
|
+
# Every check is stored, not just changes, so #reallocated_baseline has
|
|
198
|
+
# a first-ever value to fall back on for a drive that's never been
|
|
199
|
+
# through #verify_drive_stable.
|
|
200
|
+
def record_smart_check(serial_number, reallocated_sector_ct:, checked_at: now)
|
|
201
|
+
ensure_drive!(serial_number)
|
|
202
|
+
db.execute(<<~SQL, [serial_number, checked_at, reallocated_sector_ct])
|
|
203
|
+
INSERT INTO smart_checks (drive_serial, checked_at, reallocated_sector_ct, verified)
|
|
204
|
+
VALUES (?, ?, ?, 0)
|
|
205
|
+
SQL
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def latest_smart_check(serial_number)
|
|
209
|
+
db.get_first_row(<<~SQL, [serial_number])
|
|
210
|
+
SELECT * FROM smart_checks WHERE drive_serial = ? ORDER BY checked_at DESC, id DESC LIMIT 1
|
|
211
|
+
SQL
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# A full-surface scan (SpinRite or equivalent) found no new defects:
|
|
215
|
+
# record the drive's most recently read reallocated count as a manually
|
|
216
|
+
# verified checkpoint. #reallocated_baseline compares against this
|
|
217
|
+
# value and this timestamp from now on, until the next verification.
|
|
218
|
+
def verify_drive_stable(serial_number, note: nil, at: now)
|
|
219
|
+
ensure_drive!(serial_number)
|
|
220
|
+
latest = latest_smart_check(serial_number) or
|
|
221
|
+
raise Error, "no SMART check recorded yet for #{serial_number}; run a sync first"
|
|
222
|
+
db.execute(<<~SQL, [serial_number, at, latest['reallocated_sector_ct'], note])
|
|
223
|
+
INSERT INTO smart_checks (drive_serial, checked_at, reallocated_sector_ct, verified, note)
|
|
224
|
+
VALUES (?, ?, ?, 1, ?)
|
|
225
|
+
SQL
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The reallocated-sector count a new check is compared against: the
|
|
229
|
+
# most recent manually verified checkpoint, or - until the first
|
|
230
|
+
# verification - the earliest check ever recorded for the drive. That
|
|
231
|
+
# fallback matters so turning this tracking on for an already-degraded
|
|
232
|
+
# drive doesn't itself read as "the count just increased".
|
|
233
|
+
def reallocated_baseline(serial_number)
|
|
234
|
+
row = db.get_first_row(<<~SQL, [serial_number])
|
|
235
|
+
SELECT reallocated_sector_ct FROM smart_checks
|
|
236
|
+
WHERE drive_serial = ? AND verified = 1
|
|
237
|
+
ORDER BY checked_at DESC, id DESC LIMIT 1
|
|
238
|
+
SQL
|
|
239
|
+
row ||= db.get_first_row(<<~SQL, [serial_number])
|
|
240
|
+
SELECT reallocated_sector_ct FROM smart_checks
|
|
241
|
+
WHERE drive_serial = ?
|
|
242
|
+
ORDER BY checked_at ASC, id ASC LIMIT 1
|
|
243
|
+
SQL
|
|
244
|
+
row && row['reallocated_sector_ct']
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
# Backfills the model for a drive registered before this field existed,
|
|
248
|
+
# or one whose enclosure didn't expose it at registration time.
|
|
249
|
+
def update_drive_model(serial_number, model:)
|
|
250
|
+
ensure_drive!(serial_number)
|
|
251
|
+
db.execute('UPDATE drives SET model = ? WHERE serial_number = ?', [model, serial_number])
|
|
252
|
+
drive(serial_number)
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# -- folders --------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
def folders
|
|
258
|
+
db.execute('SELECT * FROM folders ORDER BY folder_path').map { |row| row_to_folder(row) }
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
def folders_on(serial_number)
|
|
262
|
+
db.execute('SELECT * FROM folders WHERE drive_serial = ? ORDER BY folder_path', [serial_number])
|
|
263
|
+
.map { |row| row_to_folder(row) }
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def folder(folder_path)
|
|
267
|
+
row = db.get_first_row('SELECT * FROM folders WHERE folder_path = ?', [folder_path])
|
|
268
|
+
row && row_to_folder(row)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
# First-time placement of a folder. Writes the assignment and a history row.
|
|
272
|
+
def assign_folder(folder_path, drive_serial, size_bytes: nil, note: nil, scope: 'tree', at: now)
|
|
273
|
+
ensure_drive!(drive_serial)
|
|
274
|
+
raise DuplicateFolder, "#{folder_path} is already assigned" if folder(folder_path)
|
|
275
|
+
|
|
276
|
+
db.transaction(:immediate) do
|
|
277
|
+
db.execute(<<~SQL, [folder_path, drive_serial, size_bytes, at, scope])
|
|
278
|
+
INSERT INTO folders (folder_path, drive_serial, size_bytes, assigned_at, scope) VALUES (?, ?, ?, ?, ?)
|
|
279
|
+
SQL
|
|
280
|
+
record_history(folder_path, drive_serial, 'assigned', note, at)
|
|
281
|
+
end
|
|
282
|
+
folder(folder_path)
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
# Records that a folder now lives on another drive. Does not move data,
|
|
286
|
+
# so whatever is already on the old drive is scheduled for cleanup
|
|
287
|
+
# (cause 'reassigned') rather than left there untracked: Purger only
|
|
288
|
+
# removes it once the folder has actually synced OK to its new drive
|
|
289
|
+
# (see Purger#ready?) and the usual grace period has passed.
|
|
290
|
+
# +schedule_cleanup: false+ is for callers (replace-drive) that retire
|
|
291
|
+
# the old drive right after, where that drive's leftover data is
|
|
292
|
+
# already understood to be the operator's problem, not tracked here.
|
|
293
|
+
def reassign_folder(folder_path, new_drive_serial, note: nil, at: now, schedule_cleanup: true)
|
|
294
|
+
ensure_drive!(new_drive_serial)
|
|
295
|
+
current = folder(folder_path) or raise UnknownFolder, "#{folder_path} is not in the manifest"
|
|
296
|
+
return current if current.drive_serial == new_drive_serial
|
|
297
|
+
|
|
298
|
+
db.transaction(:immediate) do
|
|
299
|
+
db.execute(<<~SQL, [new_drive_serial, at, folder_path])
|
|
300
|
+
UPDATE folders SET drive_serial = ?, assigned_at = ?, last_synced_at = NULL, last_sync_status = NULL
|
|
301
|
+
WHERE folder_path = ?
|
|
302
|
+
SQL
|
|
303
|
+
record_history(folder_path, new_drive_serial, 'reassigned',
|
|
304
|
+
note || "moved from #{current.drive_serial}", at)
|
|
305
|
+
if schedule_cleanup
|
|
306
|
+
# missing_runs is set far above any real grace_runs: 'reassigned'
|
|
307
|
+
# has no repeated-probe concept to confirm (unlike
|
|
308
|
+
# 'missing_on_nas'), so PendingDeletion#expired?'s run-count half
|
|
309
|
+
# is always satisfied here on purpose. The real gates are
|
|
310
|
+
# expires_at (grace_days) below, plus Purger#ready? requiring a
|
|
311
|
+
# verified sync to the new drive.
|
|
312
|
+
db.execute(<<~SQL, [folder_path, current.drive_serial, current.drive_serial, at, at])
|
|
313
|
+
INSERT INTO pending_deletions (folder_path, relative_path, kind, drive_serial, cause,
|
|
314
|
+
first_missing_at, last_missing_at, missing_runs)
|
|
315
|
+
VALUES (?, ?, 'folder', ?, 'reassigned', ?, ?, 1000000)
|
|
316
|
+
SQL
|
|
317
|
+
end
|
|
318
|
+
end
|
|
319
|
+
folder(folder_path)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def remove_folder(folder_path, note: nil, at: now)
|
|
323
|
+
current = folder(folder_path) or raise UnknownFolder, "#{folder_path} is not in the manifest"
|
|
324
|
+
db.transaction(:immediate) do
|
|
325
|
+
db.execute('DELETE FROM folders WHERE folder_path = ?', [folder_path])
|
|
326
|
+
record_history(folder_path, current.drive_serial, 'removed', note, at)
|
|
327
|
+
end
|
|
328
|
+
current
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
def mark_folder_status(folder_path, status, at: now)
|
|
332
|
+
db.execute('UPDATE folders SET last_sync_status = ? WHERE folder_path = ?', [status, folder_path])
|
|
333
|
+
folder(folder_path)
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# -- history --------------------------------------------------------
|
|
337
|
+
|
|
338
|
+
def history(folder_path = nil, limit: nil)
|
|
339
|
+
sql = 'SELECT * FROM placement_history'
|
|
340
|
+
params = []
|
|
341
|
+
if folder_path
|
|
342
|
+
sql += ' WHERE folder_path = ?'
|
|
343
|
+
params << folder_path
|
|
344
|
+
end
|
|
345
|
+
sql += ' ORDER BY id DESC'
|
|
346
|
+
if limit
|
|
347
|
+
sql += ' LIMIT ?'
|
|
348
|
+
params << limit
|
|
349
|
+
end
|
|
350
|
+
db.execute(sql, params).map { |row| HistoryEntry.new(**symbolize(row)) }
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# -- sync runs ------------------------------------------------------
|
|
354
|
+
|
|
355
|
+
# +run_started_at+ is when the whole `sync` began: every folder synced by
|
|
356
|
+
# one run shares it, which is what #run_summaries groups on.
|
|
357
|
+
def record_sync(folder_path:, drive_serial:, started_at:, finished_at:, exit_status:,
|
|
358
|
+
bytes_transferred: nil, total_size_bytes: nil, run_started_at: nil)
|
|
359
|
+
status = exit_status.zero? ? 'ok' : 'failed'
|
|
360
|
+
params = [folder_path, drive_serial, started_at, finished_at, exit_status, bytes_transferred, total_size_bytes,
|
|
361
|
+
run_started_at || started_at]
|
|
362
|
+
db.transaction(:immediate) do
|
|
363
|
+
db.execute(<<~SQL, params)
|
|
364
|
+
INSERT INTO sync_runs (folder_path, drive_serial, started_at, finished_at, exit_status,
|
|
365
|
+
bytes_transferred, total_size_bytes, run_started_at)
|
|
366
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
367
|
+
SQL
|
|
368
|
+
if exit_status.zero?
|
|
369
|
+
db.execute(<<~SQL, [finished_at, status, total_size_bytes, folder_path])
|
|
370
|
+
UPDATE folders SET last_synced_at = ?, last_sync_status = ?,
|
|
371
|
+
size_bytes = COALESCE(?, size_bytes)
|
|
372
|
+
WHERE folder_path = ?
|
|
373
|
+
SQL
|
|
374
|
+
else
|
|
375
|
+
db.execute('UPDATE folders SET last_sync_status = ? WHERE folder_path = ?', [status, folder_path])
|
|
376
|
+
end
|
|
377
|
+
end
|
|
378
|
+
folder(folder_path)
|
|
379
|
+
end
|
|
380
|
+
|
|
381
|
+
def sync_runs(folder_path: nil, limit: 50)
|
|
382
|
+
sql = 'SELECT * FROM sync_runs'
|
|
383
|
+
params = []
|
|
384
|
+
if folder_path
|
|
385
|
+
sql += ' WHERE folder_path = ?'
|
|
386
|
+
params << folder_path
|
|
387
|
+
end
|
|
388
|
+
sql += ' ORDER BY id DESC LIMIT ?'
|
|
389
|
+
params << limit
|
|
390
|
+
db.execute(sql, params).map { |row| SyncRun.new(**symbolize(row).except(:run_started_at)) }
|
|
391
|
+
end
|
|
392
|
+
|
|
393
|
+
# One row per `sync` run, newest first: how many folders it synced, how
|
|
394
|
+
# many actually copied data (and how much), and how many failed.
|
|
395
|
+
def run_summaries(limit: 10)
|
|
396
|
+
db.execute(<<~SQL, [limit]).map { |row| RunSummary.new(**symbolize(row)) }
|
|
397
|
+
SELECT run_started_at, MAX(finished_at) AS last_finished_at, COUNT(*) AS folders,
|
|
398
|
+
SUM(CASE WHEN COALESCE(bytes_transferred, 0) > 0 THEN 1 ELSE 0 END) AS copied,
|
|
399
|
+
COALESCE(SUM(bytes_transferred), 0) AS bytes_transferred,
|
|
400
|
+
SUM(CASE WHEN exit_status <> 0 THEN 1 ELSE 0 END) AS failed
|
|
401
|
+
FROM sync_runs WHERE run_started_at IS NOT NULL
|
|
402
|
+
GROUP BY run_started_at ORDER BY run_started_at DESC LIMIT ?
|
|
403
|
+
SQL
|
|
404
|
+
end
|
|
405
|
+
|
|
406
|
+
# drive serial => when a sync last copied data onto it (ISO 8601): what
|
|
407
|
+
# Backblaze must have scanned since for its "nothing left" to count.
|
|
408
|
+
def last_copied_at
|
|
409
|
+
db.execute(<<~SQL).to_h { |row| [row['drive_serial'], row['at']] }
|
|
410
|
+
SELECT drive_serial, MAX(finished_at) AS at FROM sync_runs
|
|
411
|
+
WHERE COALESCE(bytes_transferred, 0) > 0 GROUP BY drive_serial
|
|
412
|
+
SQL
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# The folders in one run that copied something or failed: the rest only
|
|
416
|
+
# confirmed nothing had changed.
|
|
417
|
+
def notable_sync_runs(run_started_at, limit: 100)
|
|
418
|
+
db.execute(<<~SQL, [run_started_at, limit]).map { |row| SyncRun.new(**symbolize(row).except(:run_started_at)) }
|
|
419
|
+
SELECT * FROM sync_runs
|
|
420
|
+
WHERE run_started_at = ? AND (exit_status <> 0 OR COALESCE(bytes_transferred, 0) > 0)
|
|
421
|
+
ORDER BY id LIMIT ?
|
|
422
|
+
SQL
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
# Every successful sync_run since +since+ (ISO8601), oldest first, no
|
|
426
|
+
# LIMIT: for estimating a run in progress, which can touch thousands
|
|
427
|
+
# of folders, unlike #sync_runs' recent-activity display.
|
|
428
|
+
def sync_runs_since(since)
|
|
429
|
+
db.execute('SELECT * FROM sync_runs WHERE started_at >= ? AND exit_status = 0 ORDER BY id', [since])
|
|
430
|
+
.map { |row| SyncRun.new(**symbolize(row).except(:run_started_at)) }
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# How long each folder's most recent verify-only sync (nothing
|
|
434
|
+
# transferred, exit 0) that started before +before+ (ISO8601) took, as
|
|
435
|
+
# { folder_path => seconds }. Lets a run in progress estimate an
|
|
436
|
+
# unchanged folder from its own history instead of from whichever
|
|
437
|
+
# folders happened to finish first this run.
|
|
438
|
+
def last_verify_seconds(before:)
|
|
439
|
+
db.execute(<<~SQL, [before]).to_h { |row| [row['folder_path'], row['seconds'].to_f] }
|
|
440
|
+
SELECT folder_path, (julianday(finished_at) - julianday(started_at)) * 86400.0 AS seconds
|
|
441
|
+
FROM sync_runs
|
|
442
|
+
WHERE id IN (SELECT MAX(id) FROM sync_runs
|
|
443
|
+
WHERE started_at < ? AND exit_status = 0 AND finished_at IS NOT NULL
|
|
444
|
+
AND COALESCE(bytes_transferred, 0) = 0
|
|
445
|
+
GROUP BY folder_path)
|
|
446
|
+
SQL
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# -- source inventory ----------------------------------------------
|
|
450
|
+
|
|
451
|
+
# Replaces the inventory with what this run saw. +rows+ are hashes with
|
|
452
|
+
# folder_path, size_bytes, state ('placed' | 'unplaced' | 'empty'), detail.
|
|
453
|
+
def replace_source_inventory(rows, at: now)
|
|
454
|
+
db.transaction(:immediate) do
|
|
455
|
+
db.execute('DELETE FROM source_inventory')
|
|
456
|
+
rows.each do |r|
|
|
457
|
+
db.execute('INSERT INTO source_inventory (folder_path, size_bytes, state, detail, seen_at) VALUES (?, ?, ?, ?, ?)',
|
|
458
|
+
[r[:folder_path], r[:size_bytes], r[:state], r[:detail], at])
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|
|
462
|
+
|
|
463
|
+
def source_inventory
|
|
464
|
+
db.execute('SELECT * FROM source_inventory ORDER BY folder_path').map { |row| SourceEntry.new(**symbolize(row)) }
|
|
465
|
+
end
|
|
466
|
+
|
|
467
|
+
# -- tripwire -------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
# +trips+ are Tripwire::Trip. An accepted trip is stamped with +at+.
|
|
470
|
+
def record_trips(run_started_at, trips, at: now)
|
|
471
|
+
db.transaction(:immediate) do
|
|
472
|
+
trips.each do |t|
|
|
473
|
+
params = [run_started_at, t.folder_path, t.scope, t.replaced, t.missing, t.files_on_drive,
|
|
474
|
+
JSON.generate(t.samples), t.accepted ? at : nil]
|
|
475
|
+
db.execute(<<~SQL, params)
|
|
476
|
+
INSERT INTO tripwire_trips (run_started_at, folder_path, scope, replaced, missing, files_on_drive,
|
|
477
|
+
samples, accepted_at)
|
|
478
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
479
|
+
SQL
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
# Every trip of the most recent run that recorded any, or [].
|
|
485
|
+
def latest_trips
|
|
486
|
+
db.execute(<<~SQL).map { |row| row_to_trip(row) }
|
|
487
|
+
SELECT * FROM tripwire_trips
|
|
488
|
+
WHERE run_started_at = (SELECT MAX(run_started_at) FROM tripwire_trips)
|
|
489
|
+
ORDER BY replaced + missing DESC, folder_path
|
|
490
|
+
SQL
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# Accepted trips, newest first: bulk changes someone let through.
|
|
494
|
+
def accepted_trips(limit: 20)
|
|
495
|
+
db.execute('SELECT * FROM tripwire_trips WHERE accepted_at IS NOT NULL ORDER BY id DESC LIMIT ?', [limit])
|
|
496
|
+
.map { |row| row_to_trip(row) }
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# -- pending deletions ----------------------------------------------
|
|
500
|
+
|
|
501
|
+
# Replaces the candidate set for +folder_path+ with +missing+, an array of
|
|
502
|
+
# [relative_path, kind] pairs reported by rsync this run. Paths seen before
|
|
503
|
+
# keep their first_missing_at and get their run counter bumped; paths no
|
|
504
|
+
# longer reported have reappeared on the NAS and are forgotten.
|
|
505
|
+
# Returns { new:, still:, reappeared: } counts.
|
|
506
|
+
def reconcile_pending(folder_path, missing, at: now)
|
|
507
|
+
existing = pending_deletions(folder_path: folder_path).select { |p| !p.reassigned? }.to_h { |p| [p.relative_path, p] }
|
|
508
|
+
keys = missing.map(&:first)
|
|
509
|
+
counts = { new: 0, still: 0, reappeared: 0 }
|
|
510
|
+
drive_serial = folder(folder_path)&.drive_serial
|
|
511
|
+
db.transaction(:immediate) do
|
|
512
|
+
existing.each_key do |rel|
|
|
513
|
+
next if keys.include?(rel)
|
|
514
|
+
|
|
515
|
+
db.execute("DELETE FROM pending_deletions WHERE folder_path = ? AND relative_path = ? AND cause = 'missing_on_nas'",
|
|
516
|
+
[folder_path, rel])
|
|
517
|
+
counts[:reappeared] += 1
|
|
518
|
+
end
|
|
519
|
+
missing.each do |rel, kind|
|
|
520
|
+
if existing[rel]
|
|
521
|
+
db.execute(<<~SQL, [at, kind, drive_serial, folder_path, rel])
|
|
522
|
+
UPDATE pending_deletions SET last_missing_at = ?, missing_runs = missing_runs + 1, kind = ?, drive_serial = ?
|
|
523
|
+
WHERE folder_path = ? AND relative_path = ? AND cause = 'missing_on_nas'
|
|
524
|
+
SQL
|
|
525
|
+
counts[:still] += 1
|
|
526
|
+
else
|
|
527
|
+
db.execute(<<~SQL, [folder_path, rel, kind, drive_serial, at, at])
|
|
528
|
+
INSERT INTO pending_deletions (folder_path, relative_path, kind, drive_serial, cause,
|
|
529
|
+
first_missing_at, last_missing_at, missing_runs)
|
|
530
|
+
VALUES (?, ?, ?, ?, 'missing_on_nas', ?, ?, 1)
|
|
531
|
+
SQL
|
|
532
|
+
counts[:new] += 1
|
|
533
|
+
end
|
|
534
|
+
end
|
|
535
|
+
end
|
|
536
|
+
counts
|
|
537
|
+
end
|
|
538
|
+
|
|
539
|
+
def pending_deletions(folder_path: nil)
|
|
540
|
+
sql = 'SELECT * FROM pending_deletions'
|
|
541
|
+
params = []
|
|
542
|
+
if folder_path
|
|
543
|
+
sql += ' WHERE folder_path = ?'
|
|
544
|
+
params << folder_path
|
|
545
|
+
end
|
|
546
|
+
sql += ' ORDER BY folder_path, relative_path'
|
|
547
|
+
db.execute(sql, params).map { |row| PendingDeletion.new(**symbolize(row)) }
|
|
548
|
+
end
|
|
549
|
+
|
|
550
|
+
def expired_deletions(now:, grace_days:, grace_runs:)
|
|
551
|
+
pending_deletions.select { |p| p.expired?(now: now, grace_days: grace_days, grace_runs: grace_runs) }
|
|
552
|
+
end
|
|
553
|
+
|
|
554
|
+
# Moves a candidate into the audit log once it has actually been removed.
|
|
555
|
+
def record_deletion(pending, drive_serial:, at: now)
|
|
556
|
+
params = [pending.folder_path, pending.relative_path, pending.kind, drive_serial, pending.first_missing_at, at]
|
|
557
|
+
db.transaction(:immediate) do
|
|
558
|
+
db.execute(<<~SQL, params)
|
|
559
|
+
INSERT INTO deletions (folder_path, relative_path, kind, drive_serial, first_missing_at, deleted_at)
|
|
560
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
561
|
+
SQL
|
|
562
|
+
db.execute('DELETE FROM pending_deletions WHERE id = ?', [pending.id])
|
|
563
|
+
end
|
|
564
|
+
end
|
|
565
|
+
|
|
566
|
+
# Audit row for something `clean` removed: excluded junk, gone right away.
|
|
567
|
+
def record_cleaned(folder_path:, relative_path:, kind:, drive_serial:, at: now)
|
|
568
|
+
db.execute(<<~SQL, [folder_path, relative_path, kind, drive_serial, at, at])
|
|
569
|
+
INSERT INTO deletions (folder_path, relative_path, kind, drive_serial, first_missing_at, deleted_at)
|
|
570
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
571
|
+
SQL
|
|
572
|
+
end
|
|
573
|
+
|
|
574
|
+
# Drops pending rows whose path has any segment matching one of +patterns+
|
|
575
|
+
# (shell globs): excluded junk never needs a grace period, on disk or not.
|
|
576
|
+
def forget_pending_matching(patterns)
|
|
577
|
+
pending_deletions.each do |p|
|
|
578
|
+
segments = p.relative_path.split('/')
|
|
579
|
+
next unless segments.any? { |seg| patterns.any? { |pat| File.fnmatch?(pat, seg, File::FNM_DOTMATCH) } }
|
|
580
|
+
|
|
581
|
+
db.execute('DELETE FROM pending_deletions WHERE id = ?', [p.id])
|
|
582
|
+
end
|
|
583
|
+
end
|
|
584
|
+
|
|
585
|
+
def clear_pending(folder_path)
|
|
586
|
+
db.execute('DELETE FROM pending_deletions WHERE folder_path = ?', [folder_path])
|
|
587
|
+
end
|
|
588
|
+
|
|
589
|
+
def deletions(limit: 50)
|
|
590
|
+
db.execute('SELECT * FROM deletions ORDER BY id DESC LIMIT ?', [limit]).map { |row| Deletion.new(**symbolize(row)) }
|
|
591
|
+
end
|
|
592
|
+
|
|
593
|
+
def schema_version
|
|
594
|
+
db.get_first_value('PRAGMA user_version')
|
|
595
|
+
end
|
|
596
|
+
|
|
597
|
+
# -- file checksums (scrub) ------------------------------------------
|
|
598
|
+
|
|
599
|
+
def checksum_rows(drive_serial, folder_path)
|
|
600
|
+
db.execute('SELECT * FROM file_checksums WHERE drive_serial = ? AND folder_path = ?', [drive_serial, folder_path])
|
|
601
|
+
.map { |row| row_to_checksum(row) }
|
|
602
|
+
end
|
|
603
|
+
|
|
604
|
+
# Reconciles one folder's rows against +found+, a hash of
|
|
605
|
+
# relative_path => [size_bytes, mtime] from a walk of the drive. A file
|
|
606
|
+
# with no row is inserted (digest NULL). A row whose file is gone is
|
|
607
|
+
# deleted. A row whose size or mtime differs is reset to a fresh,
|
|
608
|
+
# unhashed baseline (a legitimate re-sync, not rot). One transaction.
|
|
609
|
+
# Returns [added, removed, changed].
|
|
610
|
+
def reconcile_checksums(drive_serial, folder_path, found)
|
|
611
|
+
existing = checksum_rows(drive_serial, folder_path).to_h { |r| [r.relative_path, r] }
|
|
612
|
+
added = removed = changed = 0
|
|
613
|
+
db.transaction(:immediate) do
|
|
614
|
+
found.each do |rel, (size, mtime)|
|
|
615
|
+
row = existing[rel]
|
|
616
|
+
if row.nil?
|
|
617
|
+
db.execute(<<~SQL, [drive_serial, folder_path, rel, size, mtime])
|
|
618
|
+
INSERT INTO file_checksums (drive_serial, folder_path, relative_path, size_bytes, mtime)
|
|
619
|
+
VALUES (?, ?, ?, ?, ?)
|
|
620
|
+
SQL
|
|
621
|
+
added += 1
|
|
622
|
+
elsif row.size_bytes != size || row.mtime != mtime
|
|
623
|
+
db.execute(<<~SQL, [size, mtime, drive_serial, folder_path, rel])
|
|
624
|
+
UPDATE file_checksums
|
|
625
|
+
SET size_bytes = ?, mtime = ?, digest = NULL, verified_at = NULL, failed_at = NULL, refetched_at = NULL,
|
|
626
|
+
status = 'ok'
|
|
627
|
+
WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?
|
|
628
|
+
SQL
|
|
629
|
+
changed += 1
|
|
630
|
+
end
|
|
631
|
+
end
|
|
632
|
+
existing.each_key do |rel|
|
|
633
|
+
next if found.key?(rel)
|
|
634
|
+
|
|
635
|
+
db.execute('DELETE FROM file_checksums WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?',
|
|
636
|
+
[drive_serial, folder_path, rel])
|
|
637
|
+
removed += 1
|
|
638
|
+
end
|
|
639
|
+
end
|
|
640
|
+
[added, removed, changed]
|
|
641
|
+
end
|
|
642
|
+
|
|
643
|
+
# Drops +drive_serial+'s rows for folders no longer in +keep_folder_paths+
|
|
644
|
+
# (the folder was reassigned, removed, or purged off this drive).
|
|
645
|
+
def prune_checksums(drive_serial, keep_folder_paths)
|
|
646
|
+
if keep_folder_paths.empty?
|
|
647
|
+
db.execute('DELETE FROM file_checksums WHERE drive_serial = ?', [drive_serial])
|
|
648
|
+
else
|
|
649
|
+
placeholders = keep_folder_paths.map { '?' }.join(',')
|
|
650
|
+
db.execute("DELETE FROM file_checksums WHERE drive_serial = ? AND folder_path NOT IN (#{placeholders})",
|
|
651
|
+
[drive_serial, *keep_folder_paths])
|
|
652
|
+
end
|
|
653
|
+
end
|
|
654
|
+
|
|
655
|
+
# The hashing work queue: 'ok' rows (already-hashed ones are due for
|
|
656
|
+
# periodic re-verification too) plus flagged rows sync has refetched,
|
|
657
|
+
# ordered refetched-repairs first, then never-hashed, then oldest
|
|
658
|
+
# verified first. A flagged row still awaiting refetch, or 'unresolved',
|
|
659
|
+
# is never returned: re-hashing it proves nothing new.
|
|
660
|
+
def checksum_frontier(drive_serial)
|
|
661
|
+
db.execute(<<~SQL, [drive_serial]).map { |row| row_to_checksum(row) }
|
|
662
|
+
SELECT * FROM file_checksums
|
|
663
|
+
WHERE drive_serial = ?
|
|
664
|
+
AND (status = 'ok'
|
|
665
|
+
OR (status IN ('corrupt','unreadable') AND refetched_at IS NOT NULL))
|
|
666
|
+
ORDER BY
|
|
667
|
+
CASE WHEN status <> 'ok' THEN 0
|
|
668
|
+
WHEN digest IS NULL THEN 1
|
|
669
|
+
ELSE 2 END,
|
|
670
|
+
verified_at, folder_path, relative_path
|
|
671
|
+
SQL
|
|
672
|
+
end
|
|
673
|
+
|
|
674
|
+
# Records the outcome of hashing one file. +outcome+ is one of
|
|
675
|
+
# :baseline (first-ever hash), :confirmed (matches), :corrupt (differs),
|
|
676
|
+
# :unreadable (Errno::EIO), :repaired (a flagged+refetched row now
|
|
677
|
+
# matches, or got its first successful read), :unresolved (a
|
|
678
|
+
# flagged+refetched row still bad), :vanished (Errno::ENOENT: row
|
|
679
|
+
# deleted). The digest is kept, not cleared, when marking corrupt: it is
|
|
680
|
+
# the known-good baseline a later repair is checked against.
|
|
681
|
+
def checksum_hashed(drive_serial, folder_path, relative_path, outcome:, digest: nil, at: now)
|
|
682
|
+
key = [drive_serial, folder_path, relative_path]
|
|
683
|
+
case outcome
|
|
684
|
+
when :baseline
|
|
685
|
+
db.execute('UPDATE file_checksums SET digest = ?, verified_at = ? WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?',
|
|
686
|
+
[digest, at, *key])
|
|
687
|
+
when :confirmed
|
|
688
|
+
db.execute('UPDATE file_checksums SET verified_at = ? WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?',
|
|
689
|
+
[at, *key])
|
|
690
|
+
when :corrupt
|
|
691
|
+
db.execute("UPDATE file_checksums SET status = 'corrupt', failed_at = ? WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?",
|
|
692
|
+
[at, *key])
|
|
693
|
+
when :unreadable
|
|
694
|
+
db.execute("UPDATE file_checksums SET status = 'unreadable', failed_at = ? WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?",
|
|
695
|
+
[at, *key])
|
|
696
|
+
when :repaired
|
|
697
|
+
db.execute(<<~SQL, [digest, at, *key])
|
|
698
|
+
UPDATE file_checksums
|
|
699
|
+
SET status = 'ok', digest = COALESCE(?, digest), verified_at = ?, failed_at = NULL, refetched_at = NULL
|
|
700
|
+
WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?
|
|
701
|
+
SQL
|
|
702
|
+
when :unresolved
|
|
703
|
+
db.execute("UPDATE file_checksums SET status = 'unresolved' WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?",
|
|
704
|
+
key)
|
|
705
|
+
when :vanished
|
|
706
|
+
db.execute('DELETE FROM file_checksums WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?', key)
|
|
707
|
+
else
|
|
708
|
+
raise ArgumentError, "unknown checksum outcome #{outcome.inspect}"
|
|
709
|
+
end
|
|
710
|
+
end
|
|
711
|
+
|
|
712
|
+
# Rows flagged by scrub that sync should refetch: not yet refetched
|
|
713
|
+
# since being flagged. 'unresolved' rows are excluded on purpose - they
|
|
714
|
+
# are never refetched again automatically.
|
|
715
|
+
def flagged_checksums(drive_serial, folder_path)
|
|
716
|
+
db.execute(<<~SQL, [drive_serial, folder_path]).map { |row| row_to_checksum(row) }
|
|
717
|
+
SELECT * FROM file_checksums
|
|
718
|
+
WHERE drive_serial = ? AND folder_path = ? AND status IN ('corrupt', 'unreadable') AND refetched_at IS NULL
|
|
719
|
+
ORDER BY relative_path
|
|
720
|
+
SQL
|
|
721
|
+
end
|
|
722
|
+
|
|
723
|
+
def mark_refetched(drive_serial, folder_path, relative_paths, at: now)
|
|
724
|
+
relative_paths.each do |rel|
|
|
725
|
+
db.execute('UPDATE file_checksums SET refetched_at = ? WHERE drive_serial = ? AND folder_path = ? AND relative_path = ?',
|
|
726
|
+
[at, drive_serial, folder_path, rel])
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
# Every non-ok row for one folder on one drive: what `restore` warns
|
|
731
|
+
# about before copying it back onto the NAS. Unlike #flagged_checksums,
|
|
732
|
+
# this also includes rows already refetched or 'unresolved'.
|
|
733
|
+
def scrub_findings_for(drive_serial, folder_path)
|
|
734
|
+
db.execute("SELECT * FROM file_checksums WHERE drive_serial = ? AND folder_path = ? AND status <> 'ok' ORDER BY relative_path",
|
|
735
|
+
[drive_serial, folder_path]).map { |row| row_to_checksum(row) }
|
|
736
|
+
end
|
|
737
|
+
|
|
738
|
+
# Every non-ok row on an active drive, newest failure first: the
|
|
739
|
+
# dashboard's "Scrub findings" section and `status`'s summary count.
|
|
740
|
+
# Retired drives are left out: `scrub` refuses them, so their findings
|
|
741
|
+
# could never be cleared.
|
|
742
|
+
def scrub_findings
|
|
743
|
+
db.execute(<<~SQL).map { |row| row_to_checksum(row) }
|
|
744
|
+
SELECT c.* FROM file_checksums c JOIN drives d ON d.serial_number = c.drive_serial
|
|
745
|
+
WHERE c.status <> 'ok' AND d.retired_at IS NULL
|
|
746
|
+
ORDER BY c.failed_at DESC
|
|
747
|
+
SQL
|
|
748
|
+
end
|
|
749
|
+
|
|
750
|
+
# The oldest verified_at across a drive's 'ok' rows: the moment since
|
|
751
|
+
# which every healthy file on it has been checked. NULL if it has no
|
|
752
|
+
# 'ok' rows yet, or any has never been hashed. Flagged and unresolved
|
|
753
|
+
# rows are left out on purpose: they are skipped by the hash queue, so
|
|
754
|
+
# their verified_at never advances, and counting them would pin the
|
|
755
|
+
# drive as stalest (and overdue) forever. They are reported as findings.
|
|
756
|
+
def scrubbed_through(drive_serial)
|
|
757
|
+
ok = "drive_serial = ? AND status = 'ok'"
|
|
758
|
+
return nil if db.get_first_value("SELECT COUNT(*) FROM file_checksums WHERE #{ok}", [drive_serial]).zero?
|
|
759
|
+
return nil if db.get_first_value("SELECT COUNT(*) FROM file_checksums WHERE #{ok} AND digest IS NULL", [drive_serial]).positive?
|
|
760
|
+
|
|
761
|
+
db.get_first_value("SELECT MIN(verified_at) FROM file_checksums WHERE #{ok}", [drive_serial])
|
|
762
|
+
end
|
|
763
|
+
|
|
764
|
+
# How many of this drive's tracked files have been verified at or
|
|
765
|
+
# after +since+ (normally the current scrub run's start time), out of
|
|
766
|
+
# how many are tracked in total. Deliberately not "ever hashed": a
|
|
767
|
+
# drive scrubbed before already has a digest for nearly every row,
|
|
768
|
+
# which would read as "done" the instant a fresh run started.
|
|
769
|
+
def checksum_progress(drive_serial, since:)
|
|
770
|
+
total = db.get_first_value('SELECT COUNT(*) FROM file_checksums WHERE drive_serial = ?', [drive_serial])
|
|
771
|
+
checked = db.get_first_value(
|
|
772
|
+
'SELECT COUNT(*) FROM file_checksums WHERE drive_serial = ? AND verified_at >= ?', [drive_serial, since]
|
|
773
|
+
)
|
|
774
|
+
{ checked: checked, total: total }
|
|
775
|
+
end
|
|
776
|
+
|
|
777
|
+
# -- benchmarks -------------------------------------------------------
|
|
778
|
+
|
|
779
|
+
# Records one `benchmark` run and drops the drive's older runs beyond
|
|
780
|
+
# BENCHMARKS_KEPT, in one transaction.
|
|
781
|
+
def record_benchmark(drive_serial, bytes:, write_mb_s:, read_mb_s:, used_bytes:, at: now)
|
|
782
|
+
ensure_drive!(drive_serial)
|
|
783
|
+
db.transaction(:immediate) do
|
|
784
|
+
db.execute(<<~SQL, [drive_serial, at, bytes, write_mb_s, read_mb_s, used_bytes])
|
|
785
|
+
INSERT INTO drive_benchmarks (drive_serial, run_at, bytes, write_mb_s, read_mb_s, used_bytes)
|
|
786
|
+
VALUES (?, ?, ?, ?, ?, ?)
|
|
787
|
+
SQL
|
|
788
|
+
db.execute(<<~SQL, [drive_serial, drive_serial, BENCHMARKS_KEPT])
|
|
789
|
+
DELETE FROM drive_benchmarks
|
|
790
|
+
WHERE drive_serial = ?
|
|
791
|
+
AND id NOT IN (SELECT id FROM drive_benchmarks WHERE drive_serial = ?
|
|
792
|
+
ORDER BY run_at DESC, id DESC LIMIT ?)
|
|
793
|
+
SQL
|
|
794
|
+
end
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
# A drive's kept runs, newest first.
|
|
798
|
+
def benchmarks(drive_serial)
|
|
799
|
+
db.execute('SELECT * FROM drive_benchmarks WHERE drive_serial = ? ORDER BY run_at DESC, id DESC', [drive_serial])
|
|
800
|
+
.map { |row| DriveBenchmark.new(**symbolize(row)) }
|
|
801
|
+
end
|
|
802
|
+
|
|
803
|
+
# When the drive was last benchmarked, or nil if never.
|
|
804
|
+
def last_benchmarked_at(drive_serial)
|
|
805
|
+
db.get_first_value('SELECT MAX(run_at) FROM drive_benchmarks WHERE drive_serial = ?', [drive_serial])
|
|
806
|
+
end
|
|
807
|
+
|
|
808
|
+
private
|
|
809
|
+
|
|
810
|
+
def now = @clock.now.utc.iso8601
|
|
811
|
+
|
|
812
|
+
def ensure_drive!(serial_number)
|
|
813
|
+
drive(serial_number) or raise UnknownDrive, "no drive registered with serial #{serial_number}"
|
|
814
|
+
end
|
|
815
|
+
|
|
816
|
+
def record_history(folder_path, drive_serial, event, note, at)
|
|
817
|
+
db.execute(<<~SQL, [folder_path, drive_serial, event, at, note])
|
|
818
|
+
INSERT INTO placement_history (folder_path, drive_serial, event, recorded_at, note)
|
|
819
|
+
VALUES (?, ?, ?, ?, ?)
|
|
820
|
+
SQL
|
|
821
|
+
end
|
|
822
|
+
|
|
823
|
+
def row_to_drive(row) = Drive.new(**symbolize(row))
|
|
824
|
+
def row_to_folder(row) = Folder.new(**symbolize(row))
|
|
825
|
+
def row_to_checksum(row) = FileChecksum.new(**symbolize(row))
|
|
826
|
+
|
|
827
|
+
def row_to_trip(row)
|
|
828
|
+
TripRecord.new(**symbolize(row), samples: JSON.parse(row['samples'] || '[]'))
|
|
829
|
+
end
|
|
830
|
+
|
|
831
|
+
def symbolize(row)
|
|
832
|
+
row.to_h.transform_keys(&:to_sym)
|
|
833
|
+
end
|
|
834
|
+
|
|
835
|
+
# Columns added after v1 are applied by checking for them, never by
|
|
836
|
+
# comparing user_version: the real manifest was stamped 5 by pre-2.0
|
|
837
|
+
# builds, so a version-gated ALTER silently never ran on it.
|
|
838
|
+
def migrate!
|
|
839
|
+
migrate_to_v1! if schema_version < 1
|
|
840
|
+
add_column('drives', 'model', 'TEXT')
|
|
841
|
+
add_column('drives', 'power_on_hours', 'INTEGER')
|
|
842
|
+
create_smart_checks_table!
|
|
843
|
+
# CREATE TABLE IF NOT EXISTS first: add_column assumes its table
|
|
844
|
+
# already exists, which migrate_to_v1! only guarantees when it
|
|
845
|
+
# actually ran (schema_version < 1). A real manifest can be older
|
|
846
|
+
# than this tool and already have pending_deletions without these
|
|
847
|
+
# columns, so add_column still does the column-level check too.
|
|
848
|
+
create_pending_deletions_table!
|
|
849
|
+
add_column('pending_deletions', 'drive_serial', 'TEXT')
|
|
850
|
+
add_column('pending_deletions', 'cause', "TEXT NOT NULL DEFAULT 'missing_on_nas'")
|
|
851
|
+
add_column('folders', 'scope', "TEXT NOT NULL DEFAULT 'tree'")
|
|
852
|
+
if add_column('sync_runs', 'run_started_at', 'TEXT')
|
|
853
|
+
backfill_run_started_at!
|
|
854
|
+
db.execute('CREATE INDEX IF NOT EXISTS idx_sync_runs_run ON sync_runs(run_started_at)')
|
|
855
|
+
end
|
|
856
|
+
create_file_checksums_table!
|
|
857
|
+
create_drive_benchmarks_table!
|
|
858
|
+
create_tripwire_trips_table!
|
|
859
|
+
db.execute("PRAGMA user_version = #{SCHEMA_VERSION}") if schema_version < SCHEMA_VERSION
|
|
860
|
+
end
|
|
861
|
+
|
|
862
|
+
def create_file_checksums_table!
|
|
863
|
+
db.execute_batch(<<~SQL)
|
|
864
|
+
CREATE TABLE IF NOT EXISTS file_checksums (
|
|
865
|
+
drive_serial TEXT NOT NULL REFERENCES drives(serial_number),
|
|
866
|
+
folder_path TEXT NOT NULL,
|
|
867
|
+
relative_path TEXT NOT NULL,
|
|
868
|
+
size_bytes INTEGER NOT NULL,
|
|
869
|
+
mtime INTEGER NOT NULL,
|
|
870
|
+
digest TEXT,
|
|
871
|
+
verified_at TEXT,
|
|
872
|
+
status TEXT NOT NULL DEFAULT 'ok',
|
|
873
|
+
failed_at TEXT,
|
|
874
|
+
refetched_at TEXT,
|
|
875
|
+
PRIMARY KEY (drive_serial, folder_path, relative_path)
|
|
876
|
+
);
|
|
877
|
+
CREATE INDEX IF NOT EXISTS idx_file_checksums_frontier
|
|
878
|
+
ON file_checksums(drive_serial, status, verified_at);
|
|
879
|
+
SQL
|
|
880
|
+
end
|
|
881
|
+
|
|
882
|
+
def create_drive_benchmarks_table!
|
|
883
|
+
db.execute_batch(<<~SQL)
|
|
884
|
+
CREATE TABLE IF NOT EXISTS drive_benchmarks (
|
|
885
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
886
|
+
drive_serial TEXT NOT NULL REFERENCES drives(serial_number),
|
|
887
|
+
run_at TEXT NOT NULL,
|
|
888
|
+
bytes INTEGER NOT NULL,
|
|
889
|
+
write_mb_s REAL NOT NULL,
|
|
890
|
+
read_mb_s REAL NOT NULL,
|
|
891
|
+
used_bytes INTEGER
|
|
892
|
+
);
|
|
893
|
+
CREATE INDEX IF NOT EXISTS idx_drive_benchmarks_drive ON drive_benchmarks(drive_serial, run_at);
|
|
894
|
+
SQL
|
|
895
|
+
end
|
|
896
|
+
|
|
897
|
+
def create_tripwire_trips_table!
|
|
898
|
+
db.execute_batch(<<~SQL)
|
|
899
|
+
CREATE TABLE IF NOT EXISTS tripwire_trips (
|
|
900
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
901
|
+
run_started_at TEXT NOT NULL,
|
|
902
|
+
folder_path TEXT NOT NULL,
|
|
903
|
+
scope TEXT NOT NULL,
|
|
904
|
+
replaced INTEGER NOT NULL,
|
|
905
|
+
missing INTEGER NOT NULL,
|
|
906
|
+
files_on_drive INTEGER NOT NULL,
|
|
907
|
+
samples TEXT NOT NULL DEFAULT '[]',
|
|
908
|
+
accepted_at TEXT
|
|
909
|
+
);
|
|
910
|
+
CREATE INDEX IF NOT EXISTS idx_tripwire_trips_run ON tripwire_trips(run_started_at);
|
|
911
|
+
SQL
|
|
912
|
+
end
|
|
913
|
+
|
|
914
|
+
def create_smart_checks_table!
|
|
915
|
+
db.execute_batch(<<~SQL)
|
|
916
|
+
CREATE TABLE IF NOT EXISTS smart_checks (
|
|
917
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
918
|
+
drive_serial TEXT NOT NULL REFERENCES drives(serial_number),
|
|
919
|
+
checked_at TEXT NOT NULL,
|
|
920
|
+
reallocated_sector_ct INTEGER,
|
|
921
|
+
verified INTEGER NOT NULL DEFAULT 0,
|
|
922
|
+
note TEXT
|
|
923
|
+
);
|
|
924
|
+
CREATE INDEX IF NOT EXISTS idx_smart_checks_drive ON smart_checks(drive_serial, checked_at);
|
|
925
|
+
SQL
|
|
926
|
+
end
|
|
927
|
+
|
|
928
|
+
def create_pending_deletions_table!
|
|
929
|
+
db.execute(<<~SQL)
|
|
930
|
+
CREATE TABLE IF NOT EXISTS pending_deletions (
|
|
931
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
932
|
+
folder_path TEXT NOT NULL,
|
|
933
|
+
relative_path TEXT NOT NULL,
|
|
934
|
+
kind TEXT NOT NULL,
|
|
935
|
+
first_missing_at TEXT NOT NULL,
|
|
936
|
+
last_missing_at TEXT NOT NULL,
|
|
937
|
+
missing_runs INTEGER NOT NULL DEFAULT 1,
|
|
938
|
+
UNIQUE (folder_path, relative_path)
|
|
939
|
+
);
|
|
940
|
+
SQL
|
|
941
|
+
end
|
|
942
|
+
|
|
943
|
+
# No-op for a table that doesn't exist (nothing to add to). True when
|
|
944
|
+
# the column was added just now.
|
|
945
|
+
def add_column(table, name, type)
|
|
946
|
+
columns = db.execute("PRAGMA table_info(#{table})")
|
|
947
|
+
return false if columns.empty? || columns.any? { |c| c['name'] == name }
|
|
948
|
+
|
|
949
|
+
db.execute("ALTER TABLE #{table} ADD COLUMN #{name} #{type}")
|
|
950
|
+
true
|
|
951
|
+
end
|
|
952
|
+
|
|
953
|
+
# Runs recorded before run_started_at existed are grouped once. A run
|
|
954
|
+
# syncs each folder at most once, back to back, and a restarted run
|
|
955
|
+
# starts over from its first folder. So a new run began wherever a
|
|
956
|
+
# folder repeats within the current run, or where more than
|
|
957
|
+
# RUN_GAP_SECONDS passed between one folder finishing and the next
|
|
958
|
+
# starting.
|
|
959
|
+
RUN_GAP_SECONDS = 600
|
|
960
|
+
|
|
961
|
+
def backfill_run_started_at!
|
|
962
|
+
rows = db.execute('SELECT id, folder_path, started_at, finished_at FROM sync_runs ORDER BY id')
|
|
963
|
+
return if rows.empty?
|
|
964
|
+
|
|
965
|
+
run_start = nil
|
|
966
|
+
last_end = nil
|
|
967
|
+
seen = Set.new
|
|
968
|
+
db.transaction(:immediate) do
|
|
969
|
+
rows.each do |r|
|
|
970
|
+
started = Time.parse(r['started_at'])
|
|
971
|
+
if last_end.nil? || started - last_end > RUN_GAP_SECONDS || seen.include?(r['folder_path'])
|
|
972
|
+
run_start = r['started_at']
|
|
973
|
+
seen.clear
|
|
974
|
+
end
|
|
975
|
+
seen << r['folder_path']
|
|
976
|
+
db.execute('UPDATE sync_runs SET run_started_at = ? WHERE id = ?', [run_start, r['id']])
|
|
977
|
+
last_end = r['finished_at'] ? Time.parse(r['finished_at']) : started
|
|
978
|
+
end
|
|
979
|
+
end
|
|
980
|
+
end
|
|
981
|
+
|
|
982
|
+
def migrate_to_v1!
|
|
983
|
+
db.transaction(:immediate) do
|
|
984
|
+
db.execute_batch(<<~SQL)
|
|
985
|
+
CREATE TABLE IF NOT EXISTS drives (
|
|
986
|
+
serial_number TEXT PRIMARY KEY,
|
|
987
|
+
friendly_name TEXT NOT NULL UNIQUE,
|
|
988
|
+
capacity_bytes INTEGER NOT NULL,
|
|
989
|
+
added_date TEXT NOT NULL,
|
|
990
|
+
volume_uuid TEXT,
|
|
991
|
+
last_seen_at TEXT,
|
|
992
|
+
last_used_bytes INTEGER,
|
|
993
|
+
last_free_bytes INTEGER,
|
|
994
|
+
smart_status TEXT,
|
|
995
|
+
smart_detail TEXT,
|
|
996
|
+
smart_checked_at TEXT,
|
|
997
|
+
retired_at TEXT
|
|
998
|
+
);
|
|
999
|
+
|
|
1000
|
+
CREATE TABLE IF NOT EXISTS folders (
|
|
1001
|
+
folder_path TEXT PRIMARY KEY,
|
|
1002
|
+
drive_serial TEXT NOT NULL REFERENCES drives(serial_number),
|
|
1003
|
+
size_bytes INTEGER,
|
|
1004
|
+
assigned_at TEXT NOT NULL,
|
|
1005
|
+
last_synced_at TEXT,
|
|
1006
|
+
last_sync_status TEXT
|
|
1007
|
+
);
|
|
1008
|
+
|
|
1009
|
+
CREATE TABLE IF NOT EXISTS placement_history (
|
|
1010
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1011
|
+
folder_path TEXT NOT NULL,
|
|
1012
|
+
drive_serial TEXT NOT NULL,
|
|
1013
|
+
event TEXT NOT NULL,
|
|
1014
|
+
recorded_at TEXT NOT NULL,
|
|
1015
|
+
note TEXT
|
|
1016
|
+
);
|
|
1017
|
+
CREATE INDEX IF NOT EXISTS idx_history_folder ON placement_history(folder_path);
|
|
1018
|
+
|
|
1019
|
+
CREATE TABLE IF NOT EXISTS sync_runs (
|
|
1020
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1021
|
+
folder_path TEXT NOT NULL,
|
|
1022
|
+
drive_serial TEXT NOT NULL,
|
|
1023
|
+
started_at TEXT NOT NULL,
|
|
1024
|
+
finished_at TEXT,
|
|
1025
|
+
exit_status INTEGER,
|
|
1026
|
+
bytes_transferred INTEGER,
|
|
1027
|
+
total_size_bytes INTEGER
|
|
1028
|
+
);
|
|
1029
|
+
CREATE INDEX IF NOT EXISTS idx_sync_runs_folder ON sync_runs(folder_path);
|
|
1030
|
+
|
|
1031
|
+
CREATE TABLE IF NOT EXISTS pending_deletions (
|
|
1032
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1033
|
+
folder_path TEXT NOT NULL,
|
|
1034
|
+
relative_path TEXT NOT NULL,
|
|
1035
|
+
kind TEXT NOT NULL,
|
|
1036
|
+
drive_serial TEXT,
|
|
1037
|
+
cause TEXT NOT NULL DEFAULT 'missing_on_nas',
|
|
1038
|
+
first_missing_at TEXT NOT NULL,
|
|
1039
|
+
last_missing_at TEXT NOT NULL,
|
|
1040
|
+
missing_runs INTEGER NOT NULL DEFAULT 1,
|
|
1041
|
+
UNIQUE (folder_path, relative_path)
|
|
1042
|
+
);
|
|
1043
|
+
|
|
1044
|
+
CREATE TABLE IF NOT EXISTS source_inventory (
|
|
1045
|
+
folder_path TEXT PRIMARY KEY,
|
|
1046
|
+
size_bytes INTEGER,
|
|
1047
|
+
state TEXT NOT NULL,
|
|
1048
|
+
detail TEXT,
|
|
1049
|
+
seen_at TEXT NOT NULL
|
|
1050
|
+
);
|
|
1051
|
+
|
|
1052
|
+
CREATE TABLE IF NOT EXISTS deletions (
|
|
1053
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1054
|
+
folder_path TEXT NOT NULL,
|
|
1055
|
+
relative_path TEXT NOT NULL,
|
|
1056
|
+
kind TEXT NOT NULL,
|
|
1057
|
+
drive_serial TEXT NOT NULL,
|
|
1058
|
+
first_missing_at TEXT NOT NULL,
|
|
1059
|
+
deleted_at TEXT NOT NULL
|
|
1060
|
+
);
|
|
1061
|
+
SQL
|
|
1062
|
+
db.execute('PRAGMA user_version = 1')
|
|
1063
|
+
end
|
|
1064
|
+
end
|
|
1065
|
+
|
|
1066
|
+
end
|
|
1067
|
+
end
|
|
1068
|
+
end
|