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