Class: Pikuri::Thunderbird::DatabaseSnapshot

Inherits:
Object
  • Object
show all
Defined in:
lib/pikuri/thunderbird/database_snapshot.rb

Overview

A consistent, lock-free, on-disk copy of a live Thunderbird SQLite DB (Gloda or a calendar store), so pikuri never queries the file Thunderbird holds open. Thunderbird keeps its DBs locked (+locking_mode=EXCLUSIVE+), so the only live-file touch is a bracketed cp --reflink=auto; every actual query runs against the copy we own.

snap = DatabaseSnapshot.new(source: profile.gloda_path, dir: cache_dir)
snap.refresh          # (re)copies iff the source generation moved
run_query_against(snap.path)
snap.generation       # the source change-counter this copy is as-of

Why bracketed

A reflink clone is atomic w.r.t. concurrent writes, but file atomicity ≠ DB consistency: a clone taken mid-transaction faithfully photographs a torn DB. So each copy is bracketed — read the change counter (header bytes 24–27) and check -journal absence before and after the copy; a copy counts as settled only when the counter didn't move and no rollback journal was present at either edge. Otherwise the copy is discarded and retried after a short backoff.

The validated change counter doubles as the generation stamp: a caller checks freshness by comparing DatabaseSnapshot.change_counter of the live source against #generation, and #refresh re-copies only when they differ. One signal drives both consistency and freshness.

Implementation details

On-disk rather than an :memory: load: the page cache keeps idle RAM ≈ 0 for a multi-hundred-MB Gloda, which a memory copy would pin for the life of the process.

The copy lands in a caller-owned 0700 dir on the same filesystem as the source (so reflink's copy-on-write extent share is available; --reflink=auto degrades to a full copy elsewhere). It is Finalizers-reaped, so a normal exit removes it; a crash can strand it (re-derivable data). Only the main DB file is copied — for a WAL store that yields an as-of-last-checkpoint view (Thunderbird checkpoints aggressively), which callers open with ?immutable=1.

Defined Under Namespace

Classes: TornError

Constant Summary collapse

LOGGER =
Pikuri.logger_for('Thunderbird::DatabaseSnapshot')

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(source:, dir:) ⇒ DatabaseSnapshot

Parameters:

  • source (String)

    path to the live Thunderbird DB.

  • dir (String)

    private dir to hold the copy (created 0700 if absent); must sit on the same filesystem as source for reflink.



79
80
81
82
83
84
85
86
87
# File 'lib/pikuri/thunderbird/database_snapshot.rb', line 79

def initialize(source:, dir:)
  @source = source
  @dir = dir
  @path = File.join(dir, File.basename(source))
  @generation = nil
  FileUtils.mkdir_p(@dir, mode: 0o700)
  # Fire-and-forget reap of the whole private dir (block form).
  Pikuri::Finalizers.register { FileUtils.rm_rf(@dir) }
end

Instance Attribute Details

#generationInteger? (readonly)

Returns the source change-counter this copy reflects; nil before the first successful #refresh.

Returns:

  • (Integer, nil)

    the source change-counter this copy reflects; nil before the first successful #refresh.



59
60
61
# File 'lib/pikuri/thunderbird/database_snapshot.rb', line 59

def generation
  @generation
end

#pathString (readonly)

Returns absolute path of the snapshot copy.

Returns:

  • (String)

    absolute path of the snapshot copy.



55
56
57
# File 'lib/pikuri/thunderbird/database_snapshot.rb', line 55

def path
  @path
end

Class Method Details

.change_counter(db_path) ⇒ Integer?

SQLite's file change counter: 4 big-endian bytes at header offset 24, bumped only when the DB is unlocked after a modification (so a read never moves it — a file-format guarantee). nil if the file is too short to have a header yet.

Parameters:

  • db_path (String)

Returns:

  • (Integer, nil)


68
69
70
71
72
73
# File 'lib/pikuri/thunderbird/database_snapshot.rb', line 68

def self.change_counter(db_path)
  header = File.binread(db_path, 4, 24)
  header && header.bytesize == 4 ? header.unpack1('N') : nil
rescue Errno::ENOENT, EOFError
  nil
end

Instance Method Details

#refresh(attempts: 6, backoff: 0.08) ⇒ Boolean

(Re)take the snapshot iff the live source generation differs from the copy we hold — a no-op (returning false) when already current.

Parameters:

  • attempts (Integer) (defaults to: 6)

    bracket retries before giving up.

  • backoff (Float) (defaults to: 0.08)

    seconds between retries.

Returns:

  • (Boolean)

    true if a fresh copy was taken this call.

Raises:

  • (TornError)

    if no settled copy could be taken.



96
97
98
99
100
101
102
# File 'lib/pikuri/thunderbird/database_snapshot.rb', line 96

def refresh(attempts: 6, backoff: 0.08)
  live = self.class.change_counter(@source)
  return false if @generation && live == @generation && File.file?(@path)

  copy_bracketed(attempts, backoff)
  true
end