From 201fd3356ab1df7e7e2cc63927c03d7d9c2edd75 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 10 Aug 2026 07:04:32 -0700 Subject: [PATCH 1/2] feat: verify the database server at startup Each adapter now reports its version against the oldest server Solid Objects is exercised against, and MySQL additionally confirms Solid Objects tables use InnoDB, since a non-transactional engine would silently break fenced commits. The doctor reports this as database_server and warns rather than failing, because refusing to run on an untested server would be a worse failure than running on one. PostgreSQL reports a packed integer, so it is normalised: comparing 170010 directly would make a 9.6 server look newer than any minimum, which the PostgreSQL run caught. --- CHANGELOG.md | 10 ++ docs/roadmap.md | 12 +- lib/solid_objects/database_adapter.rb | 34 ++++++ lib/solid_objects/database_adapters/mysql.rb | 28 +++++ .../database_adapters/postgresql.rb | 15 +++ lib/solid_objects/database_adapters/sqlite.rb | 5 + lib/solid_objects/doctor.rb | 15 +++ .../lib/solid_objects/database_adapter.rbs | 15 +++ .../solid_objects/database_adapters/mysql.rbs | 11 ++ .../database_adapters/postgresql.rbs | 8 ++ .../database_adapters/sqlite.rbs | 3 + sig/generated/lib/solid_objects/doctor.rbs | 3 + test/integration/database_version_test.rb | 108 ++++++++++++++++++ 13 files changed, 262 insertions(+), 5 deletions(-) create mode 100644 test/integration/database_version_test.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index c2da92f..18b88f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +- Verify the database server. Each adapter reports its version against the + oldest one Solid Objects is exercised against, PostgreSQL 13, MySQL 8.0, and + SQLite 3.35, and MySQL additionally confirms that Solid Objects tables use + InnoDB, since a non-transactional engine would silently break fenced commits. + The doctor reports this as `database_server` and warns rather than failing: + refusing to run on an untested server would be a worse failure than running + on one. + ## 0.8.0 - 2026-08-10 - Replace a supervised role whose thread died. A role that raised left its diff --git a/docs/roadmap.md b/docs/roadmap.md index 438c14e..f2fd42c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,6 +24,9 @@ - Reconciliation read APIs - Installation doctor, authorization reference, fit guide, and legacy-state migration cookbook +- Database server verification: each adapter reports its version against a + tested minimum, MySQL confirms Solid Objects tables use InnoDB, and the + doctor warns rather than refusing to run on an untested server - Handler Active Record write isolation, same-database commit actions, ambient transaction rejection, adapter lock/query deadlines, bounded SQLite lock retries outside those deadlines, structured sync timeout diagnostics, and @@ -81,13 +84,12 @@ benchmark, and its concurrency tests are implemented. 2. Add result lookup by request ID and broader deadlock retry classification. 3. Add scheduled retention and stale-process maintenance. -4. Add database/server-version checks and MySQL InnoDB verification at boot. -5. Add Turbo append intents and expand reconnect coverage in a full browser. -6. Add distributed rate limits, global admission hooks, and cache-capacity +4. Add Turbo append intents and expand reconnect coverage in a full browser. +5. Add distributed rate limits, global admission hooks, and cache-capacity eviction. -7. Expand security scanning. Compatibility CI across supported Rails and Ruby +6. Expand security scanning. Compatibility CI across supported Rails and Ruby versions is implemented; Ruby 4.0 is not yet in the matrix. -8. Benchmark all workloads under documented hardware/database settings and +7. Benchmark all workloads under documented hardware/database settings and publish adapter-specific adoption measurements. Throughput, synchronous latency, query counts, and the three reactive delivery paths are measured on SQLite; adapter-specific and end-to-end browser measurements are not. diff --git a/lib/solid_objects/database_adapter.rb b/lib/solid_objects/database_adapter.rb index b8a9f73..d1f51e1 100644 --- a/lib/solid_objects/database_adapter.rb +++ b/lib/solid_objects/database_adapter.rb @@ -32,6 +32,40 @@ def initialize(connection) @fixed_connection = connection_pool ? nil : connection end + # The oldest server the adapter has been exercised against. Reported rather + # than enforced: refusing to boot on an untested server would be a worse + # failure than running on one. + # @rbs () -> Gem::Version? + def minimum_server_version + nil + end + + # @rbs () -> Gem::Version + def server_version + with_connection do |connection| + Gem::Version.new(connection.database_version.to_s) + end + end + + # @rbs () -> Array[String] + def unsupported_server_reasons + reasons = [] + minimum = minimum_server_version + if minimum && server_version < minimum + reasons << "#{self.class.name.demodulize} #{server_version} is older than " \ + "Solid Objects requires, which is #{minimum}" + end + reasons.concat(additional_server_reasons) + reasons + rescue => error + [ "the database server could not be verified: #{error.class}: #{error.message}" ] + end + + # @rbs () -> Array[String] + def additional_server_reasons + [] + end + # @rbs () -> bool def supports_skip_locked? false diff --git a/lib/solid_objects/database_adapters/mysql.rb b/lib/solid_objects/database_adapters/mysql.rb index cc5ebf7..e816828 100644 --- a/lib/solid_objects/database_adapters/mysql.rb +++ b/lib/solid_objects/database_adapters/mysql.rb @@ -13,6 +13,34 @@ def claim_lock "FOR UPDATE SKIP LOCKED" end + # A non-transactional engine would silently break fenced commits, so the + # storage engine is verified rather than assumed. + # @rbs () -> Array[String] + def additional_server_reasons + tables = non_innodb_tables + return [] if tables.empty? + + [ "these Solid Objects tables do not use InnoDB, so their commits are " \ + "not transactional: #{tables.join(", ")}" ] + end + + # @rbs () -> Array[String] + def non_innodb_tables + names = SolidObjects::Doctor::EXPECTED_COLUMNS.keys.map { |name| SolidObjects.table_name(name) } + with_connection do |connection| + connection.select_rows(<<~SQL.squish).filter_map { |table, engine| table if engine != "InnoDB" } + SELECT table_name, engine FROM information_schema.tables + WHERE table_schema = DATABASE() + AND table_name IN (#{names.map { |name| connection.quote(name) }.join(", ")}) + SQL + end + end + + # @rbs () -> Gem::Version? + def minimum_server_version + Gem::Version.new("8.0") + end + # @rbs () -> String def current_time_expression "CURRENT_TIMESTAMP(6)" diff --git a/lib/solid_objects/database_adapters/postgresql.rb b/lib/solid_objects/database_adapters/postgresql.rb index a78091a..44446a6 100644 --- a/lib/solid_objects/database_adapters/postgresql.rb +++ b/lib/solid_objects/database_adapters/postgresql.rb @@ -3,6 +3,21 @@ module SolidObjects module DatabaseAdapters class Postgresql < DatabaseAdapter + # @rbs () -> Gem::Version? + def minimum_server_version + Gem::Version.new("13") + end + + # PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it + # directly would make every server look newer than any minimum. + # @rbs () -> Gem::Version + def server_version + packed = with_connection { |connection| connection.database_version.to_i } + return super unless packed.positive? + + Gem::Version.new("#{packed / 10_000}.#{packed % 10_000}") + end + # @rbs () -> bool def supports_skip_locked? true diff --git a/lib/solid_objects/database_adapters/sqlite.rb b/lib/solid_objects/database_adapters/sqlite.rb index fb84927..8d94c37 100644 --- a/lib/solid_objects/database_adapters/sqlite.rb +++ b/lib/solid_objects/database_adapters/sqlite.rb @@ -8,6 +8,11 @@ class Sqlite < DatabaseAdapter LOCK_RETRY_MUTEX = Thread::Mutex.new LOCK_RETRY_CONDITION = Thread::ConditionVariable.new + # @rbs () -> Gem::Version? + def minimum_server_version + Gem::Version.new("3.35") + end + # @rbs () -> String def current_time_expression "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')" diff --git a/lib/solid_objects/doctor.rb b/lib/solid_objects/doctor.rb index c13dc4d..eda7bbf 100644 --- a/lib/solid_objects/doctor.rb +++ b/lib/solid_objects/doctor.rb @@ -111,6 +111,7 @@ def call configuration_check, schema_check, check_authorization, + check_database_server, schema_check.failed? ? skipped_runtime : check_runtime, ready_for_round_trip?(configuration_check, schema_check) ? check_sync_round_trip : @@ -191,6 +192,20 @@ def check_authorization pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context") end + # @rbs () -> Check + def check_database_server + adapter = SolidObjects.database_adapter + reasons = adapter.unsupported_server_reasons + return warn_check(:database_server, reasons.join("; ")) unless reasons.empty? + + pass( + :database_server, + "#{adapter.class.name.demodulize} #{adapter.server_version} meets the tested minimum" + ) + rescue => error + warn_check(:database_server, "#{error.class}: #{error.message}") + end + # @rbs () -> Check def check_runtime cutoff = SolidObjects.database_adapter.database_now - diff --git a/sig/generated/lib/solid_objects/database_adapter.rbs b/sig/generated/lib/solid_objects/database_adapter.rbs index bd9909c..17483a5 100644 --- a/sig/generated/lib/solid_objects/database_adapter.rbs +++ b/sig/generated/lib/solid_objects/database_adapter.rbs @@ -16,6 +16,21 @@ module SolidObjects # @rbs (untyped) -> void def initialize: (untyped) -> void + # The oldest server the adapter has been exercised against. Reported rather + # than enforced: refusing to boot on an untested server would be a worse + # failure than running on one. + # @rbs () -> Gem::Version? + def minimum_server_version: () -> Gem::Version? + + # @rbs () -> Gem::Version + def server_version: () -> Gem::Version + + # @rbs () -> Array[String] + def unsupported_server_reasons: () -> Array[String] + + # @rbs () -> Array[String] + def additional_server_reasons: () -> Array[String] + # @rbs () -> bool def supports_skip_locked?: () -> bool diff --git a/sig/generated/lib/solid_objects/database_adapters/mysql.rbs b/sig/generated/lib/solid_objects/database_adapters/mysql.rbs index 81ccb28..c6af5be 100644 --- a/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +++ b/sig/generated/lib/solid_objects/database_adapters/mysql.rbs @@ -9,6 +9,17 @@ module SolidObjects # @rbs () -> String def claim_lock: () -> String + # A non-transactional engine would silently break fenced commits, so the + # storage engine is verified rather than assumed. + # @rbs () -> Array[String] + def additional_server_reasons: () -> Array[String] + + # @rbs () -> Array[String] + def non_innodb_tables: () -> Array[String] + + # @rbs () -> Gem::Version? + def minimum_server_version: () -> Gem::Version? + # @rbs () -> String def current_time_expression: () -> String diff --git a/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs b/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs index c824ec9..e35b664 100644 --- a/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +++ b/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs @@ -3,6 +3,14 @@ module SolidObjects module DatabaseAdapters class Postgresql < DatabaseAdapter + # @rbs () -> Gem::Version? + def minimum_server_version: () -> Gem::Version? + + # PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it + # directly would make every server look newer than any minimum. + # @rbs () -> Gem::Version + def server_version: () -> Gem::Version + # @rbs () -> bool def supports_skip_locked?: () -> bool diff --git a/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs b/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs index 61e3d49..afd4737 100644 --- a/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +++ b/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs @@ -11,6 +11,9 @@ module SolidObjects LOCK_RETRY_CONDITION: untyped + # @rbs () -> Gem::Version? + def minimum_server_version: () -> Gem::Version? + # @rbs () -> String def current_time_expression: () -> String diff --git a/sig/generated/lib/solid_objects/doctor.rbs b/sig/generated/lib/solid_objects/doctor.rbs index e8d4342..a6ff3c9 100644 --- a/sig/generated/lib/solid_objects/doctor.rbs +++ b/sig/generated/lib/solid_objects/doctor.rbs @@ -72,6 +72,9 @@ module SolidObjects # @rbs () -> Check def check_authorization: () -> Check + # @rbs () -> Check + def check_database_server: () -> Check + # @rbs () -> Check def check_runtime: () -> Check diff --git a/test/integration/database_version_test.rb b/test/integration/database_version_test.rb new file mode 100644 index 0000000..11b4051 --- /dev/null +++ b/test/integration/database_version_test.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require "database_test_helper" +require "solid_objects/doctor" + +class DatabaseVersionTest < ActiveSupport::TestCase + test "reports the server version for the connected adapter" do + version = SolidObjects.database_adapter.server_version + + assert_kind_of Gem::Version, version + assert_operator version, :>, Gem::Version.new("0") + # A packed integer such as 170010 would compare greater than any minimum, + # making the check pass on servers it should flag. + assert_operator version, :<, Gem::Version.new("1000"), + "the version must be a human version, not a packed integer" + end + + test "an old PostgreSQL server is still detected" do + skip unless SolidObjects::Record.connection.adapter_name.match?(/postgres/i) + adapter = SolidObjects.database_adapter + adapter.define_singleton_method(:with_connection) { |&block| block.call(Struct.new(:database_version).new(90_600)) } + + assert_operator adapter.server_version, :<, adapter.minimum_server_version + ensure + restore(adapter, :with_connection) + end + + test "a supported server passes verification" do + assert_empty SolidObjects.database_adapter.unsupported_server_reasons + end + + test "an old server is reported rather than raised" do + adapter = SolidObjects.database_adapter + adapter.define_singleton_method(:server_version) { Gem::Version.new("1.0") } + + reasons = adapter.unsupported_server_reasons + + assert_equal 1, reasons.length + assert_match(/requires/, reasons.first) + ensure + restore(adapter, :server_version) + end + + test "an unreadable server version does not raise" do + adapter = SolidObjects.database_adapter + adapter.define_singleton_method(:server_version) { raise "boom" } + + assert_nothing_raised { adapter.unsupported_server_reasons } + ensure + restore(adapter, :server_version) + end + + test "the doctor reports a supported server" do + report = SolidObjects::Doctor.new.call + + check = report.check(:database_server) + assert_equal :pass, check.status + assert_match(/\d+\./, check.message) + end + + test "the doctor warns about an unsupported server" do + adapter = SolidObjects.database_adapter + adapter.define_singleton_method(:unsupported_server_reasons) { [ "too old" ] } + + check = SolidObjects::Doctor.new.call.check(:database_server) + + assert_equal :warn, check.status + assert_match(/too old/, check.message) + ensure + restore(adapter, :unsupported_server_reasons) + end + + test "an unsupported server does not fail the report" do + adapter = SolidObjects.database_adapter + adapter.define_singleton_method(:unsupported_server_reasons) { [ "too old" ] } + + assert SolidObjects::Doctor.new.call.healthy? + ensure + restore(adapter, :unsupported_server_reasons) + end + + test "MySQL verifies that Solid Objects tables use InnoDB" do + skip unless SolidObjects::Record.connection.adapter_name.match?(/mysql/i) + + assert_empty SolidObjects.database_adapter.unsupported_server_reasons + end + + test "MySQL reports a non-transactional storage engine" do + skip unless SolidObjects::Record.connection.adapter_name.match?(/mysql/i) + adapter = SolidObjects.database_adapter + adapter.define_singleton_method(:non_innodb_tables) { [ "solid_objects_messages" ] } + + reasons = adapter.unsupported_server_reasons + + assert(reasons.any? { |reason| reason.include?("InnoDB") }) + ensure + restore(adapter, :non_innodb_tables) + end + + private + + # Skipped tests still run their ensure, where the local is nil. + def restore(adapter, name) + return unless adapter&.singleton_class&.method_defined?(name) + + adapter.singleton_class.send(:remove_method, name) + end +end From f4d302eea92354e045414a292c67c52d45c46603 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Mon, 10 Aug 2026 07:12:28 -0700 Subject: [PATCH 2/2] fix: observe the server version once The reasons list read the version for its comparison and again for its message, and the doctor read it a third time for the passing text. A transient failure on a later read would have replaced an already determined result with a generic verification error, and every healthy check paid extra round trips. One observed version now decides both status and message. --- lib/solid_objects/database_adapter.rb | 11 ++++++---- lib/solid_objects/doctor.rb | 5 +++-- .../lib/solid_objects/database_adapter.rbs | 6 ++++-- test/integration/database_version_test.rb | 20 +++++++++++++++++-- 4 files changed, 32 insertions(+), 10 deletions(-) diff --git a/lib/solid_objects/database_adapter.rb b/lib/solid_objects/database_adapter.rb index d1f51e1..5c3314f 100644 --- a/lib/solid_objects/database_adapter.rb +++ b/lib/solid_objects/database_adapter.rb @@ -47,12 +47,15 @@ def server_version end end - # @rbs () -> Array[String] - def unsupported_server_reasons + # One observed version decides both the status and the message. Reading it + # again could let a transient failure replace an already determined result. + # @rbs (?Gem::Version?) -> Array[String] + def unsupported_server_reasons(observed = nil) + observed ||= server_version reasons = [] minimum = minimum_server_version - if minimum && server_version < minimum - reasons << "#{self.class.name.demodulize} #{server_version} is older than " \ + if minimum && observed < minimum + reasons << "#{self.class.name.demodulize} #{observed} is older than " \ "Solid Objects requires, which is #{minimum}" end reasons.concat(additional_server_reasons) diff --git a/lib/solid_objects/doctor.rb b/lib/solid_objects/doctor.rb index eda7bbf..ee58bb8 100644 --- a/lib/solid_objects/doctor.rb +++ b/lib/solid_objects/doctor.rb @@ -195,12 +195,13 @@ def check_authorization # @rbs () -> Check def check_database_server adapter = SolidObjects.database_adapter - reasons = adapter.unsupported_server_reasons + observed = adapter.server_version + reasons = adapter.unsupported_server_reasons(observed) return warn_check(:database_server, reasons.join("; ")) unless reasons.empty? pass( :database_server, - "#{adapter.class.name.demodulize} #{adapter.server_version} meets the tested minimum" + "#{adapter.class.name.demodulize} #{observed} meets the tested minimum" ) rescue => error warn_check(:database_server, "#{error.class}: #{error.message}") diff --git a/sig/generated/lib/solid_objects/database_adapter.rbs b/sig/generated/lib/solid_objects/database_adapter.rbs index 17483a5..72d8362 100644 --- a/sig/generated/lib/solid_objects/database_adapter.rbs +++ b/sig/generated/lib/solid_objects/database_adapter.rbs @@ -25,8 +25,10 @@ module SolidObjects # @rbs () -> Gem::Version def server_version: () -> Gem::Version - # @rbs () -> Array[String] - def unsupported_server_reasons: () -> Array[String] + # One observed version decides both the status and the message. Reading it + # again could let a transient failure replace an already determined result. + # @rbs (?Gem::Version?) -> Array[String] + def unsupported_server_reasons: (?Gem::Version?) -> Array[String] # @rbs () -> Array[String] def additional_server_reasons: () -> Array[String] diff --git a/test/integration/database_version_test.rb b/test/integration/database_version_test.rb index 11b4051..6555ce3 100644 --- a/test/integration/database_version_test.rb +++ b/test/integration/database_version_test.rb @@ -25,6 +25,22 @@ class DatabaseVersionTest < ActiveSupport::TestCase restore(adapter, :with_connection) end + test "the observed version is read once for status and message" do + adapter = SolidObjects.database_adapter + reads = 0 + real = adapter.method(:server_version) + adapter.define_singleton_method(:server_version) { + reads += 1 + real.call + } + + SolidObjects::Doctor.new.call.check(:database_server) + + assert_equal 1, reads, "the doctor should observe the server version once" + ensure + restore(adapter, :server_version) + end + test "a supported server passes verification" do assert_empty SolidObjects.database_adapter.unsupported_server_reasons end @@ -60,7 +76,7 @@ class DatabaseVersionTest < ActiveSupport::TestCase test "the doctor warns about an unsupported server" do adapter = SolidObjects.database_adapter - adapter.define_singleton_method(:unsupported_server_reasons) { [ "too old" ] } + adapter.define_singleton_method(:unsupported_server_reasons) { |_observed = nil| [ "too old" ] } check = SolidObjects::Doctor.new.call.check(:database_server) @@ -72,7 +88,7 @@ class DatabaseVersionTest < ActiveSupport::TestCase test "an unsupported server does not fail the report" do adapter = SolidObjects.database_adapter - adapter.define_singleton_method(:unsupported_server_reasons) { [ "too old" ] } + adapter.define_singleton_method(:unsupported_server_reasons) { |_observed = nil| [ "too old" ] } assert SolidObjects::Doctor.new.call.healthy? ensure