Skip to content

Commit c20a283

Browse files
committed
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.
1 parent 6d8cd60 commit c20a283

13 files changed

Lines changed: 262 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
- Verify the database server. Each adapter reports its version against the
6+
oldest one Solid Objects is exercised against, PostgreSQL 13, MySQL 8.0, and
7+
SQLite 3.35, and MySQL additionally confirms that Solid Objects tables use
8+
InnoDB, since a non-transactional engine would silently break fenced commits.
9+
The doctor reports this as `database_server` and warns rather than failing:
10+
refusing to run on an untested server would be a worse failure than running
11+
on one.
12+
313
## 0.8.0 - 2026-08-10
414

515
- Replace a supervised role whose thread died. A role that raised left its

docs/roadmap.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@
2424
- Reconciliation read APIs
2525
- Installation doctor, authorization reference, fit guide, and legacy-state
2626
migration cookbook
27+
- Database server verification: each adapter reports its version against a
28+
tested minimum, MySQL confirms Solid Objects tables use InnoDB, and the
29+
doctor warns rather than refusing to run on an untested server
2730
- Handler Active Record write isolation, same-database commit actions, ambient
2831
transaction rejection, adapter lock/query deadlines, bounded SQLite lock
2932
retries outside those deadlines, structured sync timeout diagnostics, and
@@ -81,13 +84,12 @@
8184
benchmark, and its concurrency tests are implemented.
8285
2. Add result lookup by request ID and broader deadlock retry classification.
8386
3. Add scheduled retention and stale-process maintenance.
84-
4. Add database/server-version checks and MySQL InnoDB verification at boot.
85-
5. Add Turbo append intents and expand reconnect coverage in a full browser.
86-
6. Add distributed rate limits, global admission hooks, and cache-capacity
87+
4. Add Turbo append intents and expand reconnect coverage in a full browser.
88+
5. Add distributed rate limits, global admission hooks, and cache-capacity
8789
eviction.
88-
7. Expand security scanning and run compatibility CI across supported Rails and
90+
6. Expand security scanning and run compatibility CI across supported Rails and
8991
Ruby versions.
90-
8. Benchmark all workloads under documented hardware/database settings and
92+
7. Benchmark all workloads under documented hardware/database settings and
9193
publish adapter-specific adoption measurements. Throughput, synchronous
9294
latency, query counts, and the three reactive delivery paths are measured on
9395
SQLite; adapter-specific and end-to-end browser measurements are not.

lib/solid_objects/database_adapter.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,40 @@ def initialize(connection)
3232
@fixed_connection = connection_pool ? nil : connection
3333
end
3434

35+
# The oldest server the adapter has been exercised against. Reported rather
36+
# than enforced: refusing to boot on an untested server would be a worse
37+
# failure than running on one.
38+
# @rbs () -> Gem::Version?
39+
def minimum_server_version
40+
nil
41+
end
42+
43+
# @rbs () -> Gem::Version
44+
def server_version
45+
with_connection do |connection|
46+
Gem::Version.new(connection.database_version.to_s)
47+
end
48+
end
49+
50+
# @rbs () -> Array[String]
51+
def unsupported_server_reasons
52+
reasons = []
53+
minimum = minimum_server_version
54+
if minimum && server_version < minimum
55+
reasons << "#{self.class.name.demodulize} #{server_version} is older than " \
56+
"Solid Objects requires, which is #{minimum}"
57+
end
58+
reasons.concat(additional_server_reasons)
59+
reasons
60+
rescue => error
61+
[ "the database server could not be verified: #{error.class}: #{error.message}" ]
62+
end
63+
64+
# @rbs () -> Array[String]
65+
def additional_server_reasons
66+
[]
67+
end
68+
3569
# @rbs () -> bool
3670
def supports_skip_locked?
3771
false

lib/solid_objects/database_adapters/mysql.rb

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,34 @@ def claim_lock
1313
"FOR UPDATE SKIP LOCKED"
1414
end
1515

16+
# A non-transactional engine would silently break fenced commits, so the
17+
# storage engine is verified rather than assumed.
18+
# @rbs () -> Array[String]
19+
def additional_server_reasons
20+
tables = non_innodb_tables
21+
return [] if tables.empty?
22+
23+
[ "these Solid Objects tables do not use InnoDB, so their commits are " \
24+
"not transactional: #{tables.join(", ")}" ]
25+
end
26+
27+
# @rbs () -> Array[String]
28+
def non_innodb_tables
29+
names = SolidObjects::Doctor::EXPECTED_COLUMNS.keys.map { |name| SolidObjects.table_name(name) }
30+
with_connection do |connection|
31+
connection.select_rows(<<~SQL.squish).filter_map { |table, engine| table if engine != "InnoDB" }
32+
SELECT table_name, engine FROM information_schema.tables
33+
WHERE table_schema = DATABASE()
34+
AND table_name IN (#{names.map { |name| connection.quote(name) }.join(", ")})
35+
SQL
36+
end
37+
end
38+
39+
# @rbs () -> Gem::Version?
40+
def minimum_server_version
41+
Gem::Version.new("8.0")
42+
end
43+
1644
# @rbs () -> String
1745
def current_time_expression
1846
"CURRENT_TIMESTAMP(6)"

lib/solid_objects/database_adapters/postgresql.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@
33
module SolidObjects
44
module DatabaseAdapters
55
class Postgresql < DatabaseAdapter
6+
# @rbs () -> Gem::Version?
7+
def minimum_server_version
8+
Gem::Version.new("13")
9+
end
10+
11+
# PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it
12+
# directly would make every server look newer than any minimum.
13+
# @rbs () -> Gem::Version
14+
def server_version
15+
packed = with_connection { |connection| connection.database_version.to_i }
16+
return super unless packed.positive?
17+
18+
Gem::Version.new("#{packed / 10_000}.#{packed % 10_000}")
19+
end
20+
621
# @rbs () -> bool
722
def supports_skip_locked?
823
true

lib/solid_objects/database_adapters/sqlite.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ class Sqlite < DatabaseAdapter
88
LOCK_RETRY_MUTEX = Thread::Mutex.new
99
LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
1010

11+
# @rbs () -> Gem::Version?
12+
def minimum_server_version
13+
Gem::Version.new("3.35")
14+
end
15+
1116
# @rbs () -> String
1217
def current_time_expression
1318
"STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"

lib/solid_objects/doctor.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ def call
111111
configuration_check,
112112
schema_check,
113113
check_authorization,
114+
check_database_server,
114115
schema_check.failed? ? skipped_runtime : check_runtime,
115116
ready_for_round_trip?(configuration_check, schema_check) ?
116117
check_sync_round_trip :
@@ -191,6 +192,20 @@ def check_authorization
191192
pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context")
192193
end
193194

195+
# @rbs () -> Check
196+
def check_database_server
197+
adapter = SolidObjects.database_adapter
198+
reasons = adapter.unsupported_server_reasons
199+
return warn_check(:database_server, reasons.join("; ")) unless reasons.empty?
200+
201+
pass(
202+
:database_server,
203+
"#{adapter.class.name.demodulize} #{adapter.server_version} meets the tested minimum"
204+
)
205+
rescue => error
206+
warn_check(:database_server, "#{error.class}: #{error.message}")
207+
end
208+
194209
# @rbs () -> Check
195210
def check_runtime
196211
cutoff = SolidObjects.database_adapter.database_now -

sig/generated/lib/solid_objects/database_adapter.rbs

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,21 @@ module SolidObjects
1616
# @rbs (untyped) -> void
1717
def initialize: (untyped) -> void
1818

19+
# The oldest server the adapter has been exercised against. Reported rather
20+
# than enforced: refusing to boot on an untested server would be a worse
21+
# failure than running on one.
22+
# @rbs () -> Gem::Version?
23+
def minimum_server_version: () -> Gem::Version?
24+
25+
# @rbs () -> Gem::Version
26+
def server_version: () -> Gem::Version
27+
28+
# @rbs () -> Array[String]
29+
def unsupported_server_reasons: () -> Array[String]
30+
31+
# @rbs () -> Array[String]
32+
def additional_server_reasons: () -> Array[String]
33+
1934
# @rbs () -> bool
2035
def supports_skip_locked?: () -> bool
2136

sig/generated/lib/solid_objects/database_adapters/mysql.rbs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,17 @@ module SolidObjects
99
# @rbs () -> String
1010
def claim_lock: () -> String
1111

12+
# A non-transactional engine would silently break fenced commits, so the
13+
# storage engine is verified rather than assumed.
14+
# @rbs () -> Array[String]
15+
def additional_server_reasons: () -> Array[String]
16+
17+
# @rbs () -> Array[String]
18+
def non_innodb_tables: () -> Array[String]
19+
20+
# @rbs () -> Gem::Version?
21+
def minimum_server_version: () -> Gem::Version?
22+
1223
# @rbs () -> String
1324
def current_time_expression: () -> String
1425

sig/generated/lib/solid_objects/database_adapters/postgresql.rbs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@
33
module SolidObjects
44
module DatabaseAdapters
55
class Postgresql < DatabaseAdapter
6+
# @rbs () -> Gem::Version?
7+
def minimum_server_version: () -> Gem::Version?
8+
9+
# PostgreSQL reports a packed integer, 170010 for 17.10, so comparing it
10+
# directly would make every server look newer than any minimum.
11+
# @rbs () -> Gem::Version
12+
def server_version: () -> Gem::Version
13+
614
# @rbs () -> bool
715
def supports_skip_locked?: () -> bool
816

0 commit comments

Comments
 (0)