Module: Mutineer::RailsWorkerDb

Defined in:
lib/mutineer/rails_worker_db.rb

Overview

#26/#27 Phase 2b (U5) — per-worker database isolation for the daemon path.

Loaded APP-SIDE by DaemonServer (a sibling gem file, pulled in by absolute path so it bypasses the app bundle — the same trick DaemonClient uses to run daemon_server.rb under a bundle that has no mutineer). It uses the app's OWN already-booted ActiveRecord and NEVER require "active_record" (R10/KTD-8): every method that touches AR first confirms RailsWorkerDb.available?, so the daemon core stays framework-agnostic and the gem keeps its zero-runtime-dependency promise.

Isolation model (KTD-7): each parallel worker gets its OWN database so concurrent forks can't clobber each other's transactional fixtures (the measured #26 corruption). RailsWorkerDb.after_fork runs inside a freshly-forked child and points that child's connection at the worker's database BEFORE any test loads; transactional fixtures then repopulate that isolated database per test.

Scope: this pass ships the SQLite adapter (per-worker file, hermetic, spike-proven). Postgres per-worker provisioning (CREATE DATABASE <db>-<worker> — the measured corruption case) extends RailsWorkerDb.worker_db_config/RailsWorkerDb.provision at the marked seam in U10; until then a non-SQLite config raises a clear NotImplementedError rather than silently mis-routing.

Honest limit (KTD-5): routing failures surface as error via RailsWorkerDb.verify_connection!. Re-raising an AR error that fires inside a test body past Minitest (so an in-test DB failure is error, not killed) is only observable under concurrent load and is deferred to U6 with the parallel gate — noted, not silently skipped.

Class Method Summary collapse

Class Method Details

.after_fork(worker, schema_path = nil) ⇒ void

This method returns an undefined value.

Child-side (after fork): route this process's ActiveRecord at the worker's own database and confirm it is reachable, so a routing failure reads as error (via the daemon's child rescue) rather than a false verdict. Loads the schema into the worker database when a schema path is given (idempotent — schema.rb runs with force: true), covering a fresh worker file.

Parameters:

  • worker (Integer)

    the worker slot index.

  • schema_path (String, nil) (defaults to: nil)

    absolute path to db/schema.rb, or nil to skip.



107
108
109
110
111
112
113
# File 'lib/mutineer/rails_worker_db.rb', line 107

def self.after_fork(worker, schema_path = nil)
  return unless available?

  ActiveRecord::Base.establish_connection(worker_db_config(worker))
  load_schema(schema_path) if schema_path
  verify_connection!
end

.available?Boolean

True when the app has ActiveRecord loaded — the only condition under which any other method here may touch AR. Never triggers an autoload/require of AR itself.

Returns:

  • (Boolean)


34
35
36
# File 'lib/mutineer/rails_worker_db.rb', line 34

def self.available?
  defined?(ActiveRecord::Base) ? true : false
end

.load_schema(schema_path) ⇒ void

This method returns an undefined value.

Load a Rails schema.rb into the current connection with its output silenced, so a parent-side provision call can never spill schema chatter onto the daemon IPC pipe. (In a fork the child's stdout is already File::NULL; this guards the parent path too.)

Parameters:

  • schema_path (String)

    absolute path to db/schema.rb.



143
144
145
146
147
148
149
150
151
# File 'lib/mutineer/rails_worker_db.rb', line 143

def self.load_schema(schema_path)
  ActiveRecord::Migration.verbose = false if defined?(ActiveRecord::Migration)
  original = $stdout
  $stdout = File.open(File::NULL, "w")
  load schema_path
ensure
  $stdout.close unless $stdout.equal?(original)
  $stdout = original
end

.per_worker_config(config_hash, worker) ⇒ Hash

Pure config-shaping (no AR): given a connection config hash, return the per-worker variant with its database swapped to the worker's own name. Adapter-general — SQLite (storage/test.sqlite3storage/test-<w>.sqlite3) and Postgres (myapp_testmyapp_test-<w>, Rails parallelize naming) both fall out of worker_database_path. Extracted + unit-tested so the Postgres SHAPE is proven ready for U10 without a live database.

Parameters:

  • config_hash (Hash)

    a connection config hash (symbol or string keys).

  • worker (Integer)

    the worker slot index.

Returns:

  • (Hash)

    the per-worker config (symbol keys), database swapped.

Raises:

  • (NotImplementedError)

    for an in-memory or empty database (no per-worker split).



87
88
89
90
91
92
93
94
95
96
# File 'lib/mutineer/rails_worker_db.rb', line 87

def self.per_worker_config(config_hash, worker)
  hash     = config_hash.transform_keys(&:to_sym)
  database = hash[:database].to_s
  if database.empty? || database == ":memory:"
    raise NotImplementedError,
          "worker-DB isolation needs a file/name-backed database (got #{database.inspect})."
  end

  hash.merge(database: worker_database_path(database, worker))
end

.provision(worker_count, schema_path) ⇒ void

This method returns an undefined value.

Explicit, parent-side provisioning: create + schema-load every worker database up front, then restore the app's default connection. This is the EXPLICIT provisioning entry point (V2 — never silent auto-create mid-audit) and the seam U10 extends for Postgres (CREATE DATABASE per worker). For SQLite the files are created on connect and the schema load makes them ready; idempotent to re-run.

Parameters:

  • worker_count (Integer)

    number of worker databases to provision.

  • schema_path (String, nil)

    absolute path to db/schema.rb, or nil to skip.



124
125
126
127
128
129
130
131
132
133
134
# File 'lib/mutineer/rails_worker_db.rb', line 124

def self.provision(worker_count, schema_path)
  return unless available?

  original = ActiveRecord::Base.connection_db_config
  (0...worker_count).each do |worker|
    ActiveRecord::Base.establish_connection(worker_db_config(worker))
    load_schema(schema_path) if schema_path
  end
ensure
  ActiveRecord::Base.establish_connection(original) if original
end

.verify_connection!void

This method returns an undefined value.

Force a round-trip to the freshly-routed connection so a broken route fails HERE (→ error) instead of later masquerading as a test failure (→ false killed).



157
158
159
# File 'lib/mutineer/rails_worker_db.rb', line 157

def self.verify_connection!
  ActiveRecord::Base.connection.execute("SELECT 1")
end

.worker_database_path(database, worker) ⇒ String

Derive a per-worker database path from a base path by inserting -<worker> before the extension. Pure string transform (no AR) so it is unit-testable in the zero-dep suite. storage/test.sqlite3, worker 1 -> storage/test-1.sqlite3.

Parameters:

  • database (String)

    the base database path.

  • worker (Integer)

    the worker slot index (0..N-1).

Returns:

  • (String)

    the per-worker database path.



45
46
47
48
# File 'lib/mutineer/rails_worker_db.rb', line 45

def self.worker_database_path(database, worker)
  ext = File.extname(database)
  "#{database.delete_suffix(ext)}-#{worker}#{ext}"
end

.worker_db_config(worker) ⇒ Hash

Build the AR connection config for one worker by copying the app's current (default test) config and swapping in the per-worker database path. SQLite only this pass — a non-SQLite adapter raises so the SQLite-first scope fails loud instead of mis-routing (Postgres is U10).

Parameters:

  • worker (Integer)

    the worker slot index.

Returns:

  • (Hash)

    a symbol-keyed AR configuration hash for the worker database.

Raises:

  • (NotImplementedError)

    when the app's database is non-SQLite or in-memory.



58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/mutineer/rails_worker_db.rb', line 58

def self.worker_db_config(worker)
  hash    = ActiveRecord::Base.connection_db_config.configuration_hash
  adapter = hash[:adapter].to_s
  # U10 seam: the config-SHAPING below (per_worker_config) is already
  # adapter-general — it derives correct SQLite *and* Postgres worker-DB names.
  # What's gated is runtime PROVISIONING: SQLite files are created on connect,
  # but Postgres needs an explicit `CREATE DATABASE` per worker (U10). Until that
  # lands, refuse non-SQLite loudly rather than route to a database that doesn't
  # exist. To finish U10: implement provision() for PG and drop this guard.
  unless adapter.start_with?("sqlite")
    raise NotImplementedError,
          "worker-DB isolation currently provisions SQLite only (got adapter #{adapter.inspect}); " \
          "Postgres per-worker provisioning is U10 (#26/#35) — use a SQLite test DB, or drop --jobs."
  end

  per_worker_config(hash, worker)
end