Skip to content

Commit 3d49fed

Browse files
authored
Merge pull request #19 from cardmagic/agent/database-version-checks
feat: verify the database server at startup
2 parents ad7bf42 + f4d302e commit 3d49fed

13 files changed

Lines changed: 284 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. Compatibility CI across supported Rails and Ruby
90+
6. Expand security scanning. Compatibility CI across supported Rails and Ruby
8991
versions is implemented; Ruby 4.0 is not yet in the matrix.
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: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,43 @@ 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+
# One observed version decides both the status and the message. Reading it
51+
# again could let a transient failure replace an already determined result.
52+
# @rbs (?Gem::Version?) -> Array[String]
53+
def unsupported_server_reasons(observed = nil)
54+
observed ||= server_version
55+
reasons = []
56+
minimum = minimum_server_version
57+
if minimum && observed < minimum
58+
reasons << "#{self.class.name.demodulize} #{observed} is older than " \
59+
"Solid Objects requires, which is #{minimum}"
60+
end
61+
reasons.concat(additional_server_reasons)
62+
reasons
63+
rescue => error
64+
[ "the database server could not be verified: #{error.class}: #{error.message}" ]
65+
end
66+
67+
# @rbs () -> Array[String]
68+
def additional_server_reasons
69+
[]
70+
end
71+
3572
# @rbs () -> bool
3673
def supports_skip_locked?
3774
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: 16 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,21 @@ 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+
observed = adapter.server_version
199+
reasons = adapter.unsupported_server_reasons(observed)
200+
return warn_check(:database_server, reasons.join("; ")) unless reasons.empty?
201+
202+
pass(
203+
:database_server,
204+
"#{adapter.class.name.demodulize} #{observed} meets the tested minimum"
205+
)
206+
rescue => error
207+
warn_check(:database_server, "#{error.class}: #{error.message}")
208+
end
209+
194210
# @rbs () -> Check
195211
def check_runtime
196212
cutoff = SolidObjects.database_adapter.database_now -

sig/generated/lib/solid_objects/database_adapter.rbs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,23 @@ 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+
# One observed version decides both the status and the message. Reading it
29+
# again could let a transient failure replace an already determined result.
30+
# @rbs (?Gem::Version?) -> Array[String]
31+
def unsupported_server_reasons: (?Gem::Version?) -> Array[String]
32+
33+
# @rbs () -> Array[String]
34+
def additional_server_reasons: () -> Array[String]
35+
1936
# @rbs () -> bool
2037
def supports_skip_locked?: () -> bool
2138

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)