Class: Rubino::Database::Migrator

Inherits:
Object
  • Object
show all
Defined in:
lib/rubino/database/migrator.rb

Overview

Handles database schema migrations in order. Migrations are stored as numbered Sequel migration files.

Constant Summary collapse

MIGRATIONS_PATH =
File.expand_path("migrations", __dir__)

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(connection) ⇒ Migrator

Returns a new instance of Migrator.



14
15
16
# File 'lib/rubino/database/migrator.rb', line 14

def initialize(connection)
  @connection = connection
end

Class Method Details

.latest_versionObject

The highest migration version present on disk (the target schema). Read off the numbered filenames, so it needs no DB connection.



20
21
22
23
24
# File 'lib/rubino/database/migrator.rb', line 20

def self.latest_version
  @latest_version ||= Dir.glob(File.join(MIGRATIONS_PATH, "*.rb"))
                         .map { |f| File.basename(f)[/\A(\d+)/, 1].to_i }
                         .max || 0
end

Instance Method Details

#migrate!(lock_path: nil) ⇒ Object

Runs all pending migrations, serialized across processes by a file lock.

CONCURRENCY (#race): every command boot migrates when the schema isn’t current, so a fan-out of N fresh ‘rubino` processes on a brand-new home would all migrate AT ONCE. Sequel’s IntegerMigrator is NOT concurrency safe at TWO points:

1. Even constructing it (which `pending?` does) runs
   `INSERT INTO schema_info VALUES (0) IF empty` — two racers both see
   an empty table and both insert → a DUPLICATE version row. That later
   makes `sessions list` hard-crash with a raw `no such table`
   backtrace, and wedges `pending?` itself ("More than 1 row in
   migrator table").
2. The migration steps themselves can interleave → a DB stuck at an
   intermediate version.

SQLite ships no migration advisory lock; Rails serializes migrations with a DB advisory lock for exactly this reason, and an OS file lock is the idiomatic Ruby equivalent. So when a lock_path is given we take an EXCLUSIVE flock and run the ENTIRE probe-and-migrate under it (the caller’s fast path uses the side-effect-free ‘up_to_date?` to avoid even constructing a Sequel migrator off the lock). A process that BLOCKED waiting for the lock re-checks `pending?` inside it and no-ops when the winner already migrated (double-checked locking). Without a lock_path (in-memory DBs, tests) the behaviour is unchanged.



49
50
51
52
53
54
55
56
# File 'lib/rubino/database/migrator.rb', line 49

def migrate!(lock_path: nil)
  return run_migrations! if lock_path.nil? || @connection.memory?

  with_migration_lock(lock_path) do
    # Double-checked under the lock: the winner migrated while we waited.
    run_migrations! if pending?
  end
end

#pending?Boolean

Returns true if there are unapplied migrations.

Intentionally does NOT rescue: a connection/schema error here is a real health problem and must propagate so callers (e.g. doctor) can report a failure instead of silently treating an unreachable DB as “up to date”.

Returns:

  • (Boolean)


80
81
82
# File 'lib/rubino/database/migrator.rb', line 80

def pending?
  !Sequel::Migrator.is_current?(@connection.db, MIGRATIONS_PATH)
end

#up_to_date?Boolean

Side-effect-FREE check that the schema is fully migrated, safe to call OFF the lock and concurrently. Reads ‘schema_info` directly rather than constructing a Sequel migrator (whose mere construction inserts the version-0 row and so races, #race). Returns true only when the table exists, holds EXACTLY ONE row, and that row is at the latest version. Anything else (missing table, zero/duplicate rows, behind) returns false → the caller takes the lock and does the real migrate under it.

Returns:

  • (Boolean)


65
66
67
68
69
70
71
72
73
# File 'lib/rubino/database/migrator.rb', line 65

def up_to_date?
  db = @connection.db
  return false unless db.table_exists?(:schema_info)

  versions = db[:schema_info].select_map(:version)
  versions.size == 1 && versions.first == self.class.latest_version
rescue StandardError
  false
end