From 3c98be40f62a79c874feeb9ecaa87ee8486096e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 11:03:10 +0200 Subject: [PATCH 01/12] Replace pg-ephemeral with a devenv postgres service - Provision one persistent Postgres via devenv services.postgres instead of pg-ephemeral spinning up a throwaway server per benchmark, per test suite, and a separate initdb cluster for pg-regress. The server starts with `devenv up`. - Recreate every benchmark instance, the main test database, and en57_regress as initialDatabases seeded from db/schema and db/seeds, replacing database.toml. - Connect over the service's unix socket via plain postgres:///name URLs, letting the devenv-exported PGHOST drive the connection. - Since databases now persist between runs, the benchmark runner resets each instance to its baseline before running: truncate for en57/RES scenarios, truncate-and-reseed for the seeded instance. - Point pg-regress at the shared server, dropping its temporary cluster bootstrap. --- Gemfile | 1 - Gemfile.lock | 12 - bin/pg-ephemeral | 16 -- database.toml | 53 ---- devenv.nix | 56 +++-- .../concurrent_append_non_conflicting_tags.rb | 8 + lib/benchmark/res_append_stream_any.rb | 1 + ...s_concurrent_append_conflicting_streams.rb | 1 + ...ncurrent_append_non_conflicting_streams.rb | 1 + lib/en57/benchmark.rb | 87 ++++--- test/test_benchmark.rb | 230 ++++++++++++++---- test/test_factories.rb | 8 +- test/test_helper.rb | 12 +- test/test_migrator.rb | 7 +- 14 files changed, 295 insertions(+), 198 deletions(-) delete mode 100755 bin/pg-ephemeral delete mode 100644 database.toml diff --git a/Gemfile b/Gemfile index c8be050..bb87b8f 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,6 @@ gem "minitest-mock" gem "minitest-stub-const" gem "mutant-minitest" gem "concurrent-ruby" -gem "pg-ephemeral" gem "syntax_tree" gem "activerecord" diff --git a/Gemfile.lock b/Gemfile.lock index 116d46d..ca1cf92 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -160,16 +160,9 @@ GEM parser (3.3.11.1) ast (~> 2.4.1) racc - pg (1.6.3) pg (1.6.3-aarch64-linux) pg (1.6.3-arm64-darwin) pg (1.6.3-x86_64-linux) - pg-ephemeral (0.5.1-aarch64-linux) - pg (~> 1.5) - pg-ephemeral (0.5.1-arm64-darwin) - pg (~> 1.5) - pg-ephemeral (0.5.1-x86_64-linux) - pg (~> 1.5) pp (0.6.3) prettyprint prettier_print (1.2.1) @@ -286,7 +279,6 @@ DEPENDENCIES minitest-mock minitest-stub-const mutant-minitest - pg-ephemeral rails rails_event_store rake @@ -348,13 +340,9 @@ CHECKSUMS nokogiri (1.19.3-arm64-darwin) sha256=71b9bd424b1b7abc18b05052a1a3cfd3627abdca62be280854cc411791357e42 nokogiri (1.19.3-x86_64-linux-gnu) sha256=2f5078620fe12e83669b5b17311b32532a8153d02eee7ad06948b926d6080976 parser (3.3.11.1) sha256=d17ace7aabe3e72c3cc94043714be27cc6f852f104d81aa284c2281aecc65d54 - pg (1.6.3) sha256=1388d0563e13d2758c1089e35e973a3249e955c659592d10e5b77c468f628a99 pg (1.6.3-aarch64-linux) sha256=0698ad563e02383c27510b76bf7d4cd2de19cd1d16a5013f375dd473e4be72ea pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d - pg-ephemeral (0.5.1-aarch64-linux) sha256=109ede089c1a5ef2d5ef80e8a1a8d7e4741a827948a1e7c3ae44c37f27fe6dbe - pg-ephemeral (0.5.1-arm64-darwin) sha256=b254bacea36f1fd9b9690f95d141c16716684beccae7ffd0a23df4477b4cf512 - pg-ephemeral (0.5.1-x86_64-linux) sha256=4c7a867df7e88abd5d8a5e816b0f633043c181017ccc6cde90b4be2087361129 pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 prettier_print (1.2.1) sha256=a72838b5f23facff21f90a5423cdcdda19e4271092b41f4ea7f50b83929e6ff9 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 diff --git a/bin/pg-ephemeral b/bin/pg-ephemeral deleted file mode 100755 index 2242dfc..0000000 --- a/bin/pg-ephemeral +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'pg-ephemeral' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) - -require "rubygems" -require "bundler/setup" - -load Gem.bin_path("pg-ephemeral", "pg-ephemeral") diff --git a/database.toml b/database.toml deleted file mode 100644 index 4409cd8..0000000 --- a/database.toml +++ /dev/null @@ -1,53 +0,0 @@ -image = "18" - -[instances.main.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.concurrent-append-non-conflicting-tags.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.concurrent-append-non-conflicting-tags-seeded.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.concurrent-append-non-conflicting-tags-seeded.seeds.events] -type = "sql-file" -path = "db/seeds/concurrent_append_non_conflicting_tags_seeded.sql" - -[instances.append-no-fail-if.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.append-no-fail-if-ar.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.append-non-conflicting-tags.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.concurrent-append-no-fail-if.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.concurrent-append-no-fail-if-ar.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.concurrent-append-conflicting-tags.seeds.schema] -type = "sql-file" -path = "db/schema/0.1.0.sql" - -[instances.res-append-stream-any.seeds.schema] -type = "sql-file" -path = "db/seeds/res.sql" - -[instances.res-concurrent-append-non-conflicting-streams.seeds.schema] -type = "sql-file" -path = "db/seeds/res.sql" - -[instances.res-concurrent-append-conflicting-streams.seeds.schema] -type = "sql-file" -path = "db/seeds/res.sql" diff --git a/devenv.nix b/devenv.nix index 7897c08..5e399db 100644 --- a/devenv.nix +++ b/devenv.nix @@ -33,6 +33,37 @@ export MUTANT_SINCE="''${MUTANT_SINCE:-HEAD}" ''; + services.postgres = { + enable = true; + package = pkgs.postgresql_18; + initialDatabases = + let + en57 = name: { + inherit name; + schema = ./db/schema/0.1.0.sql; + }; + res = name: { + inherit name; + schema = ./db/seeds/res.sql; + }; + in + [ + (en57 "main") + (en57 "append-no-fail-if") + (en57 "append-no-fail-if-ar") + (en57 "append-non-conflicting-tags") + (en57 "concurrent-append-no-fail-if") + (en57 "concurrent-append-no-fail-if-ar") + (en57 "concurrent-append-conflicting-tags") + (en57 "concurrent-append-non-conflicting-tags") + (en57 "concurrent-append-non-conflicting-tags-seeded") + (res "res-append-stream-any") + (res "res-concurrent-append-non-conflicting-streams") + (res "res-concurrent-append-conflicting-streams") + (en57 "en57_regress") + ]; + }; + files = let commitSkill = '' @@ -159,23 +190,12 @@ pg_regress=${pkgs.postgresql_18.dev}/lib/pgxs/src/test/regress/pg_regress bindir=${pkgs.postgresql_18}/bin - workdir=$(mktemp -d) - datadir=$workdir/data - socketdir=$workdir/socket dbname=en57_regress - mkdir -p "$socketdir" - - cleanup() { - "$bindir/pg_ctl" -D "$datadir" -m immediate stop >/dev/null 2>&1 || true - rm -rf "$workdir" - } - trap cleanup EXIT - - "$bindir/initdb" -D "$datadir" -U postgres --auth=trust >/dev/null - "$bindir/pg_ctl" -D "$datadir" -w \ - -o "-k $socketdir -c listen_addresses='''" start >/dev/null - "$bindir/createdb" -h "$socketdir" -U postgres "$dbname" - "$bindir/psql" -h "$socketdir" -U postgres -d "$dbname" \ + user=''${PGUSER:-$(id -un)} + + "$bindir/dropdb" -h "$PGHOST" -U "$user" --if-exists "$dbname" + "$bindir/createdb" -h "$PGHOST" -U "$user" "$dbname" + "$bindir/psql" -h "$PGHOST" -U "$user" -d "$dbname" \ -v ON_ERROR_STOP=1 -q -f db/schema/0.1.0.sql rm -rf test/pg_regress/results @@ -183,8 +203,8 @@ "$pg_regress" \ --use-existing \ - --host="$socketdir" \ - --user=postgres \ + --host="$PGHOST" \ + --user="$user" \ --dbname="$dbname" \ --inputdir=test/pg_regress \ --outputdir=test/pg_regress/results \ diff --git a/lib/benchmark/concurrent_append_non_conflicting_tags.rb b/lib/benchmark/concurrent_append_non_conflicting_tags.rb index 2c2d687..7ee7a49 100644 --- a/lib/benchmark/concurrent_append_non_conflicting_tags.rb +++ b/lib/benchmark/concurrent_append_non_conflicting_tags.rb @@ -38,6 +38,14 @@ def call(measure, retries, _run_id) concurrent_append_non_conflicting_tags.with( database_instance: "concurrent-append-non-conflicting-tags-seeded", name: "10x100 concurrent append, non-conflicting tags (seeded)", + reset: + Scenario::RESET_EN57 + "; " + + File.read( + File.expand_path( + "../../db/seeds/concurrent_append_non_conflicting_tags_seeded.sql", + __dir__, + ), + ), ) end end diff --git a/lib/benchmark/res_append_stream_any.rb b/lib/benchmark/res_append_stream_any.rb index ccb95fd..33f36b1 100644 --- a/lib/benchmark/res_append_stream_any.rb +++ b/lib/benchmark/res_append_stream_any.rb @@ -8,6 +8,7 @@ module Benchmark runs: ->(runs) { runs * 10 }, concurrency: 1, batch_size: 100, + reset: Scenario::RESET_RES, ) do def setup(database_url) require "active_record" diff --git a/lib/benchmark/res_concurrent_append_conflicting_streams.rb b/lib/benchmark/res_concurrent_append_conflicting_streams.rb index 94b80ce..f59e122 100644 --- a/lib/benchmark/res_concurrent_append_conflicting_streams.rb +++ b/lib/benchmark/res_concurrent_append_conflicting_streams.rb @@ -7,6 +7,7 @@ module Benchmark name: "10x100 concurrent append, conflicting streams (RES)", concurrency: 10, batch_size: 100, + reset: Scenario::RESET_RES, ) do def setup(database_url) require "active_record" diff --git a/lib/benchmark/res_concurrent_append_non_conflicting_streams.rb b/lib/benchmark/res_concurrent_append_non_conflicting_streams.rb index 8899158..5f9167c 100644 --- a/lib/benchmark/res_concurrent_append_non_conflicting_streams.rb +++ b/lib/benchmark/res_concurrent_append_non_conflicting_streams.rb @@ -7,6 +7,7 @@ module Benchmark name: "10x100 concurrent append, non-conflicting streams (RES)", concurrency: 10, batch_size: 100, + reset: Scenario::RESET_RES, ) do def setup(database_url) require "active_record" diff --git a/lib/en57/benchmark.rb b/lib/en57/benchmark.rb index fca99f5..563b301 100644 --- a/lib/en57/benchmark.rb +++ b/lib/en57/benchmark.rb @@ -3,7 +3,7 @@ require "benchmark" require "concurrent-ruby" require "connection_pool" -require "pg_ephemeral" +require "pg" require "securerandom" require_relative "../en57" @@ -22,6 +22,8 @@ module Benchmark :retry_count, ) + Runnable = Data.define(:build, :reset) + class Table def format(results) return "" if results.empty? @@ -121,7 +123,19 @@ def initialize(mean:, stddev:, min:, max:, median:) class Scenario Configuration = - Data.define(:database_instance, :name, :concurrency, :batch_size, :runs) + Data.define( + :database_instance, + :name, + :concurrency, + :batch_size, + :runs, + :reset, + ) + + RESET_EN57 = "TRUNCATE en57.tags, en57.events RESTART IDENTITY CASCADE" + RESET_RES = + "TRUNCATE event_store_events, event_store_events_in_streams " \ + "RESTART IDENTITY CASCADE" @definitions = [] @@ -133,6 +147,7 @@ def self.define( concurrency: 1, batch_size: 100, runs: ->(runs) { runs }, + reset: RESET_EN57, &block ) register( @@ -142,6 +157,7 @@ def self.define( concurrency:, batch_size:, runs:, + reset:, ), &block ) @@ -158,6 +174,8 @@ def self.register(configuration, &block) configuration.database_instance end + define_singleton_method(:reset) { configuration.reset } + define_singleton_method( :build, ) do |database_url:, warmup_runs:, runs:| @@ -243,9 +261,12 @@ def self.scenarios(runs:) .to_h do |scenario_class| [ scenario_class.database_instance, - ->(database_url, warmup_runs) do - scenario_class.build(database_url:, warmup_runs:, runs:) - end, + Runnable.new( + reset: scenario_class.reset, + build: ->(database_url, warmup_runs) do + scenario_class.build(database_url:, warmup_runs:, runs:) + end, + ), ] end end @@ -260,35 +281,45 @@ def run En57.configuration.append_retries = 100 results = - @scenarios.map do |instance_name, mk_scenario| - PgEphemeral.with_server(instance_name:) do |server| - samples = Concurrent::Array.new - retries = Concurrent::AtomicFixnum.new - - scenario = mk_scenario.call(server.url, 2) - scenario.run( - ->(&block) { samples << ::Benchmark.realtime { block.call } }, - -> { retries.increment }, - ) - measurement = Measurement.from(samples) - - Result.new( - name: scenario.name, - runs: scenario.runs, - mean: measurement.mean, - stddev: measurement.stddev, - min: measurement.min, - max: measurement.max, - median: measurement.median, - retry_count: retries.value, - ) - end + @scenarios.map do |instance_name, runnable| + database_url = "postgres:///#{instance_name}" + reset(database_url, runnable.reset) + + samples = Concurrent::Array.new + retries = Concurrent::AtomicFixnum.new + + scenario = runnable.build.call(database_url, 2) + scenario.run( + ->(&block) { samples << ::Benchmark.realtime { block.call } }, + -> { retries.increment }, + ) + measurement = Measurement.from(samples) + + Result.new( + name: scenario.name, + runs: scenario.runs, + mean: measurement.mean, + stddev: measurement.stddev, + min: measurement.min, + max: measurement.max, + median: measurement.median, + retry_count: retries.value, + ) end @formatter.format(results) ensure En57.configuration.append_retries = original_append_retries end + + private + + def reset(database_url, sql) + connection = PG.connect(database_url) + connection.exec(sql) + ensure + connection&.close + end end class CLI diff --git a/test/test_benchmark.rb b/test/test_benchmark.rb index 2521402..93e81c9 100644 --- a/test/test_benchmark.rb +++ b/test/test_benchmark.rb @@ -86,27 +86,26 @@ def test_runner_formats_results "formatted" end - server = Data.define(:url).new("postgres://example") mk_scenario = ->(name) do - ->(_database_url, _warmup_runs) do - Data - .define(:name, :runs, :retry_count) do - def run(measure, retries) - 3.times do - retries.call - measure.call { nil } + Runnable.new( + reset: "", + build: ->(_database_url, _warmup_runs) do + Data + .define(:name, :runs, :retry_count) do + def run(measure, retries) + 3.times do + retries.call + measure.call { nil } + end end end - end - .new(name, 1, 3) - end + .new(name, 1, 3) + end, + ) end output = - PgEphemeral.stub( - :with_server, - ->(instance_name:, &block) { block.call(server) }, - ) do + PG.stub(:connect, fake_pg_connection.method(:connect)) do Runner.new( formatter:, scenarios: { @@ -125,7 +124,6 @@ def run(measure, retries) def test_runner_sets_append_retries_during_benchmark formatter = Object.new formatter.define_singleton_method(:format) { |_results| "formatted" } - server = Data.define(:url).new("postgres://example") append_retries = nil original_append_retries = En57.configuration.append_retries En57.configuration.append_retries = 7 @@ -145,14 +143,15 @@ def run(measure, _retries) end .new(->(value) { append_retries = value }) - PgEphemeral.stub( - :with_server, - ->(instance_name:, &block) { block.call(server) }, - ) do + PG.stub(:connect, fake_pg_connection.method(:connect)) do Runner.new( formatter:, scenarios: { - "instance" => ->(_database_url, _warmup_runs) { scenario }, + "instance" => + Runnable.new( + reset: "", + build: ->(_database_url, _warmup_runs) { scenario }, + ), }, ).run end @@ -169,7 +168,6 @@ def test_runner_uses_concurrent_array_for_samples formatter.define_singleton_method(:format) do |results| formatted_results = results end - server = Data.define(:url).new("postgres://example") samples = Class .new(Array) do @@ -189,16 +187,17 @@ def run(measure, _retries) end .new - PgEphemeral.stub( - :with_server, - ->(instance_name:, &block) { block.call(server) }, - ) do + PG.stub(:connect, fake_pg_connection.method(:connect)) do Concurrent::Array.stub(:new, samples) do ::Benchmark.stub(:realtime, ->(&block) { block.call || 0.1 }) do Runner.new( formatter:, scenarios: { - "instance" => ->(_database_url, _warmup_runs) { scenario }, + "instance" => + Runnable.new( + reset: "", + build: ->(_database_url, _warmup_runs) { scenario }, + ), }, ).run end @@ -208,15 +207,13 @@ def run(measure, _retries) assert_in_delta(1.1, formatted_results.fetch(0).mean) end - def test_runner_uses_scenario_instance_names_and_database_urls + def test_runner_resets_database_and_uses_instance_database_urls formatter = Object.new formatter.define_singleton_method(:format) { |_results| "formatted" } - server = Data.define(:url).new("postgres://example") - instance_names = [] database_urls = [] warmup_runs = [] measured_blocks = 0 - mk_scenario = ->(database_url, warmup_run_count) do + build = ->(database_url, warmup_run_count) do database_urls << database_url warmup_runs << warmup_run_count Class @@ -238,20 +235,46 @@ def run(measure, _retries) end end - PgEphemeral.stub( - :with_server, - ->(instance_name:, &block) do - instance_names << instance_name - block.call(server) - end, - ) do - Runner.new(formatter:, scenarios: { "instance" => mk_scenario }).run + connection = fake_pg_connection + PG.stub(:connect, connection.method(:connect)) do + Runner.new( + formatter:, + scenarios: { + "instance" => Runnable.new(reset: "RESET SQL", build:), + }, + ).run end - assert_equal(["instance"], instance_names) - assert_equal(["postgres://example"], database_urls) + assert_equal(["postgres:///instance"], database_urls) assert_equal([2], warmup_runs) assert_equal(3, measured_blocks) + assert_equal(["postgres:///instance"], connection.urls) + assert_equal(["RESET SQL"], connection.statements) + assert_equal(1, connection.closed) + end + + def test_runner_propagates_reset_connection_errors_without_masking + formatter = Object.new + formatter.define_singleton_method(:format) { |_results| "formatted" } + boom = Class.new(StandardError) + + error = + assert_raises(boom) do + PG.stub(:connect, ->(_url) { raise boom }) do + Runner.new( + formatter:, + scenarios: { + "instance" => + Runnable.new( + reset: "", + build: ->(_database_url, _warmup_runs) { nil }, + ), + }, + ).run + end + end + + assert_instance_of(boom, error) end def test_scenario_uses_noop_measure_for_warmup @@ -286,13 +309,9 @@ def call(measure, _retries, _run_id) end end scenario = nil - server = Data.define(:url).new("postgres://example") durations = [0.1, 0.2] - PgEphemeral.stub( - :with_server, - ->(instance_name:, &block) { block.call(server) }, - ) do + PG.stub(:connect, fake_pg_connection.method(:connect)) do ::Benchmark.stub( :realtime, ->(&block) do @@ -303,9 +322,13 @@ def call(measure, _retries, _run_id) Runner.new( formatter:, scenarios: { - "warmup" => ->(_database_url, warmup_runs) do - scenario = scenario_class.new(warmup_runs:) - end, + "warmup" => + Runnable.new( + reset: "", + build: ->(_database_url, warmup_runs) do + scenario = scenario_class.new(warmup_runs:) + end, + ), }, ).run end @@ -636,6 +659,7 @@ def test_runner_discovers_scenarios_by_database_instance first_scenario = Class.new do def self.database_instance = "a-discovered" + def self.reset = "reset-a" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -644,6 +668,7 @@ def self.build(database_url:, warmup_runs:, runs:) second_scenario = Class.new do def self.database_instance = "b-discovered" + def self.reset = "reset-b" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -652,12 +677,11 @@ def self.build(database_url:, warmup_runs:, runs:) Scenario.stub(:definitions, [second_scenario, first_scenario]) do assert_equal(%w[a-discovered b-discovered], Runner.names) + runnable = Runner.scenarios(runs: 3).fetch("a-discovered") + assert_equal("reset-a", runnable.reset) assert_equal( ["postgres://example", 2, 3], - Runner - .scenarios(runs: 3) - .fetch("a-discovered") - .call("postgres://example", 2), + runnable.build.call("postgres://example", 2), ) end end @@ -666,11 +690,13 @@ def test_classic_runner_selects_named_scenarios first_scenario = Class.new do def self.database_instance = "first" + def self.reset = "" def self.build(...) = nil end second_scenario = Class.new do def self.database_instance = "second" + def self.reset = "" def self.build(...) = nil end @@ -688,6 +714,7 @@ def test_classic_runner_defaults_to_fifty_runs scenario = Class.new do def self.database_instance = "scenario" + def self.reset = "" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -700,6 +727,7 @@ def self.build(database_url:, warmup_runs:, runs:) .classic .instance_variable_get(:@scenarios) .fetch("scenario") + .build .call("postgres://example", 2) assert_equal(["postgres://example", 2, 50], scenario) @@ -804,6 +832,102 @@ def test_measurement_calculates_median_for_larger_even_sample_counts assert_in_delta(0.35, measurement.median) end + + def test_reset_en57_truncates_event_and_tag_tables + assert_equal( + "TRUNCATE en57.tags, en57.events RESTART IDENTITY CASCADE", + Scenario::RESET_EN57, + ) + end + + def test_reset_res_truncates_event_store_tables + assert_equal( + "TRUNCATE event_store_events, event_store_events_in_streams " \ + "RESTART IDENTITY CASCADE", + Scenario::RESET_RES, + ) + end + + def test_scenario_define_defaults_reset_to_en57_truncate + original_definitions = Scenario.definitions.dup + scenario_class = + Scenario.define(database_instance: "reset-default", name: "Reset") + + assert_equal(Scenario::RESET_EN57, scenario_class.reset) + ensure + Scenario.definitions.replace(original_definitions) + end + + def test_scenario_define_accepts_custom_reset + original_definitions = Scenario.definitions.dup + scenario_class = + Scenario.define( + database_instance: "reset-custom", + name: "Reset", + reset: "TRUNCATE custom", + ) + + assert_equal("TRUNCATE custom", scenario_class.reset) + ensure + Scenario.definitions.replace(original_definitions) + end + + def test_seeded_scenario_reset_reloads_seed_after_truncate + seeded = + Scenario.definitions.find do + it.database_instance == + "concurrent-append-non-conflicting-tags-seeded" + end + + assert(seeded.reset.start_with?(Scenario::RESET_EN57)) + assert_includes(seeded.reset, "INSERT INTO en57.events") + end + + def test_res_scenarios_reset_with_res_truncate + res_scenarios = + Scenario.definitions.select do + it.database_instance.start_with?("res-") + end + + refute_empty(res_scenarios) + res_scenarios.each do |scenario| + assert_equal(Scenario::RESET_RES, scenario.reset) + end + end + + def test_scenario_with_overrides_reset + original_definitions = Scenario.definitions.dup + scenario_class = + Scenario.define(database_instance: "reset-base", name: "Base") + copy = scenario_class.with(database_instance: "reset-copy", reset: "X") + + assert_equal(Scenario::RESET_EN57, scenario_class.reset) + assert_equal("X", copy.reset) + ensure + Scenario.definitions.replace(original_definitions) + end + + def fake_pg_connection + Class + .new do + attr_reader :urls, :statements, :closed + + def initialize + @urls = [] + @statements = [] + @closed = 0 + end + + def connect(url) + @urls << url + self + end + + def exec(sql) = @statements << sql + def close = @closed += 1 + end + .new + end end class CLI::TestInitialize < Minitest::Test diff --git a/test/test_factories.rb b/test/test_factories.rb index cb2f92c..79c21cb 100644 --- a/test/test_factories.rb +++ b/test/test_factories.rb @@ -5,15 +5,15 @@ module En57 class TestFactories < IntegrationTest def test_for_pg_round_trips_with_connection_uri - assert_round_trip EventStore.for_pg(SERVER.url) + assert_round_trip EventStore.for_pg(MAIN_URL) end def test_for_pooled_pg_round_trips_with_default_max_connections - assert_round_trip EventStore.for_pooled_pg(SERVER.url) + assert_round_trip EventStore.for_pooled_pg(MAIN_URL) end def test_for_pooled_pg_round_trips_with_custom_max_connections - assert_round_trip EventStore.for_pooled_pg(SERVER.url, max_connections: 1) + assert_round_trip EventStore.for_pooled_pg(MAIN_URL, max_connections: 1) end def test_for_active_record_round_trips_with_default_model @@ -34,7 +34,7 @@ def test_event_store_does_not_conflict_with_public_schema_tables CONNECTION.exec("CREATE TABLE public.events (id integer PRIMARY KEY)") CONNECTION.exec("CREATE TABLE public.tags (id integer PRIMARY KEY)") - assert_round_trip EventStore.for_pg(SERVER.url) + assert_round_trip EventStore.for_pg(MAIN_URL) ensure CONNECTION.exec("DROP TABLE IF EXISTS public.tags, public.events") end diff --git a/test/test_helper.rb b/test/test_helper.rb index 28c0048..1f8e11c 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -15,27 +15,26 @@ # test dependencies require "securerandom" require "concurrent-ruby" -require "pg_ephemeral" module En57 class IntegrationTest < Minitest::Test - SERVER = PgEphemeral.start + MAIN_URL = "postgres:///main" - CONNECTION = PG.connect(SERVER.url) + CONNECTION = PG.connect(MAIN_URL) POOL_SIZE = 8 - PG_POOL = ConnectionPool.new(size: POOL_SIZE) { PG.connect(SERVER.url) } + PG_POOL = ConnectionPool.new(size: POOL_SIZE) { PG.connect(MAIN_URL) } SEQUEL_DB = Sequel.connect( - SERVER.url, + MAIN_URL, preconnect: :concurrently, max_connections: POOL_SIZE, ) AR_POOL = -> do - ActiveRecord::Base.establish_connection("#{SERVER.url}&pool=#{POOL_SIZE}") + ActiveRecord::Base.establish_connection("#{MAIN_URL}?pool=#{POOL_SIZE}") ActiveRecord::Base.connection_pool end.call @@ -55,7 +54,6 @@ def setup = SEQUEL_DB.disconnect PG_POOL.shutdown(&:close) CONNECTION.close - SERVER.shutdown end end end diff --git a/test/test_migrator.rb b/test/test_migrator.rb index db74e45..59cfb64 100644 --- a/test/test_migrator.rb +++ b/test/test_migrator.rb @@ -1,7 +1,6 @@ # frozen_string_literal: true require "test_helper" -require "uri" module En57 class TestMigrator < IntegrationTest @@ -119,11 +118,7 @@ def with_database ) end - def database_url(name) - uri = URI(SERVER.url) - uri.path = "/#{name}" - uri.to_s - end + def database_url(name) = "postgres:///#{name}" def schema_path(version) File.expand_path("../db/schema/#{version}.sql", __dir__) From 4b25baf636c0b924fda70220f7f5251e0a097947 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 11:05:18 +0200 Subject: [PATCH 02/12] Shorten the pg-regress database name to regress - en57_regress was redundantly namespaced; the database already lives on the project's dedicated Postgres server. --- devenv.nix | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/devenv.nix b/devenv.nix index 5e399db..405e6ca 100644 --- a/devenv.nix +++ b/devenv.nix @@ -60,7 +60,7 @@ (res "res-append-stream-any") (res "res-concurrent-append-non-conflicting-streams") (res "res-concurrent-append-conflicting-streams") - (en57 "en57_regress") + (en57 "regress") ]; }; @@ -190,7 +190,7 @@ pg_regress=${pkgs.postgresql_18.dev}/lib/pgxs/src/test/regress/pg_regress bindir=${pkgs.postgresql_18}/bin - dbname=en57_regress + dbname=regress user=''${PGUSER:-$(id -un)} "$bindir/dropdb" -h "$PGHOST" -U "$user" --if-exists "$dbname" From 9b4a9d3d7904bff86ef679318ced932a3bc6b0aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 11:22:42 +0200 Subject: [PATCH 03/12] Start postgres service in CI via devenv test - `devenv tasks run test` only runs tasks; it never starts services, so postgres was absent in CI and any DB-touching test had no server to reach. - Switch the CI step to `devenv test`, which starts the postgres process, waits for it to be healthy, runs enterTest, then tears it down. - Add an enterTest hook delegating to the existing test task namespace so the suite runs under the started service. --- .github/workflows/main.yml | 2 +- devenv.nix | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9183cd1..d4469aa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -53,4 +53,4 @@ jobs: key: ${{ runner.os }}-gems-${{ hashFiles('Gemfile.lock') }} restore-keys: ${{ runner.os }}-gems- - name: Run the tests - run: devenv tasks run test + run: devenv test diff --git a/devenv.nix b/devenv.nix index 405e6ca..4fe42fc 100644 --- a/devenv.nix +++ b/devenv.nix @@ -33,6 +33,10 @@ export MUTANT_SINCE="''${MUTANT_SINCE:-HEAD}" ''; + enterTest = '' + devenv tasks run test + ''; + services.postgres = { enable = true; package = pkgs.postgresql_18; From 7314af0a2ec8cf53c1520eb1fe346e23f09320ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 11:29:51 +0200 Subject: [PATCH 04/12] Revert "Cache the Nix store in GitHub Actions" This reverts commit c4c06b7fc53f156abd85f5f317e8b5ac0eaa7d9b. --- .github/workflows/main.yml | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index d4469aa..b69cad9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,19 +27,6 @@ jobs: with: extra_nix_config: | accept-flake-config = true - keep-outputs = true - keep-env-derivations = true - - name: Cache Nix store - uses: nix-community/cache-nix-action@v7 - with: - primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', 'devenv.lock', 'devenv.yaml') }} - restore-prefixes-first-match: nix-${{ runner.os }}- - gc-max-store-size-linux: 1G - purge: true - purge-prefixes: nix-${{ runner.os }}- - purge-created: 0 - purge-last-accessed: P1DT12H - purge-primary-key: never - name: Set up cachix uses: cachix/cachix-action@v16 with: From c973c6442a66abe682d40d28a6c38ea2e716bb89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 11:40:57 +0200 Subject: [PATCH 05/12] Document devenv up and devenv test - Note that `devenv shell` does not start the postgres service, and show `devenv up` / `devenv up -d` to bring services up before running DB-backed code. - Document `devenv test` as the one-step start-wait-run-teardown command and update the CI line to match. - Correct the `test:pg` row: it now targets the running postgres service, not an ephemeral instance. --- README.md | 26 ++++++++++++++++++++++---- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ee194bf..ec5bb41 100644 --- a/README.md +++ b/README.md @@ -182,6 +182,15 @@ devenv shell This provides Ruby, PostgreSQL, and the formatters, and installs the gem dependencies (`bundle install`, via the `dev:setup` task) on entry. +`devenv shell` provides the toolchain but does not start the PostgreSQL +service. Bring the services up before running anything that touches the +database: + +```sh +devenv up # foreground; Ctrl-C to stop +devenv up -d # detached; stop later with `devenv processes down` +``` + Tasks are run with `devenv tasks run`: | Task | What it does | @@ -189,11 +198,20 @@ Tasks are run with `devenv tasks run`: | `test` | Run the whole `test:` namespace (unit, mutation, pg_regress) | | `test:unit` | Run the unit tests (`bin/m test`) | | `test:mutate` | Run mutation testing (`mutant`) for changes since `MUTANT_SINCE` (defaults to `HEAD`) | -| `test:pg` | Run the `pg_regress` suite against an ephemeral PostgreSQL | +| `test:pg` | Run the `pg_regress` suite against the running PostgreSQL service | | `dev:format` | Format Ruby and SQL with [treefmt](https://treefmt.com) (syntax_tree + sqlfluff) | +`devenv tasks run` does not start services, so the DB-backed tests assume +PostgreSQL is already up (see `devenv up` above). To start the services, +wait for them to be healthy, run the suite, and tear them down in one step, +use: + +```sh +devenv test +``` + `pg-regress` is also available as a standalone script in the shell. -CI runs `devenv tasks run test`. The devenv config also wires up Claude Code -hooks: edited files are formatted with treefmt, and the test suite runs at the -end of each agent loop. +CI runs `devenv test`. The devenv config also wires up Claude Code hooks: +edited files are formatted with treefmt, and the test suite runs at the end +of each agent loop. From 956d9a01d27b3f04a60d143e50059096abf41fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 11:43:48 +0200 Subject: [PATCH 06/12] Run devenv test in agent stop hooks - Both the Claude Code Stop hook and the pi onStop hook ran `devenv tasks run test`, which never starts services and so silently relied on a postgres instance being up already. - Switch both to `devenv test` so the hook owns the service lifecycle: start postgres, wait for health, run the suite, tear down. Agents no longer depend on a manual `devenv up`. - Caveat: a concurrent `devenv up` can clash with the instance `devenv test` starts. --- devenv.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/devenv.nix b/devenv.nix index 4fe42fc..5adeee9 100644 --- a/devenv.nix +++ b/devenv.nix @@ -124,7 +124,7 @@ "*" = "treefmt {file}"; }; onStop = { - "*" = "devenv tasks run test"; + "*" = "devenv test"; }; }; }; @@ -228,12 +228,12 @@ ''; }; test = { - name = "Run the test namespace on stop"; + name = "Run devenv test on stop"; hookType = "Stop"; command = '' input=$(cat) cd "''${DEVENV_ROOT:-.}" || exit 0 - devenv tasks run test 1>&2 && exit 0 + devenv test 1>&2 && exit 0 [ "$(printf '%s' "$input" | jq -r '.stop_hook_active // false')" = "true" ] && exit 0 exit 2 ''; From 673cf5423c8f74a41f921b9c3cc6c1dd4d783cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 12:18:18 +0200 Subject: [PATCH 07/12] Run stree via bin/stree in treefmt - Point the treefmt ruby formatter at the bin/stree binstub instead of a bare `stree`, so formatting always uses the Bundler-pinned syntax_tree gem rather than whatever stree happens to be on PATH. - Resolves relative to the project root, which treefmt uses as its working directory, so the dev:format task, the Claude format hook, and the .pi edit hook all pick it up. --- devenv.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devenv.nix b/devenv.nix index 5adeee9..7c382d2 100644 --- a/devenv.nix +++ b/devenv.nix @@ -170,7 +170,7 @@ }; settings.formatter = { ruby = { - command = "stree"; + command = "bin/stree"; options = [ "write" ]; includes = [ "*.rb" ]; }; From 0eaa619dd0e821647dff449c241d1e4b7c349385 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 15:02:04 +0200 Subject: [PATCH 08/12] Clone ephemeral test databases from golden templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the static initialDatabases list (13 named DBs reset with TRUNCATE) with three golden templates: golden_en57, golden_res, and golden_en57_seeded. The seeded golden bakes its 1M-row seed in at provisioning time. - Add En57::EphemeralDatabase.with(template:), which clones a golden via CREATE DATABASE ... TEMPLATE (PostgreSQL's fastest copy — file-level, not row-by-row), yields a database_url, and drops it WITH (FORCE) when the block ends. No TRUNCATE. - IntegrationTest now clones golden_en57 per test and drops it on teardown, isolating every test; pools connect lazily. Benchmarks clone per scenario instead of resetting. - The seeded benchmark clones the 1M-row golden (~1s) instead of re-INSERTing the seed on every run (~16s). - Ignore EphemeralDatabase#execute in mutant: the receiver->self mutation runs Kernel#exec on multi-word SQL and replaces the worker, so it cannot be killed. - Adopting this needs a one-time Postgres data-dir reset so devenv provisions the goldens (devenv up and devenv test use separate state dirs); the old named databases are obsolete. --- .mutant.yml | 1 + devenv.nix | 37 +++--- .../concurrent_append_non_conflicting_tags.rb | 9 +- lib/benchmark/res_append_stream_any.rb | 2 +- ...s_concurrent_append_conflicting_streams.rb | 2 +- ...ncurrent_append_non_conflicting_streams.rb | 2 +- lib/en57/benchmark.rb | 76 +++++------- lib/en57/ephemeral_database.rb | 35 ++++++ test/test_benchmark.rb | 100 ++++++++-------- test/test_ephemeral_database.rb | 111 ++++++++++++++++++ test/test_factories.rb | 19 +-- test/test_helper.rb | 62 +++++----- test/test_integration.rb | 32 ++--- test/test_migrator.rb | 16 +-- test/test_stress.rb | 4 +- 15 files changed, 318 insertions(+), 190 deletions(-) create mode 100644 lib/en57/ephemeral_database.rb create mode 100644 test/test_ephemeral_database.rb diff --git a/.mutant.yml b/.mutant.yml index ece4ea4..45868b9 100644 --- a/.mutant.yml +++ b/.mutant.yml @@ -33,3 +33,4 @@ matcher: - En57::Benchmark::ResConcurrentAppendConflictingStreams* - En57::Benchmark::ResConcurrentAppendNonConflictingStreams* - En57::Benchmark::Scenario#concurrently # thread raise mutation survives despite direct coverage + - En57::EphemeralDatabase#execute # receiver->self mutation runs Kernel#exec on multi-word SQL, replacing the worker diff --git a/devenv.nix b/devenv.nix index 7c382d2..926b5f4 100644 --- a/devenv.nix +++ b/devenv.nix @@ -42,29 +42,24 @@ package = pkgs.postgresql_18; initialDatabases = let - en57 = name: { - inherit name; - schema = ./db/schema/0.1.0.sql; - }; - res = name: { - inherit name; - schema = ./db/seeds/res.sql; - }; + seededSchema = pkgs.runCommand "golden-en57-seeded.sql" { } '' + cat ${./db/schema/0.1.0.sql} \ + ${./db/seeds/concurrent_append_non_conflicting_tags_seeded.sql} > $out + ''; in [ - (en57 "main") - (en57 "append-no-fail-if") - (en57 "append-no-fail-if-ar") - (en57 "append-non-conflicting-tags") - (en57 "concurrent-append-no-fail-if") - (en57 "concurrent-append-no-fail-if-ar") - (en57 "concurrent-append-conflicting-tags") - (en57 "concurrent-append-non-conflicting-tags") - (en57 "concurrent-append-non-conflicting-tags-seeded") - (res "res-append-stream-any") - (res "res-concurrent-append-non-conflicting-streams") - (res "res-concurrent-append-conflicting-streams") - (en57 "regress") + { + name = "golden_en57"; + schema = ./db/schema/0.1.0.sql; + } + { + name = "golden_res"; + schema = ./db/seeds/res.sql; + } + { + name = "golden_en57_seeded"; + schema = seededSchema; + } ]; }; diff --git a/lib/benchmark/concurrent_append_non_conflicting_tags.rb b/lib/benchmark/concurrent_append_non_conflicting_tags.rb index 7ee7a49..d55fe14 100644 --- a/lib/benchmark/concurrent_append_non_conflicting_tags.rb +++ b/lib/benchmark/concurrent_append_non_conflicting_tags.rb @@ -38,14 +38,7 @@ def call(measure, retries, _run_id) concurrent_append_non_conflicting_tags.with( database_instance: "concurrent-append-non-conflicting-tags-seeded", name: "10x100 concurrent append, non-conflicting tags (seeded)", - reset: - Scenario::RESET_EN57 + "; " + - File.read( - File.expand_path( - "../../db/seeds/concurrent_append_non_conflicting_tags_seeded.sql", - __dir__, - ), - ), + template: "golden_en57_seeded", ) end end diff --git a/lib/benchmark/res_append_stream_any.rb b/lib/benchmark/res_append_stream_any.rb index 33f36b1..c4cf767 100644 --- a/lib/benchmark/res_append_stream_any.rb +++ b/lib/benchmark/res_append_stream_any.rb @@ -8,7 +8,7 @@ module Benchmark runs: ->(runs) { runs * 10 }, concurrency: 1, batch_size: 100, - reset: Scenario::RESET_RES, + template: "golden_res", ) do def setup(database_url) require "active_record" diff --git a/lib/benchmark/res_concurrent_append_conflicting_streams.rb b/lib/benchmark/res_concurrent_append_conflicting_streams.rb index f59e122..0101076 100644 --- a/lib/benchmark/res_concurrent_append_conflicting_streams.rb +++ b/lib/benchmark/res_concurrent_append_conflicting_streams.rb @@ -7,7 +7,7 @@ module Benchmark name: "10x100 concurrent append, conflicting streams (RES)", concurrency: 10, batch_size: 100, - reset: Scenario::RESET_RES, + template: "golden_res", ) do def setup(database_url) require "active_record" diff --git a/lib/benchmark/res_concurrent_append_non_conflicting_streams.rb b/lib/benchmark/res_concurrent_append_non_conflicting_streams.rb index 5f9167c..e1992f7 100644 --- a/lib/benchmark/res_concurrent_append_non_conflicting_streams.rb +++ b/lib/benchmark/res_concurrent_append_non_conflicting_streams.rb @@ -7,7 +7,7 @@ module Benchmark name: "10x100 concurrent append, non-conflicting streams (RES)", concurrency: 10, batch_size: 100, - reset: Scenario::RESET_RES, + template: "golden_res", ) do def setup(database_url) require "active_record" diff --git a/lib/en57/benchmark.rb b/lib/en57/benchmark.rb index 563b301..40c4f6f 100644 --- a/lib/en57/benchmark.rb +++ b/lib/en57/benchmark.rb @@ -7,6 +7,7 @@ require "securerandom" require_relative "../en57" +require_relative "ephemeral_database" module En57 module Benchmark @@ -22,7 +23,7 @@ module Benchmark :retry_count, ) - Runnable = Data.define(:build, :reset) + Runnable = Data.define(:build, :template) class Table def format(results) @@ -129,14 +130,9 @@ class Scenario :concurrency, :batch_size, :runs, - :reset, + :template, ) - RESET_EN57 = "TRUNCATE en57.tags, en57.events RESTART IDENTITY CASCADE" - RESET_RES = - "TRUNCATE event_store_events, event_store_events_in_streams " \ - "RESTART IDENTITY CASCADE" - @definitions = [] def self.definitions = @definitions @@ -147,7 +143,7 @@ def self.define( concurrency: 1, batch_size: 100, runs: ->(runs) { runs }, - reset: RESET_EN57, + template: "golden_en57", &block ) register( @@ -157,7 +153,7 @@ def self.define( concurrency:, batch_size:, runs:, - reset:, + template:, ), &block ) @@ -174,7 +170,7 @@ def self.register(configuration, &block) configuration.database_instance end - define_singleton_method(:reset) { configuration.reset } + define_singleton_method(:template) { configuration.template } define_singleton_method( :build, @@ -262,7 +258,7 @@ def self.scenarios(runs:) [ scenario_class.database_instance, Runnable.new( - reset: scenario_class.reset, + template: scenario_class.template, build: ->(database_url, warmup_runs) do scenario_class.build(database_url:, warmup_runs:, runs:) end, @@ -281,45 +277,37 @@ def run En57.configuration.append_retries = 100 results = - @scenarios.map do |instance_name, runnable| - database_url = "postgres:///#{instance_name}" - reset(database_url, runnable.reset) - - samples = Concurrent::Array.new - retries = Concurrent::AtomicFixnum.new - - scenario = runnable.build.call(database_url, 2) - scenario.run( - ->(&block) { samples << ::Benchmark.realtime { block.call } }, - -> { retries.increment }, - ) - measurement = Measurement.from(samples) - - Result.new( - name: scenario.name, - runs: scenario.runs, - mean: measurement.mean, - stddev: measurement.stddev, - min: measurement.min, - max: measurement.max, - median: measurement.median, - retry_count: retries.value, - ) + @scenarios.map do |_instance_name, runnable| + EphemeralDatabase.with( + template: runnable.template, + ) do |database_url| + samples = Concurrent::Array.new + retries = Concurrent::AtomicFixnum.new + + scenario = runnable.build.call(database_url, 2) + scenario.run( + ->(&block) { samples << ::Benchmark.realtime { block.call } }, + -> { retries.increment }, + ) + measurement = Measurement.from(samples) + + Result.new( + name: scenario.name, + runs: scenario.runs, + mean: measurement.mean, + stddev: measurement.stddev, + min: measurement.min, + max: measurement.max, + median: measurement.median, + retry_count: retries.value, + ) + end end @formatter.format(results) ensure En57.configuration.append_retries = original_append_retries end - - private - - def reset(database_url, sql) - connection = PG.connect(database_url) - connection.exec(sql) - ensure - connection&.close - end end class CLI diff --git a/lib/en57/ephemeral_database.rb b/lib/en57/ephemeral_database.rb new file mode 100644 index 0000000..062276d --- /dev/null +++ b/lib/en57/ephemeral_database.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require "pg" +require "securerandom" + +module En57 + module EphemeralDatabase + extend self + + ADMIN_URL = "postgres:///postgres" + + def with(template: nil, prefix: "en57") + name = "#{prefix}.#{SecureRandom.hex(8)}" + admin = PG.connect(ADMIN_URL) + statement = "CREATE DATABASE #{PG::Connection.quote_ident(name)}" + statement += + " TEMPLATE #{PG::Connection.quote_ident(template)}" if template + execute(admin, statement) + yield "postgres:///#{name}" + ensure + if admin + execute( + admin, + "DROP DATABASE IF EXISTS #{PG::Connection.quote_ident(name)} " \ + "WITH (FORCE)", + ) + end + admin&.close + end + + private + + def execute(connection, statement) = connection.exec(statement) + end +end diff --git a/test/test_benchmark.rb b/test/test_benchmark.rb index 93e81c9..c06e224 100644 --- a/test/test_benchmark.rb +++ b/test/test_benchmark.rb @@ -88,7 +88,7 @@ def test_runner_formats_results mk_scenario = ->(name) do Runnable.new( - reset: "", + template: "golden_en57", build: ->(_database_url, _warmup_runs) do Data .define(:name, :runs, :retry_count) do @@ -149,7 +149,7 @@ def run(measure, _retries) scenarios: { "instance" => Runnable.new( - reset: "", + template: "golden_en57", build: ->(_database_url, _warmup_runs) { scenario }, ), }, @@ -195,7 +195,7 @@ def run(measure, _retries) scenarios: { "instance" => Runnable.new( - reset: "", + template: "golden_en57", build: ->(_database_url, _warmup_runs) { scenario }, ), }, @@ -207,7 +207,7 @@ def run(measure, _retries) assert_in_delta(1.1, formatted_results.fetch(0).mean) end - def test_runner_resets_database_and_uses_instance_database_urls + def test_runner_clones_template_and_yields_ephemeral_database_url formatter = Object.new formatter.define_singleton_method(:format) { |_results| "formatted" } database_urls = [] @@ -240,20 +240,29 @@ def run(measure, _retries) Runner.new( formatter:, scenarios: { - "instance" => Runnable.new(reset: "RESET SQL", build:), + "instance" => Runnable.new(template: "golden_res", build:), }, ).run end - assert_equal(["postgres:///instance"], database_urls) + assert_equal(1, database_urls.size) + assert_match(%r{\Apostgres:///en57\.\h{16}\z}, database_urls.fetch(0)) assert_equal([2], warmup_runs) assert_equal(3, measured_blocks) - assert_equal(["postgres:///instance"], connection.urls) - assert_equal(["RESET SQL"], connection.statements) + assert_equal([EphemeralDatabase::ADMIN_URL], connection.urls) + assert_match( + /\ACREATE DATABASE "en57\.\h{16}" TEMPLATE "golden_res"\z/, + connection.statements.fetch(0), + ) + assert_match( + /\ADROP DATABASE IF EXISTS "en57\.\h{16}" WITH \(FORCE\)\z/, + connection.statements.fetch(1), + ) + assert_equal(2, connection.statements.size) assert_equal(1, connection.closed) end - def test_runner_propagates_reset_connection_errors_without_masking + def test_runner_propagates_clone_connection_errors_without_masking formatter = Object.new formatter.define_singleton_method(:format) { |_results| "formatted" } boom = Class.new(StandardError) @@ -266,7 +275,7 @@ def test_runner_propagates_reset_connection_errors_without_masking scenarios: { "instance" => Runnable.new( - reset: "", + template: "golden_en57", build: ->(_database_url, _warmup_runs) { nil }, ), }, @@ -324,7 +333,7 @@ def call(measure, _retries, _run_id) scenarios: { "warmup" => Runnable.new( - reset: "", + template: "golden_en57", build: ->(_database_url, warmup_runs) do scenario = scenario_class.new(warmup_runs:) end, @@ -659,7 +668,7 @@ def test_runner_discovers_scenarios_by_database_instance first_scenario = Class.new do def self.database_instance = "a-discovered" - def self.reset = "reset-a" + def self.template = "golden_a" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -668,7 +677,7 @@ def self.build(database_url:, warmup_runs:, runs:) second_scenario = Class.new do def self.database_instance = "b-discovered" - def self.reset = "reset-b" + def self.template = "golden_b" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -678,7 +687,7 @@ def self.build(database_url:, warmup_runs:, runs:) Scenario.stub(:definitions, [second_scenario, first_scenario]) do assert_equal(%w[a-discovered b-discovered], Runner.names) runnable = Runner.scenarios(runs: 3).fetch("a-discovered") - assert_equal("reset-a", runnable.reset) + assert_equal("golden_a", runnable.template) assert_equal( ["postgres://example", 2, 3], runnable.build.call("postgres://example", 2), @@ -690,13 +699,13 @@ def test_classic_runner_selects_named_scenarios first_scenario = Class.new do def self.database_instance = "first" - def self.reset = "" + def self.template = "golden_en57" def self.build(...) = nil end second_scenario = Class.new do def self.database_instance = "second" - def self.reset = "" + def self.template = "golden_en57" def self.build(...) = nil end @@ -714,7 +723,7 @@ def test_classic_runner_defaults_to_fifty_runs scenario = Class.new do def self.database_instance = "scenario" - def self.reset = "" + def self.template = "golden_en57" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -833,57 +842,44 @@ def test_measurement_calculates_median_for_larger_even_sample_counts assert_in_delta(0.35, measurement.median) end - def test_reset_en57_truncates_event_and_tag_tables - assert_equal( - "TRUNCATE en57.tags, en57.events RESTART IDENTITY CASCADE", - Scenario::RESET_EN57, - ) - end - - def test_reset_res_truncates_event_store_tables - assert_equal( - "TRUNCATE event_store_events, event_store_events_in_streams " \ - "RESTART IDENTITY CASCADE", - Scenario::RESET_RES, - ) - end - - def test_scenario_define_defaults_reset_to_en57_truncate + def test_scenario_define_defaults_template_to_golden_en57 original_definitions = Scenario.definitions.dup scenario_class = - Scenario.define(database_instance: "reset-default", name: "Reset") + Scenario.define( + database_instance: "template-default", + name: "Template", + ) - assert_equal(Scenario::RESET_EN57, scenario_class.reset) + assert_equal("golden_en57", scenario_class.template) ensure Scenario.definitions.replace(original_definitions) end - def test_scenario_define_accepts_custom_reset + def test_scenario_define_accepts_custom_template original_definitions = Scenario.definitions.dup scenario_class = Scenario.define( - database_instance: "reset-custom", - name: "Reset", - reset: "TRUNCATE custom", + database_instance: "template-custom", + name: "Template", + template: "golden_custom", ) - assert_equal("TRUNCATE custom", scenario_class.reset) + assert_equal("golden_custom", scenario_class.template) ensure Scenario.definitions.replace(original_definitions) end - def test_seeded_scenario_reset_reloads_seed_after_truncate + def test_seeded_scenario_clones_seeded_template seeded = Scenario.definitions.find do it.database_instance == "concurrent-append-non-conflicting-tags-seeded" end - assert(seeded.reset.start_with?(Scenario::RESET_EN57)) - assert_includes(seeded.reset, "INSERT INTO en57.events") + assert_equal("golden_en57_seeded", seeded.template) end - def test_res_scenarios_reset_with_res_truncate + def test_res_scenarios_clone_res_template res_scenarios = Scenario.definitions.select do it.database_instance.start_with?("res-") @@ -891,18 +887,22 @@ def test_res_scenarios_reset_with_res_truncate refute_empty(res_scenarios) res_scenarios.each do |scenario| - assert_equal(Scenario::RESET_RES, scenario.reset) + assert_equal("golden_res", scenario.template) end end - def test_scenario_with_overrides_reset + def test_scenario_with_overrides_template original_definitions = Scenario.definitions.dup scenario_class = - Scenario.define(database_instance: "reset-base", name: "Base") - copy = scenario_class.with(database_instance: "reset-copy", reset: "X") + Scenario.define(database_instance: "template-base", name: "Base") + copy = + scenario_class.with( + database_instance: "template-copy", + template: "golden_x", + ) - assert_equal(Scenario::RESET_EN57, scenario_class.reset) - assert_equal("X", copy.reset) + assert_equal("golden_en57", scenario_class.template) + assert_equal("golden_x", copy.template) ensure Scenario.definitions.replace(original_definitions) end diff --git a/test/test_ephemeral_database.rb b/test/test_ephemeral_database.rb new file mode 100644 index 0000000..638a364 --- /dev/null +++ b/test/test_ephemeral_database.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +require "test_helper" + +module En57 + class TestEphemeralDatabase < Minitest::Test + cover "En57::EphemeralDatabase*" + + def test_clones_from_template_yields_url_then_drops_and_closes + admin = recording_connection + urls = [] + yielded = nil + connect = ->(url) do + urls << url + admin + end + + result = + PG.stub(:connect, connect) do + EphemeralDatabase.with(template: "golden_x") do |database_url| + yielded = database_url + "block-result" + end + end + + assert_equal([EphemeralDatabase::ADMIN_URL], urls) + assert_match(%r{\Apostgres:///en57\.\h{16}\z}, yielded) + + name = yielded.delete_prefix("postgres:///") + assert_equal( + [ + %(CREATE DATABASE "#{name}" TEMPLATE "golden_x"), + %(DROP DATABASE IF EXISTS "#{name}" WITH (FORCE)), + ], + admin.statements, + ) + assert_equal(1, admin.closed) + assert_equal("block-result", result) + end + + def test_without_template_creates_a_plain_database + admin = recording_connection + yielded = nil + + PG.stub(:connect, ->(_url) { admin }) do + EphemeralDatabase.with { |database_url| yielded = database_url } + end + + name = yielded.delete_prefix("postgres:///") + assert_equal(%(CREATE DATABASE "#{name}"), admin.statements.fetch(0)) + end + + def test_uses_the_given_prefix_for_the_database_name + yielded = nil + + PG.stub(:connect, ->(_url) { recording_connection }) do + EphemeralDatabase.with(prefix: "migrator") { |url| yielded = url } + end + + assert_match(%r{\Apostgres:///migrator\.\h{16}\z}, yielded) + end + + def test_drops_and_closes_even_when_the_block_raises + admin = recording_connection + boom = Class.new(StandardError) + + assert_raises(boom) do + PG.stub(:connect, ->(_url) { admin }) do + EphemeralDatabase.with(template: "golden_x") { raise boom } + end + end + + assert_equal(2, admin.statements.size) + assert_match( + /\ADROP DATABASE IF EXISTS .+ WITH \(FORCE\)\z/, + admin.statements.fetch(1), + ) + assert_equal(1, admin.closed) + end + + def test_propagates_connection_errors_without_running_teardown + boom = Class.new(StandardError) + + assert_raises(boom) do + PG.stub(:connect, ->(_url) { raise boom }) do + EphemeralDatabase.with(template: "golden_x") do + flunk("must not yield") + end + end + end + end + + private + + def recording_connection + Class + .new do + attr_reader :statements, :closed + + def initialize + @statements = [] + @closed = 0 + end + + def exec(sql) = @statements << sql + def close = @closed += 1 + end + .new + end + end +end diff --git a/test/test_factories.rb b/test/test_factories.rb index 79c21cb..e213ac1 100644 --- a/test/test_factories.rb +++ b/test/test_factories.rb @@ -5,15 +5,18 @@ module En57 class TestFactories < IntegrationTest def test_for_pg_round_trips_with_connection_uri - assert_round_trip EventStore.for_pg(MAIN_URL) + assert_round_trip EventStore.for_pg(database_url) end def test_for_pooled_pg_round_trips_with_default_max_connections - assert_round_trip EventStore.for_pooled_pg(MAIN_URL) + assert_round_trip EventStore.for_pooled_pg(database_url) end def test_for_pooled_pg_round_trips_with_custom_max_connections - assert_round_trip EventStore.for_pooled_pg(MAIN_URL, max_connections: 1) + assert_round_trip EventStore.for_pooled_pg( + database_url, + max_connections: 1, + ) end def test_for_active_record_round_trips_with_default_model @@ -27,16 +30,16 @@ def test_for_active_record_round_trips_with_custom_model end def test_for_sequel_round_trips_with_database - assert_round_trip EventStore.for_sequel(SEQUEL_DB) + assert_round_trip EventStore.for_sequel(sequel_db) end def test_event_store_does_not_conflict_with_public_schema_tables - CONNECTION.exec("CREATE TABLE public.events (id integer PRIMARY KEY)") - CONNECTION.exec("CREATE TABLE public.tags (id integer PRIMARY KEY)") + connection.exec("CREATE TABLE public.events (id integer PRIMARY KEY)") + connection.exec("CREATE TABLE public.tags (id integer PRIMARY KEY)") - assert_round_trip EventStore.for_pg(MAIN_URL) + assert_round_trip EventStore.for_pg(database_url) ensure - CONNECTION.exec("DROP TABLE IF EXISTS public.tags, public.events") + connection.exec("DROP TABLE IF EXISTS public.tags, public.events") end private diff --git a/test/test_helper.rb b/test/test_helper.rb index 1f8e11c..4ca46ae 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -11,6 +11,7 @@ require "connection_pool" require "en57" +require "en57/ephemeral_database" # test dependencies require "securerandom" @@ -18,42 +19,49 @@ module En57 class IntegrationTest < Minitest::Test - MAIN_URL = "postgres:///main" - - CONNECTION = PG.connect(MAIN_URL) + ADAPTER_NAMES = %i[pg sequel active_record] POOL_SIZE = 8 - PG_POOL = ConnectionPool.new(size: POOL_SIZE) { PG.connect(MAIN_URL) } + attr_reader :database_url, :connection, :sequel_db - SEQUEL_DB = - Sequel.connect( - MAIN_URL, - preconnect: :concurrently, - max_connections: POOL_SIZE, + def setup + @admin = PG.connect(EphemeralDatabase::ADMIN_URL) + @database_name = "en57.#{SecureRandom.hex(8)}" + @admin.exec( + "CREATE DATABASE #{PG::Connection.quote_ident(@database_name)} " \ + "TEMPLATE golden_en57", ) + @database_url = "postgres:///#{@database_name}" - AR_POOL = -> do - ActiveRecord::Base.establish_connection("#{MAIN_URL}?pool=#{POOL_SIZE}") - ActiveRecord::Base.connection_pool - end.call - - ADAPTERS = { - pg: -> { PgAdapter.for_pool(PG_POOL) }, - sequel: -> { SequelAdapter.new(SEQUEL_DB) }, - active_record: -> { ActiveRecordAdapter.new(AR_POOL) }, - } + @connection = PG.connect(@database_url) + @pg_pool = + ConnectionPool.new(size: POOL_SIZE) { PG.connect(@database_url) } + @sequel_db = Sequel.connect(@database_url, max_connections: POOL_SIZE) + ActiveRecord::Base.establish_connection( + "#{@database_url}?pool=#{POOL_SIZE}", + ) + @ar_pool = ActiveRecord::Base.connection_pool + end - def setup = - CONNECTION.exec( - "TRUNCATE TABLE en57.tags, en57.events RESTART IDENTITY CASCADE", + def teardown + @ar_pool&.disconnect! + @sequel_db&.disconnect + @pg_pool&.shutdown(&:close) + @connection&.close + @admin&.exec( + "DROP DATABASE IF EXISTS " \ + "#{PG::Connection.quote_ident(@database_name)} WITH (FORCE)", ) + @admin&.close + end - Minitest.after_run do - AR_POOL.disconnect! - SEQUEL_DB.disconnect - PG_POOL.shutdown(&:close) - CONNECTION.close + def adapter_factory(name) + { + pg: -> { PgAdapter.for_pool(@pg_pool) }, + sequel: -> { SequelAdapter.new(@sequel_db) }, + active_record: -> { ActiveRecordAdapter.new(@ar_pool) }, + }.fetch(name) end end end diff --git a/test/test_integration.rb b/test/test_integration.rb index c0ab1e7..796a365 100644 --- a/test/test_integration.rb +++ b/test/test_integration.rb @@ -4,9 +4,9 @@ module En57 class TestIntegration < IntegrationTest - ADAPTERS.each do |name, factory| + ADAPTER_NAMES.each do |name| define_method "test_#{name}_happy_path" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = [ Event.new( id: ids[0], @@ -30,7 +30,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_read_with_position_yields_events_and_positions" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = [ Event.new(id: ids[0], type: "OrderPlaced"), Event.new(id: ids[1], type: "PriceChanged"), @@ -45,7 +45,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_append_with_fail_if_and_no_matches_appends_events" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| event = Event.new(id: ids[0], type: "OrderPlaced") assert_equal( Success.new(position: 1), @@ -60,7 +60,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_append_with_fail_if_and_matches_returns_failure" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| existing_event = Event.new( id: ids[0], @@ -88,7 +88,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_append_with_after_ignores_matches_at_or_before_cutoff" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| existing_event = Event.new(id: ids[0], type: "OrderPlaced") assert_equal( Success.new(position: 1), @@ -110,7 +110,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_append_with_after_returns_failure_if_match_is_after_cutoff" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| existing_event = Event.new(id: ids[0], type: "OrderPlaced") assert_equal( Success.new(position: 1), @@ -130,7 +130,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_append_with_duplicate_id_raises_unique_violation" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| existing_event = Event.new(id: ids[0], type: "OrderPlaced") assert_equal( Success.new(position: 1), @@ -148,7 +148,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_tags_round_trip" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| event = Event.new(id: ids[0], type: "OrderPlaced", tags: ["order_id:123"]) @@ -158,7 +158,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_read_filters_after" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = [ Event.new(id: ids[0], type: "OrderPlaced"), Event.new(id: ids[1], type: "PriceChanged"), @@ -170,7 +170,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_read_filters_by_tags" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = [ Event.new( id: ids[0], @@ -197,7 +197,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_read_filters_by_type" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = [ Event.new(id: ids[0], type: "OrderPlaced"), Event.new(id: ids[1], type: "PriceChanged"), @@ -212,7 +212,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_read_filters_by_any_of_types" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = [ Event.new(id: ids[0], type: "PriceChanged"), Event.new(id: ids[1], type: "OrderPlaced"), @@ -228,7 +228,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_read_filters_by_type_and_tag_on_same_item" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = [ Event.new(id: ids[0], type: "OrderPlaced", tags: ["order_id:123"]), Event.new(id: ids[1], type: "OrderPlaced", tags: ["order_id:456"]), @@ -249,7 +249,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_read_or_combines_scopes_as_disjunction" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = [ Event.new(id: ids[0], type: "OrderPlaced", tags: ["order_id:123"]), Event.new(id: ids[1], type: "OrderPlaced", tags: ["order_id:456"]), @@ -272,7 +272,7 @@ class TestIntegration < IntegrationTest end define_method "test_#{name}_read_streams_results_spanning_many_batches" do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| events = (1..5).map do |n| Event.new( diff --git a/test/test_migrator.rb b/test/test_migrator.rb index 59cfb64..2ada086 100644 --- a/test/test_migrator.rb +++ b/test/test_migrator.rb @@ -4,6 +4,9 @@ module En57 class TestMigrator < IntegrationTest + def setup = nil + def teardown = nil + def test_status_reports_pending_schema_on_empty_database with_database do |url| assert_equal( @@ -108,17 +111,8 @@ def test_migrate_rejects_partial_status private - def with_database - name = "en57_migrator_#{SecureRandom.hex(8)}" - CONNECTION.exec(%(CREATE DATABASE #{PG::Connection.quote_ident(name)})) - yield database_url(name) - ensure - CONNECTION.exec( - %(DROP DATABASE IF EXISTS #{PG::Connection.quote_ident(name)}), - ) - end - - def database_url(name) = "postgres:///#{name}" + def with_database(&block) = + EphemeralDatabase.with(prefix: "en57-migrator", &block) def schema_path(version) File.expand_path("../db/schema/#{version}.sql", __dir__) diff --git a/test/test_stress.rb b/test/test_stress.rb index 9d9eb5e..bbb34b7 100644 --- a/test/test_stress.rb +++ b/test/test_stress.rb @@ -4,11 +4,11 @@ module En57 class TestStress < IntegrationTest - ADAPTERS.each do |name, factory| + ADAPTER_NAMES.each do |name| define_method( "test_#{name}_only_one_writer_can_consume_account_credits", ) do - with_event_store(factory) do |event_store| + with_event_store(adapter_factory(name)) do |event_store| event_store.append( [ Event.new( From d0590aa534268ce4bd2b4f0526113ec1431cdc81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 15:02:44 +0200 Subject: [PATCH 09/12] Forbid code comments in agent rules - Keep explanations in commit messages and rely on clear names instead of inline comments. - Drop the redundant "run treefmt after editing" rule; the treefmt hook already runs on edit. --- AGENTS.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 4cc278e..5d4e736 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,5 @@ # Agent rules -- Prefer inlining variables when they are not reused. -- After editing files, run `treefmt`. -- When adding and building a feature, follow the TDD process. +- Prefer inlining variables when they are not reused +- When adding and building a feature, follow the TDD process +- Never add code comments, keep method and variable names clear enough for comments not to be needed, keep "the why" comments in commit messages From 6239ee8badeb780a171406eedc78b5b963ce960f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 15:49:28 +0200 Subject: [PATCH 10/12] Clone one test database per run, not per example - Provision the golden_en57 clone and connection pools once at suite load and drop the database in Minitest.after_run, instead of per example. - Reset state between examples with TRUNCATE ... RESTART IDENTITY; with no parallelism yet, per-example databases only added createdb/dropdb and pool-rebuild overhead. - Integration suite drops from ~3.3s to ~1s. --- test/test_helper.rb | 60 +++++++++++++++++++++++++-------------------- 1 file changed, 33 insertions(+), 27 deletions(-) diff --git a/test/test_helper.rb b/test/test_helper.rb index 4ca46ae..8574582 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -23,44 +23,50 @@ class IntegrationTest < Minitest::Test POOL_SIZE = 8 - attr_reader :database_url, :connection, :sequel_db + ADMIN = PG.connect(EphemeralDatabase::ADMIN_URL) + DATABASE_NAME = "en57.#{SecureRandom.hex(8)}" + ADMIN.exec( + "CREATE DATABASE #{PG::Connection.quote_ident(DATABASE_NAME)} " \ + "TEMPLATE golden_en57", + ) + DATABASE_URL = "postgres:///#{DATABASE_NAME}" - def setup - @admin = PG.connect(EphemeralDatabase::ADMIN_URL) - @database_name = "en57.#{SecureRandom.hex(8)}" - @admin.exec( - "CREATE DATABASE #{PG::Connection.quote_ident(@database_name)} " \ - "TEMPLATE golden_en57", + CONNECTION = PG.connect(DATABASE_URL) + PG_POOL = ConnectionPool.new(size: POOL_SIZE) { PG.connect(DATABASE_URL) } + SEQUEL_DB = Sequel.connect(DATABASE_URL, max_connections: POOL_SIZE) + AR_POOL = -> do + ActiveRecord::Base.establish_connection( + "#{DATABASE_URL}?pool=#{POOL_SIZE}", ) - @database_url = "postgres:///#{@database_name}" + ActiveRecord::Base.connection_pool + end.call - @connection = PG.connect(@database_url) - @pg_pool = - ConnectionPool.new(size: POOL_SIZE) { PG.connect(@database_url) } - @sequel_db = Sequel.connect(@database_url, max_connections: POOL_SIZE) - ActiveRecord::Base.establish_connection( - "#{@database_url}?pool=#{POOL_SIZE}", + def database_url = DATABASE_URL + def connection = CONNECTION + def sequel_db = SEQUEL_DB + + def setup = + CONNECTION.exec( + "TRUNCATE TABLE en57.tags, en57.events RESTART IDENTITY CASCADE", ) - @ar_pool = ActiveRecord::Base.connection_pool - end - def teardown - @ar_pool&.disconnect! - @sequel_db&.disconnect - @pg_pool&.shutdown(&:close) - @connection&.close - @admin&.exec( + Minitest.after_run do + AR_POOL.disconnect! + SEQUEL_DB.disconnect + PG_POOL.shutdown(&:close) + CONNECTION.close + ADMIN.exec( "DROP DATABASE IF EXISTS " \ - "#{PG::Connection.quote_ident(@database_name)} WITH (FORCE)", + "#{PG::Connection.quote_ident(DATABASE_NAME)} WITH (FORCE)", ) - @admin&.close + ADMIN.close end def adapter_factory(name) { - pg: -> { PgAdapter.for_pool(@pg_pool) }, - sequel: -> { SequelAdapter.new(@sequel_db) }, - active_record: -> { ActiveRecordAdapter.new(@ar_pool) }, + pg: -> { PgAdapter.for_pool(PG_POOL) }, + sequel: -> { SequelAdapter.new(SEQUEL_DB) }, + active_record: -> { ActiveRecordAdapter.new(AR_POOL) }, }.fetch(name) end end From 920dc97b460dadea394f7f5182d5234752265d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 15:56:01 +0200 Subject: [PATCH 11/12] Format with nix syntax_tree, drop the gem - Add pkgs.rubyPackages.syntax_tree (6.3.0, same version as the lockfile, so formatting output is unchanged) and point the treefmt ruby formatter at its store-path stree binary. - Referencing the absolute nix path (like scripts.pg-regress does for postgres) avoids a stale bundle-installed stree shadowing it on PATH. - Drop syntax_tree from the Gemfile and its bin/stree binstub; formatting no longer depends on the project bundle. --- Gemfile | 1 - Gemfile.lock | 6 ------ bin/stree | 16 ---------------- devenv.nix | 3 ++- 4 files changed, 2 insertions(+), 24 deletions(-) delete mode 100755 bin/stree diff --git a/Gemfile b/Gemfile index bb87b8f..a7721db 100644 --- a/Gemfile +++ b/Gemfile @@ -14,7 +14,6 @@ gem "minitest-mock" gem "minitest-stub-const" gem "mutant-minitest" gem "concurrent-ruby" -gem "syntax_tree" gem "activerecord" gem "sequel" diff --git a/Gemfile.lock b/Gemfile.lock index ca1cf92..a249f8f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -165,7 +165,6 @@ GEM pg (1.6.3-x86_64-linux) pp (0.6.3) prettyprint - prettier_print (1.2.1) prettyprint (0.2.0) prism (1.9.0) psych (5.4.0) @@ -242,8 +241,6 @@ GEM bigdecimal sorbet-runtime (0.6.13270) stringio (3.2.0) - syntax_tree (6.3.0) - prettier_print (>= 1.2.0) thor (1.5.0) timeout (0.6.1) tsort (0.2.0) @@ -283,7 +280,6 @@ DEPENDENCIES rails_event_store rake sequel - syntax_tree CHECKSUMS action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 @@ -344,7 +340,6 @@ CHECKSUMS pg (1.6.3-arm64-darwin) sha256=7240330b572e6355d7c75a7de535edb5dfcbd6295d9c7777df4d9dddfb8c0e5f pg (1.6.3-x86_64-linux) sha256=5d9e188c8f7a0295d162b7b88a768d8452a899977d44f3274d1946d67920ae8d pp (0.6.3) sha256=2951d514450b93ccfeb1df7d021cae0da16e0a7f95ee1e2273719669d0ab9df6 - prettier_print (1.2.1) sha256=a72838b5f23facff21f90a5423cdcdda19e4271092b41f4ea7f50b83929e6ff9 prettyprint (0.2.0) sha256=2bc9e15581a94742064a3cc8b0fb9d45aae3d03a1baa6ef80922627a0766f193 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 @@ -370,7 +365,6 @@ CHECKSUMS sequel (5.105.0) sha256=4191d5e5d011b7229ff8a3401ca6beac047c4d35b1ec6703376513b4b25b2e67 sorbet-runtime (0.6.13270) sha256=e7bc6899e438e8fa02fb9324febb4433538b262ecfe8ce770cbd1221e2bf3276 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 - syntax_tree (6.3.0) sha256=56e25a9692c798ec94c5442fe94c5e94af76bef91edc8bb02052cbdecf35f13d thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 timeout (0.6.1) sha256=78f57368a7e7bbadec56971f78a3f5ecbcfb59b7fcbb0a3ed6ddc08a5094accb tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f diff --git a/bin/stree b/bin/stree deleted file mode 100755 index e91e19a..0000000 --- a/bin/stree +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env ruby -# frozen_string_literal: true - -# -# This file was generated by Bundler. -# -# The application 'stree' is installed as part of a gem, and -# this file is here to facilitate running it. -# - -ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../Gemfile", __dir__) - -require "rubygems" -require "bundler/setup" - -load Gem.bin_path("syntax_tree", "stree") diff --git a/devenv.nix b/devenv.nix index 926b5f4..308b5d1 100644 --- a/devenv.nix +++ b/devenv.nix @@ -22,6 +22,7 @@ pkgs.postgresql_18 pkgs.postgresql_18.pg_config pkgs.sqlfluff + pkgs.rubyPackages.syntax_tree pkgs.jq pkgs.libyaml pkgs.pi-coding-agent @@ -165,7 +166,7 @@ }; settings.formatter = { ruby = { - command = "bin/stree"; + command = "${pkgs.rubyPackages.syntax_tree}/bin/stree"; options = [ "write" ]; includes = [ "*.rb" ]; }; From 92b3a3b0a95da696b1f39dced0f2ac01281df07c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pawe=C5=82=20Pacana?= Date: Thu, 25 Jun 2026 15:56:26 +0200 Subject: [PATCH 12/12] Reapply "Cache the Nix store in GitHub Actions" This reverts commit 7314af0a2ec8cf53c1520eb1fe346e23f09320ec. --- .github/workflows/main.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index b69cad9..d4469aa 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,6 +27,19 @@ jobs: with: extra_nix_config: | accept-flake-config = true + keep-outputs = true + keep-env-derivations = true + - name: Cache Nix store + uses: nix-community/cache-nix-action@v7 + with: + primary-key: nix-${{ runner.os }}-${{ hashFiles('**/*.nix', 'devenv.lock', 'devenv.yaml') }} + restore-prefixes-first-match: nix-${{ runner.os }}- + gc-max-store-size-linux: 1G + purge: true + purge-prefixes: nix-${{ runner.os }}- + purge-created: 0 + purge-last-accessed: P1DT12H + purge-primary-key: never - name: Set up cachix uses: cachix/cachix-action@v16 with: