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

#current_versionObject

Returns the current migration version: the highest version recorded in the ‘schema_info` bookkeeping table, or 0 when it is missing/empty.

Read MAX(version) DIRECTLY: Sequel 5.105 dropped ‘Sequel::Migrator.get_current_migration_version`, so the old call raised NoMethodError on every invocation. The version row IS the schema version (IntegerMigrator writes the applied number there), so MAX(version) is the authoritative reading.



83
84
85
86
87
88
89
90
# File 'lib/rubino/database/migrator.rb', line 83

def current_version
  db = @connection.db
  return 0 unless db.table_exists?(:schema_info)

  db[:schema_info].max(:version).to_i
rescue StandardError
  0
end

#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)


97
98
99
# File 'lib/rubino/database/migrator.rb', line 97

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

#pending_migrationsObject

Returns list of pending migration files



102
103
104
105
106
107
108
# File 'lib/rubino/database/migrator.rb', line 102

def pending_migrations
  Sequel::Migrator.migrator_class(MIGRATIONS_PATH)
                  .new(@connection.db, MIGRATIONS_PATH)
                  .files
rescue StandardError
  []
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