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/.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/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 diff --git a/Gemfile b/Gemfile index c8be050..a7721db 100644 --- a/Gemfile +++ b/Gemfile @@ -14,8 +14,6 @@ gem "minitest-mock" gem "minitest-stub-const" gem "mutant-minitest" gem "concurrent-ruby" -gem "pg-ephemeral" -gem "syntax_tree" gem "activerecord" gem "sequel" diff --git a/Gemfile.lock b/Gemfile.lock index 116d46d..a249f8f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -160,19 +160,11 @@ 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) prettyprint (0.2.0) prism (1.9.0) psych (5.4.0) @@ -249,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) @@ -286,12 +276,10 @@ DEPENDENCIES minitest-mock minitest-stub-const mutant-minitest - pg-ephemeral rails rails_event_store rake sequel - syntax_tree CHECKSUMS action_text-trix (2.1.19) sha256=7012f59421009cf284aa651294896414d653a61a2417c9b8714c8476d2f74009 @@ -348,15 +336,10 @@ 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 prism (1.9.0) sha256=7b530c6a9f92c24300014919c9dcbc055bf4cdf51ec30aed099b06cd6674ef85 psych (5.4.0) sha256=14f72d69a611af663d7d70e4a7b67d9eb1f3ae9f8d916b478961d5a0075ba5b7 @@ -382,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/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. 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/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/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..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 @@ -33,6 +34,36 @@ export MUTANT_SINCE="''${MUTANT_SINCE:-HEAD}" ''; + enterTest = '' + devenv tasks run test + ''; + + services.postgres = { + enable = true; + package = pkgs.postgresql_18; + initialDatabases = + let + 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 + [ + { + name = "golden_en57"; + schema = ./db/schema/0.1.0.sql; + } + { + name = "golden_res"; + schema = ./db/seeds/res.sql; + } + { + name = "golden_en57_seeded"; + schema = seededSchema; + } + ]; + }; + files = let commitSkill = '' @@ -89,7 +120,7 @@ "*" = "treefmt {file}"; }; onStop = { - "*" = "devenv tasks run test"; + "*" = "devenv test"; }; }; }; @@ -135,7 +166,7 @@ }; settings.formatter = { ruby = { - command = "stree"; + command = "${pkgs.rubyPackages.syntax_tree}/bin/stree"; options = [ "write" ]; includes = [ "*.rb" ]; }; @@ -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" \ + dbname=regress + 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 \ @@ -204,12 +224,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 ''; diff --git a/lib/benchmark/concurrent_append_non_conflicting_tags.rb b/lib/benchmark/concurrent_append_non_conflicting_tags.rb index 2c2d687..d55fe14 100644 --- a/lib/benchmark/concurrent_append_non_conflicting_tags.rb +++ b/lib/benchmark/concurrent_append_non_conflicting_tags.rb @@ -38,6 +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)", + 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 ccb95fd..c4cf767 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, + 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 94b80ce..0101076 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, + 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 8899158..e1992f7 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, + template: "golden_res", ) do def setup(database_url) require "active_record" diff --git a/lib/en57/benchmark.rb b/lib/en57/benchmark.rb index fca99f5..40c4f6f 100644 --- a/lib/en57/benchmark.rb +++ b/lib/en57/benchmark.rb @@ -3,10 +3,11 @@ require "benchmark" require "concurrent-ruby" require "connection_pool" -require "pg_ephemeral" +require "pg" require "securerandom" require_relative "../en57" +require_relative "ephemeral_database" module En57 module Benchmark @@ -22,6 +23,8 @@ module Benchmark :retry_count, ) + Runnable = Data.define(:build, :template) + class Table def format(results) return "" if results.empty? @@ -121,7 +124,14 @@ 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, + :template, + ) @definitions = [] @@ -133,6 +143,7 @@ def self.define( concurrency: 1, batch_size: 100, runs: ->(runs) { runs }, + template: "golden_en57", &block ) register( @@ -142,6 +153,7 @@ def self.define( concurrency:, batch_size:, runs:, + template:, ), &block ) @@ -158,6 +170,8 @@ def self.register(configuration, &block) configuration.database_instance end + define_singleton_method(:template) { configuration.template } + define_singleton_method( :build, ) do |database_url:, warmup_runs:, runs:| @@ -243,9 +257,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( + template: scenario_class.template, + build: ->(database_url, warmup_runs) do + scenario_class.build(database_url:, warmup_runs:, runs:) + end, + ), ] end end @@ -260,12 +277,14 @@ def run En57.configuration.append_retries = 100 results = - @scenarios.map do |instance_name, mk_scenario| - PgEphemeral.with_server(instance_name:) do |server| + @scenarios.map do |_instance_name, runnable| + EphemeralDatabase.with( + template: runnable.template, + ) do |database_url| samples = Concurrent::Array.new retries = Concurrent::AtomicFixnum.new - scenario = mk_scenario.call(server.url, 2) + scenario = runnable.build.call(database_url, 2) scenario.run( ->(&block) { samples << ::Benchmark.realtime { block.call } }, -> { retries.increment }, 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 2521402..c06e224 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( + template: "golden_en57", + 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( + template: "golden_en57", + 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( + template: "golden_en57", + 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_clones_template_and_yields_ephemeral_database_url 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,55 @@ 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(template: "golden_res", build:), + }, + ).run end - assert_equal(["instance"], instance_names) - assert_equal(["postgres://example"], 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([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_clone_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( + template: "golden_en57", + 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 +318,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 +331,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( + template: "golden_en57", + build: ->(_database_url, warmup_runs) do + scenario = scenario_class.new(warmup_runs:) + end, + ), }, ).run end @@ -636,6 +668,7 @@ def test_runner_discovers_scenarios_by_database_instance first_scenario = Class.new do def self.database_instance = "a-discovered" + def self.template = "golden_a" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -644,6 +677,7 @@ def self.build(database_url:, warmup_runs:, runs:) second_scenario = Class.new do def self.database_instance = "b-discovered" + def self.template = "golden_b" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -652,12 +686,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("golden_a", runnable.template) 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 +699,13 @@ def test_classic_runner_selects_named_scenarios first_scenario = Class.new do def self.database_instance = "first" + def self.template = "golden_en57" def self.build(...) = nil end second_scenario = Class.new do def self.database_instance = "second" + def self.template = "golden_en57" def self.build(...) = nil end @@ -688,6 +723,7 @@ def test_classic_runner_defaults_to_fifty_runs scenario = Class.new do def self.database_instance = "scenario" + def self.template = "golden_en57" def self.build(database_url:, warmup_runs:, runs:) [database_url, warmup_runs, runs] @@ -700,6 +736,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 +841,93 @@ def test_measurement_calculates_median_for_larger_even_sample_counts assert_in_delta(0.35, measurement.median) end + + def test_scenario_define_defaults_template_to_golden_en57 + original_definitions = Scenario.definitions.dup + scenario_class = + Scenario.define( + database_instance: "template-default", + name: "Template", + ) + + assert_equal("golden_en57", scenario_class.template) + ensure + Scenario.definitions.replace(original_definitions) + end + + def test_scenario_define_accepts_custom_template + original_definitions = Scenario.definitions.dup + scenario_class = + Scenario.define( + database_instance: "template-custom", + name: "Template", + template: "golden_custom", + ) + + assert_equal("golden_custom", scenario_class.template) + ensure + Scenario.definitions.replace(original_definitions) + end + + def test_seeded_scenario_clones_seeded_template + seeded = + Scenario.definitions.find do + it.database_instance == + "concurrent-append-non-conflicting-tags-seeded" + end + + assert_equal("golden_en57_seeded", seeded.template) + end + + def test_res_scenarios_clone_res_template + res_scenarios = + Scenario.definitions.select do + it.database_instance.start_with?("res-") + end + + refute_empty(res_scenarios) + res_scenarios.each do |scenario| + assert_equal("golden_res", scenario.template) + end + end + + def test_scenario_with_overrides_template + original_definitions = Scenario.definitions.dup + scenario_class = + Scenario.define(database_instance: "template-base", name: "Base") + copy = + scenario_class.with( + database_instance: "template-copy", + template: "golden_x", + ) + + assert_equal("golden_en57", scenario_class.template) + assert_equal("golden_x", copy.template) + 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_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 cb2f92c..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(SERVER.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(SERVER.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(SERVER.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(SERVER.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 28c0048..8574582 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -11,39 +11,39 @@ require "connection_pool" require "en57" +require "en57/ephemeral_database" # test dependencies require "securerandom" require "concurrent-ruby" -require "pg_ephemeral" module En57 class IntegrationTest < Minitest::Test - SERVER = PgEphemeral.start - - CONNECTION = PG.connect(SERVER.url) + ADAPTER_NAMES = %i[pg sequel active_record] POOL_SIZE = 8 - PG_POOL = ConnectionPool.new(size: POOL_SIZE) { PG.connect(SERVER.url) } - - SEQUEL_DB = - Sequel.connect( - SERVER.url, - preconnect: :concurrently, - max_connections: POOL_SIZE, - ) + 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}" + 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("#{SERVER.url}&pool=#{POOL_SIZE}") + ActiveRecord::Base.establish_connection( + "#{DATABASE_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) }, - } + def database_url = DATABASE_URL + def connection = CONNECTION + def sequel_db = SEQUEL_DB def setup = CONNECTION.exec( @@ -55,7 +55,19 @@ def setup = SEQUEL_DB.disconnect PG_POOL.shutdown(&:close) CONNECTION.close - SERVER.shutdown + ADMIN.exec( + "DROP DATABASE IF EXISTS " \ + "#{PG::Connection.quote_ident(DATABASE_NAME)} WITH (FORCE)", + ) + ADMIN.close + end + + 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 db74e45..2ada086 100644 --- a/test/test_migrator.rb +++ b/test/test_migrator.rb @@ -1,10 +1,12 @@ # frozen_string_literal: true require "test_helper" -require "uri" 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( @@ -109,21 +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) - uri = URI(SERVER.url) - uri.path = "/#{name}" - uri.to_s - end + 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(