Class: Pikuri::Thunderbird::DatabaseSnapshot
- Inherits:
-
Object
- Object
- Pikuri::Thunderbird::DatabaseSnapshot
- 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
-
#generation ⇒ Integer?
readonly
The source change-counter this copy reflects;
nilbefore the first successful #refresh. -
#path ⇒ String
readonly
Absolute path of the snapshot copy.
Class Method Summary collapse
-
.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).
Instance Method Summary collapse
- #initialize(source:, dir:) ⇒ DatabaseSnapshot constructor
-
#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.
Constructor Details
#initialize(source:, dir:) ⇒ DatabaseSnapshot
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
#generation ⇒ Integer? (readonly)
Returns 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 |
#path ⇒ String (readonly)
Returns 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.
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.
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 |