Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
12 changes: 7 additions & 5 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 37 additions & 0 deletions lib/solid_objects/database_adapter.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions lib/solid_objects/database_adapters/mysql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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)"
Expand Down
15 changes: 15 additions & 0 deletions lib/solid_objects/database_adapters/postgresql.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions lib/solid_objects/database_adapters/sqlite.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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')"
Expand Down
16 changes: 16 additions & 0 deletions lib/solid_objects/doctor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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 :
Expand Down Expand Up @@ -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 -
Expand Down
17 changes: 17 additions & 0 deletions sig/generated/lib/solid_objects/database_adapter.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions sig/generated/lib/solid_objects/database_adapters/mysql.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions sig/generated/lib/solid_objects/database_adapters/sqlite.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions sig/generated/lib/solid_objects/doctor.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
124 changes: 124 additions & 0 deletions test/integration/database_version_test.rb
Original file line number Diff line number Diff line change
@@ -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
Loading