Module: Raif::EvalsDatabase

Defined in:
lib/raif/evals_database.rb

Overview

Points raif evals at its own database, so a run neither collides with the host's test suite on the test database nor leaves a spec waiting on an eval's uncommitted rows. Evals still boot in the test environment, because that is where host apps load FactoryBot, WebMock and friends; only the database name changes, and only when Raif.config.evals_database_suffix is set.

Defined Under Namespace

Classes: InUseError

Constant Summary collapse

IN_MEMORY_DATABASES =

Nothing to name: an in-memory SQLite database is already private to the process.

[":memory:"].freeze

Class Method Summary collapse

Class Method Details

.configure! ⇒ Object

Renames every database the test environment connects to, replicas included, so a host with more than one database cannot leave some of its models writing to the test database. Runs in after_initialize, once the host's Raif initializer has set the suffix. Pools established before then - eager loading runs connects_to during boot - share these config objects, so renaming in place and dropping any open connections moves them too.

Prepared in the same hook as the rename, not later: the rest of boot can already query the renamed database - routes load after after_initialize and can load models whose class bodies read the schema - and a missing or empty database fails the boot there.



33
34
35
36
37
38
39
# File 'lib/raif/evals_database.rb', line 33

def configure!
  return unless enabled?

  ActiveSupport.on_load(:active_record) do
    Raif::EvalsDatabase.switch_to(Raif.config.evals_database_suffix, replacing: "_test", lock: true)
  end
end

.database_names ⇒ Object



115
116
117
# File 'lib/raif/evals_database.rb', line 115

def database_names
  managed_configs(ActiveRecord::Base.configurations, env_name: Rails.env).map(&:database).uniq
end

.enabled? ⇒ Boolean

Returns:

  • (Boolean)


16
17
18
# File 'lib/raif/evals_database.rb', line 16

def enabled?
  Raif.running_evals? && Rails.env.test? && Raif.config.evals_database_suffix.present?
end

.in_memory?(database) ⇒ Boolean

Returns:

  • (Boolean)


20
21
22
# File 'lib/raif/evals_database.rb', line 20

def in_memory?(database)
  IN_MEMORY_DATABASES.include?(database) || database.include?("mode=memory")
end

.lock! ⇒ Object

Held for the life of the run, so a second run cannot truncate this one's databases under it. Preparing a database truncates it, and so does each worker preparing its own. The workers inherit the lock when they fork, and the worker databases are named after the locked one, so this one lock covers them too.



96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
# File 'lib/raif/evals_database.rb', line 96

def lock!
  database = database_names.first
  return if database.blank? || in_memory?(database)

  path = Rails.root.join("tmp", "raif_evals-#{database.gsub(/[^\w.-]+/, "_")}.lock")
  FileUtils.mkdir_p(path.dirname)
  file = File.open(path, File::RDWR | File::CREAT)

  unless file.flock(File::LOCK_EX | File::LOCK_NB)
    file.close
    raise InUseError, "Another raif evals run is using the #{database} database. Starting this run would " \
      "truncate its tables under it. Wait for that run to finish, or give this run a database of its own " \
      "with a different Raif.config.evals_database_suffix."
  end

  # Kept open: closing the file, or letting it be garbage collected, releases the lock.
  @lock_file = file
end

.name_for(database, suffix:, replacing: "_test") ⇒ Object

app_test becomes app_raif_evals. A name without a _test suffix keeps its name and gains the suffix, and a SQLite file keeps its extension: db/test.sqlite3 becomes db/test_raif_evals.sqlite3. A worker database passes replacing: nil, since its name only gains the worker number. A SQLite URI keeps its query: file:db/test.sqlite3?mode=rwc becomes file:db/test_raif_evals.sqlite3?mode=rwc.



66
67
68
69
70
71
72
73
74
75
76
# File 'lib/raif/evals_database.rb', line 66

def name_for(database, suffix:, replacing: "_test")
  return database if in_memory?(database)

  path, separator, query = database.start_with?("file:") ? database.partition("?") : [database, "", ""]
  extension = File.extname(path)
  extension = "" unless extension.match?(/\A\.sqlite3?\z/)
  base = path.delete_suffix(extension)
  base = base.delete_suffix(replacing) if replacing

  "#{base}#{suffix}#{extension}#{separator}#{query}"
end

.prepare! ⇒ Object

Creates each evals database that does not exist and loads the schema into any whose schema is out of date, the same way Rails prepares its parallel test databases. An up-to-date database is truncated instead, so every run starts from empty tables.



81
82
83
84
85
86
87
88
89
90
# File 'lib/raif/evals_database.rb', line 81

def prepare!
  verbose_was, ENV["VERBOSE"] = ENV["VERBOSE"], "false"

  managed_configs(ActiveRecord::Base.configurations, env_name: Rails.env).uniq(&:database).each do |db_config|
    reconstruct_from_schema(db_config)
  end
ensure
  ActiveRecord::Base.establish_connection
  ENV["VERBOSE"] = verbose_was
end

.rename(configurations, env_name:, suffix:, replacing: "_test") ⇒ Object



56
57
58
59
60
# File 'lib/raif/evals_database.rb', line 56

def rename(configurations, env_name:, suffix:, replacing: "_test")
  managed_configs(configurations, env_name: env_name).each do |db_config|
    db_config._database = name_for(db_config.database, suffix: suffix, replacing: replacing)
  end
end

.switch_to(suffix, replacing:, lock: false) ⇒ Object



49
50
51
52
53
54
# File 'lib/raif/evals_database.rb', line 49

def switch_to(suffix, replacing:, lock: false)
  rename(ActiveRecord::Base.configurations, env_name: Rails.env, suffix: suffix, replacing: replacing)
  lock! if lock
  ActiveRecord::Base.connection_handler.clear_all_connections!(:all)
  prepare!
end

.use_worker_database!(worker_number) ⇒ Object

Moves a forked eval worker onto a database of its own - app_raif_evals becomes app_raif_evals_2 - so its uncommitted rows cannot hold up the other workers. Two workers sharing one database block on any unique index both write the same key to, which a shared fixture or a repeated case does on every execution.



45
46
47
# File 'lib/raif/evals_database.rb', line 45

def use_worker_database!(worker_number)
  switch_to("_#{worker_number}", replacing: nil)
end