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..5c3314f 100644 --- a/lib/solid_objects/database_adapter.rb +++ b/lib/solid_objects/database_adapter.rb @@ -32,6 +32,43 @@ 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 + + # 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 && observed < minimum + reasons << "#{self.class.name.demodulize} #{observed} 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..ee58bb8 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,21 @@ 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 + 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} #{observed} 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..72d8362 100644 --- a/sig/generated/lib/solid_objects/database_adapter.rbs +++ b/sig/generated/lib/solid_objects/database_adapter.rbs @@ -16,6 +16,23 @@ 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 + + # 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] + # @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..6555ce3 --- /dev/null +++ b/test/integration/database_version_test.rb @@ -0,0 +1,124 @@ +# 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 "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 + + 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) { |_observed = nil| [ "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) { |_observed = nil| [ "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