solid_objects 0.7.3 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/docs/benchmarks.md +15 -0
  4. data/docs/local-testing.md +91 -0
  5. data/docs/realtime.md +43 -0
  6. data/docs/roadmap.md +35 -26
  7. data/lib/generators/solid_objects/templates/solid_objects.rb +8 -0
  8. data/lib/solid_objects/configuration.rb +10 -0
  9. data/lib/solid_objects/database_adapter.rb +37 -0
  10. data/lib/solid_objects/database_adapters/mysql.rb +28 -0
  11. data/lib/solid_objects/database_adapters/postgresql.rb +15 -0
  12. data/lib/solid_objects/database_adapters/sqlite.rb +11 -4
  13. data/lib/solid_objects/doctor.rb +16 -0
  14. data/lib/solid_objects/supervisor.rb +125 -6
  15. data/lib/solid_objects/version.rb +1 -1
  16. data/lib/solid_objects/wake_up_adapters/postgresql.rb +136 -0
  17. data/lib/solid_objects/wake_up_adapters/redis.rb +183 -0
  18. data/lib/solid_objects/wake_up_adapters.rb +23 -0
  19. data/lib/solid_objects.rb +3 -0
  20. data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
  21. data/sig/generated/lib/solid_objects/database_adapter.rbs +17 -0
  22. data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +11 -0
  23. data/sig/generated/lib/solid_objects/database_adapters/postgresql.rbs +8 -0
  24. data/sig/generated/lib/solid_objects/database_adapters/sqlite.rbs +5 -0
  25. data/sig/generated/lib/solid_objects/doctor.rbs +3 -0
  26. data/sig/generated/lib/solid_objects/supervisor.rbs +45 -2
  27. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +75 -0
  28. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +96 -0
  29. data/sig/generated/lib/solid_objects/wake_up_adapters.rbs +17 -0
  30. metadata +9 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: de78ca94f2c12e3c08d50c7b2512d6dbda67a47571540390c7494c6a828f1d35
4
- data.tar.gz: b194c137ff3110cabb6cb2f7319819fdd64f314a9ae2113868438a89acce36b8
3
+ metadata.gz: b7fee6db10af36996dbe9c8484fab405b53e2463a3fd7d2f7414310fedab68d5
4
+ data.tar.gz: 8b71bb8fbf0dc0359ce8a00097428dbbcd189e4d2b4071ba0339b9c3ec1cd933
5
5
  SHA512:
6
- metadata.gz: 78834ecb346469854b6d21d938fe3a6ed2689cd60cb037b22b3ce00025466c172ceb3dacdfcf94f88b0e0739e3dcac1c3e84896c6685bc4fe53d4db565ab8513
7
- data.tar.gz: 31fbae44dce542e1fc07fe218bf504eb8627583ecc64895e8d86a5805340aeea47531b5f08a6e844809c31b513998c53c28b90993ae9cd40b864e868323eee27
6
+ metadata.gz: 62f2ce7ecfa4899f82af3682c5a8fb97d7005e6ba9152a8a63433b2ec345d2edc3d795c29a7f6de24d25f275afd4857986ed34302359d0f3aab018f936656acd
7
+ data.tar.gz: 2114e1d8b7757bf598562fba6e1b8ecafeea852d6925a5161f74b1dcab6dab9b5750ca71327b0cfa23b927869cbeee348f1bd6d03cbfc5d964d2bfd53a625435
data/CHANGELOG.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 - 2026-08-10
4
+
5
+ - Add a browser test suite running the refresh modules against real Chromium and
6
+ a real Turbo build, covering `component_refresh.js`, which previously had no
7
+ tests at all. Every batching defect that reached production passed the jsdom
8
+ suite, because jsdom cannot model Turbo applying a morph, task boundaries
9
+ between socket deliveries, or abort semantics.
10
+ - Verify the database server. Each adapter reports its version against the
11
+ oldest one Solid Objects is exercised against, PostgreSQL 13, MySQL 8.0, and
12
+ SQLite 3.35, and MySQL additionally confirms that Solid Objects tables use
13
+ InnoDB, since a non-transactional engine would silently break fenced commits.
14
+ The doctor reports this as `database_server` and warns rather than failing:
15
+ refusing to run on an untested server would be a worse failure than running
16
+ on one.
17
+ - Add `SolidObjects::WakeUpAdapters::Redis`, an optional cross-process wake-up
18
+ using Redis publish and subscribe. This is the option for MySQL, which has no
19
+ notification primitive. Measured cross-process wake-up latency drops from
20
+ 103.8 ms to 5.7 ms at p50. One background subscription per process fans out to
21
+ every waiting role in memory. The `redis` gem is not a dependency of this gem,
22
+ and `WakeUpAdapters.for` does not select it, so adopting Redis stays explicit.
23
+
24
+ ## 0.8.0 - 2026-08-10
25
+
26
+ - Replace a supervised role whose thread died. A role that raised left its
27
+ thread dead while the process kept running and quietly did less work; the
28
+ supervisor now restarts it until shutdown is requested. Prune dead process
29
+ records on an interval as part of the same monitor. Both intervals are
30
+ configurable through `supervisor_monitor_interval` and
31
+ `dead_process_cleanup_interval`.
32
+ - Run compatibility CI across the span the gemspec advertises: Ruby 3.3 and 3.4
33
+ against Rails 8.0 and 8.1. The suite previously ran on one combination, so
34
+ `>= 8.0` was a claim rather than a tested guarantee. Set `RAILS_VERSION` to
35
+ pin a Rails line locally.
36
+ - Stop a synchronous lock retry from asking for a negative wait when its
37
+ deadline expires between the check and the wait, which raised
38
+ `ArgumentError: time interval must not be negative` instead of the timeout
39
+ the caller expected. Found by the new compatibility matrix.
40
+ - Add `SolidObjects::WakeUpAdapters::Postgresql`, an optional cross-process
41
+ wake-up using PostgreSQL notifications. In-process signalling cannot reach a
42
+ worker process, so reactive delivery waited out `polling_interval`. With the
43
+ adapter configured, measured cross-process wake-up latency drops from 103.7 ms
44
+ to 2.9 ms at p50. The polling interval remains the upper bound, and neither
45
+ signalling nor waiting raises into its caller. `WakeUpAdapters.for` selects
46
+ notifications on PostgreSQL and the in-process default elsewhere; it is not
47
+ the default, because the adapter opens a connection per waiting thread
48
+ outside the pool and `LISTEN` does not survive a transaction-pooling proxy.
49
+
3
50
  ## 0.7.3 - 2026-08-09
4
51
 
5
52
  - Coordinate batched component refreshes by revision as well as scope and batch
data/docs/benchmarks.md CHANGED
@@ -76,6 +76,21 @@ Cable delivery, or browser rendering, which dominate wall-clock time in a real
76
76
  deployment and make the request-count difference matter more than it appears
77
77
  here. End-to-end latency against a deployed application has not been measured.
78
78
 
79
+ ## Cross-process wake-up
80
+
81
+ Measured 2026-08-09 against PostgreSQL 17, 30 samples, with `polling_interval`
82
+ at its 100 ms default and a signal sent 2 ms after the waiter began.
83
+
84
+ | Wake-up strategy | p50 | p95 |
85
+ | --- | ---: | ---: |
86
+ | In-process `WakeUp` | 103.7 ms | 105.1 ms |
87
+ | `WakeUpAdapters::Postgresql` | 2.9 ms | 5.1 ms |
88
+
89
+ The in-process wake-up cannot reach another process, so a worker waits out the
90
+ full polling interval no matter how quickly the web process committed. The
91
+ notification adapter removes that floor rather than shrinking it, and the
92
+ polling interval remains the upper bound if a notification is missed.
93
+
79
94
  ## Durable row growth
80
95
 
81
96
  The storage cost is deterministic even when latency is not:
@@ -0,0 +1,91 @@
1
+ # Local testing
2
+
3
+ The default suite runs against SQLite and needs nothing extra:
4
+
5
+ ```bash
6
+ bundle exec rake
7
+ ```
8
+
9
+ Everything below is optional. Each adapter and each optional service skips its
10
+ tests when the service is absent, so a missing container degrades coverage
11
+ rather than breaking the run. That is convenient, and it is also a trap: a
12
+ skipped test looks identical to a passing one in the summary line. Check the
13
+ skip count when a change touches an adapter.
14
+
15
+ ## PostgreSQL
16
+
17
+ ```bash
18
+ brew services start postgresql@17
19
+ createuser -h 127.0.0.1 -s solid_objects
20
+ psql -h 127.0.0.1 -d postgres -c "ALTER USER solid_objects WITH PASSWORD 'solid_objects';"
21
+ createdb -h 127.0.0.1 -O solid_objects solid_objects_test
22
+
23
+ SOLID_OBJECTS_DATABASE_URL=postgresql://solid_objects:solid_objects@127.0.0.1:5432/solid_objects_test \
24
+ bundle exec rake test
25
+ ```
26
+
27
+ Running this locally is worth the setup: it is what caught the PostgreSQL
28
+ version comparison reading a packed integer, where `170010` compared greater
29
+ than any minimum and made the check useless on the adapter it mattered most for.
30
+
31
+ ## MySQL and Redis in Docker
32
+
33
+ These use non-default ports so they cannot collide with a MySQL or Redis that
34
+ another project is already running:
35
+
36
+ ```bash
37
+ docker run -d --name so-mysql -p 3307:3306 \
38
+ -e MYSQL_ROOT_PASSWORD=solid_objects \
39
+ -e MYSQL_DATABASE=solid_objects_test \
40
+ -e MYSQL_USER=solid_objects \
41
+ -e MYSQL_PASSWORD=solid_objects \
42
+ mysql:8
43
+
44
+ docker run -d --name so-redis -p 6380:6379 redis:7-alpine
45
+ ```
46
+
47
+ ```bash
48
+ SOLID_OBJECTS_DATABASE_URL=mysql2://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
49
+ bundle exec rake test
50
+
51
+ SOLID_OBJECTS_REDIS_URL=redis://127.0.0.1:6380/15 \
52
+ bundle exec rake test TEST=test/integration/redis_wake_up_test.rb
53
+ ```
54
+
55
+ Stop them with `docker rm -f so-mysql so-redis`.
56
+
57
+ ## Recreating a database between runs
58
+
59
+ The test helper migrates unconditionally, so a second run against a database
60
+ that already has the tables fails with a duplicate-table error rather than a
61
+ test failure. Recreate first:
62
+
63
+ ```bash
64
+ dropdb -h 127.0.0.1 solid_objects_test && createdb -h 127.0.0.1 -O solid_objects solid_objects_test
65
+
66
+ docker exec so-mysql mysql -u root -psolid_objects \
67
+ -e "DROP DATABASE IF EXISTS solid_objects_test; CREATE DATABASE solid_objects_test;
68
+ GRANT ALL ON solid_objects_test.* TO 'solid_objects'@'%';"
69
+ ```
70
+
71
+ ## Rails and Ruby span
72
+
73
+ `RAILS_VERSION` pins the Rails line the gemspec advertises:
74
+
75
+ ```bash
76
+ RAILS_VERSION=8.0 bundle install
77
+ RAILS_VERSION=8.0 bundle exec rake test
78
+ ```
79
+
80
+ ## Browser modules
81
+
82
+ ```bash
83
+ npm install
84
+ npm test # jsdom, fast
85
+ npx playwright install chromium
86
+ npm run test:browser # real Chromium and a real Turbo build
87
+ ```
88
+
89
+ The jsdom suite covers logic; the browser suite covers integration with Turbo.
90
+ Both matter: every batching defect that reached production passed the jsdom
91
+ suite alone.
data/docs/realtime.md CHANGED
@@ -110,6 +110,49 @@ applications discover the namespaced engine asset. Applications created with
110
110
  explicitly serve the module. Turbo's normal morph rules still apply; use
111
111
  `data-turbo-permanent` for elements that must never be changed.
112
112
 
113
+ ## Cross-process wake-up
114
+
115
+ Runtime roles poll for work and are woken early by an in-process signal. That
116
+ signal cannot cross process boundaries, so a commit in a Puma process does not
117
+ wake a broadcast executor in a worker process, and delivery waits out
118
+ `polling_interval`, 100 ms by default.
119
+
120
+ On PostgreSQL, install the notification adapter to remove that delay:
121
+
122
+ ```ruby
123
+ # config/initializers/solid_objects.rb
124
+ configuration.wake_up_adapter = SolidObjects::WakeUpAdapters.for
125
+ ```
126
+
127
+ `WakeUpAdapters.for` returns notifications on PostgreSQL and the in-process
128
+ default on SQLite and MySQL, so the same line is safe across adapters. Name
129
+ `SolidObjects::WakeUpAdapters::Postgresql.new` directly to require it.
130
+
131
+ MySQL has no notification primitive. MySQL applications either keep polling and
132
+ tune `polling_interval`, or configure the Redis adapter:
133
+
134
+ ```ruby
135
+ configuration.wake_up_adapter = SolidObjects::WakeUpAdapters::Redis.new(
136
+ url: ENV["REDIS_URL"]
137
+ )
138
+ ```
139
+
140
+ Measured latency for a cross-process wake-up drops from 103.8 ms to 5.7 ms at
141
+ p50. The `redis` gem is not a dependency of this gem, so applications add it
142
+ themselves. One background subscription per process fans out to every waiting
143
+ role in memory, rather than one connection per thread, and `WakeUpAdapters.for`
144
+ does not select it: Redis is infrastructure this gem otherwise does not require,
145
+ so choosing it is explicit.
146
+
147
+ Measured latency for a cross-process wake-up drops from 103.7 ms to 2.9 ms at
148
+ p50. The adapter keeps `polling_interval` as the upper bound: a missed or failed
149
+ notification costs latency, never correctness, and signalling never raises into
150
+ the caller that committed. `LISTEN` needs its own connection, so the adapter
151
+ opens one outside the pool and releases it on `stop`.
152
+
153
+ Applications on SQLite or MySQL, or that do not configure the adapter, keep the
154
+ existing polling behaviour.
155
+
113
156
  ## Batched component refreshes
114
157
 
115
158
  A component refresh costs one browser request. When one actor mutation changes
data/docs/roadmap.md CHANGED
@@ -24,6 +24,9 @@
24
24
  - Reconciliation read APIs
25
25
  - Installation doctor, authorization reference, fit guide, and legacy-state
26
26
  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
27
30
  - Handler Active Record write isolation, same-database commit actions, ambient
28
31
  transaction rejection, adapter lock/query deadlines, bounded SQLite lock
29
32
  retries outside those deadlines, structured sync timeout diagnostics, and
@@ -31,23 +34,37 @@
31
34
  - Bounded message/process pruning, actor-type opt-in instance expiration,
32
35
  graceful caller shutdown, committed state snapshots, and an opt-in Minitest
33
36
  helper
37
+ - Supervisor role replacement: a role whose thread dies is restarted until
38
+ shutdown is requested, and dead process records are pruned on an interval
34
39
  - SQLite, PostgreSQL, and MySQL integration suites
40
+ - Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
41
+ a listening connection per waiting thread and release on supervisor shutdown
42
+ - Opt-in cross-process wake-up on Redis, the option for MySQL applications,
43
+ measured at 103.8 ms to 5.7 ms at p50; the `redis` gem stays outside this
44
+ gem's dependencies
35
45
  - Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
36
46
  RuboCop policy, and a warning-free Brakeman scan
37
- - A JavaScript suite covering the state payload and batched refresh browser
38
- modules, run in CI with Node's test runner and jsdom, with every GitHub
39
- Actions reference pinned to a commit SHA
47
+ - Compatibility CI across the supported span: Ruby 3.3 and 3.4 against Rails 8.0
48
+ and 8.1, pinned through `RAILS_VERSION` so the advertised range is verified
49
+ rather than assumed
50
+ - A JavaScript suite covering every browser module, run in CI with Node's test
51
+ runner and jsdom, plus a browser suite running the same modules against real
52
+ Chromium and a real Turbo build, with every GitHub Actions reference pinned to
53
+ a commit SHA
40
54
 
41
55
  ## Partially implemented
42
56
 
43
- - Supervisor: starts and drains thread roles, but does not replace a crashed
44
- role or run periodic maintenance automatically.
45
- - Wake-up strategy: in-process signaling plus durable polling and injection are
46
- implemented; PostgreSQL `LISTEN/NOTIFY` and optional Redis adapters are not.
47
- Signaling cannot cross process boundaries, so a commit in a web process does
48
- not wake a broadcast executor in a worker process; that delivery waits up to
49
- `polling_interval`, 100 ms by default. This is the largest remaining term in
50
- reactive update latency, and neither batching nor state payloads reduce it.
57
+ - Wake-up strategy: in-process signaling, durable polling, injection, and an
58
+ opt-in PostgreSQL notification adapter are implemented; a Redis adapter is
59
+ not. In-process signaling cannot cross process boundaries, so without the
60
+ adapter a commit in a web process does not wake a broadcast executor in a
61
+ worker process and that delivery waits up to `polling_interval`, 100 ms by
62
+ default. `WakeUpAdapters.for` removes that delay on PostgreSQL, measured at
63
+ 103.7 ms to 2.9 ms at p50. It is opt-in rather than automatic: it opens a
64
+ connection per waiting thread outside the pool, and `LISTEN` does not survive
65
+ a transaction-pooling proxy such as PgBouncer. MySQL has no notification
66
+ primitive, so MySQL applications keep polling unless they configure the Redis
67
+ adapter.
51
68
  - Realtime: scalar and dependency-driven keyed ERB component replacement or
52
69
  morphing, personalized refresh authorization, revision fencing, coalescing,
53
70
  reconnect convergence, batched refreshes, and personalized state payloads are
@@ -59,27 +76,19 @@
59
76
  distributed per-actor rate limits and global admission control do not.
60
77
  - Administration: actor and dead-letter views plus policy hooks exist; richer
61
78
  filtering, audit records, and bulk-safe tools do not.
62
- - Browser module coverage: the state payload and batched refresh modules have
63
- JavaScript tests; `component_refresh.js`, which drives individual morph
64
- refreshes, does not.
65
79
  - Outboxes use portable status rows with polling indexes; future versions may
66
80
  introduce narrow ready/claimed membership tables for very large outboxes.
67
81
 
68
82
  ## Next milestones
69
83
 
70
- 1. Add automatic supervisor role replacement and periodic dead-process cleanup.
71
- 2. Add PostgreSQL notification and optional Redis wake-up adapters with latency
72
- benchmarks and polling-race tests, removing the cross-process polling delay
73
- rather than shrinking it with a smaller `polling_interval`.
74
- 3. Add result lookup by request ID and broader deadlock retry classification.
75
- 4. Add scheduled retention and stale-process maintenance.
76
- 5. Add database/server-version checks and MySQL InnoDB verification at boot.
77
- 6. Add Turbo append intents and expand reconnect coverage in a full browser.
78
- 7. Add distributed rate limits, global admission hooks, and cache-capacity
84
+ 1. Add result lookup by request ID and broader deadlock retry classification.
85
+ 2. Add scheduled retention and stale-process maintenance.
86
+ 3. Add Turbo append intents and expand reconnect coverage in a full browser.
87
+ 4. Add distributed rate limits, global admission hooks, and cache-capacity
79
88
  eviction.
80
- 8. Expand security scanning and run compatibility CI across supported Rails and
81
- Ruby versions.
82
- 9. Benchmark all workloads under documented hardware/database settings and
89
+ 5. Expand security scanning. Compatibility CI across supported Rails and Ruby
90
+ versions is implemented; Ruby 4.0 is not yet in the matrix.
91
+ 6. Benchmark all workloads under documented hardware/database settings and
83
92
  publish adapter-specific adoption measurements. Throughput, synchronous
84
93
  latency, query counts, and the three reactive delivery paths are measured on
85
94
  SQLite; adapter-specific and end-to-end browser measurements are not.
@@ -58,4 +58,12 @@ SolidObjects.configure do |configuration|
58
58
  # configuration.authorize_administration = lambda do |authorization_context:, **|
59
59
  # authorization_context.is_a?(Hash) && authorization_context[:source] == "cli"
60
60
  # end
61
+
62
+ # Runtime roles poll for work and are woken early by an in-process signal,
63
+ # which cannot reach another process. On PostgreSQL, notifications remove that
64
+ # delay. This opens a connection per waiting thread outside the pool and does
65
+ # not work through a transaction-pooling proxy such as PgBouncer, so it is
66
+ # opt-in:
67
+ #
68
+ # configuration.wake_up_adapter = SolidObjects::WakeUpAdapters.for
61
69
  end
@@ -21,6 +21,8 @@ module SolidObjects
21
21
  # @rbs @process_heartbeat_interval: Float
22
22
  # @rbs @process_alive_threshold: Float
23
23
  # @rbs @shutdown_timeout: Float
24
+ # @rbs @supervisor_monitor_interval: Float
25
+ # @rbs @dead_process_cleanup_interval: Float
24
26
  # @rbs @message_retention: Numeric
25
27
  # @rbs @message_retention_by_actor_type: Hash[String, Numeric]
26
28
  # @rbs @instance_retention_by_actor_type: Hash[String, Numeric]
@@ -62,6 +64,8 @@ module SolidObjects
62
64
  :process_heartbeat_interval,
63
65
  :process_alive_threshold,
64
66
  :shutdown_timeout,
67
+ :supervisor_monitor_interval,
68
+ :dead_process_cleanup_interval,
65
69
  :message_retention,
66
70
  :message_retention_by_actor_type,
67
71
  :instance_retention_by_actor_type,
@@ -102,6 +106,8 @@ module SolidObjects
102
106
  @max_attempts = 5
103
107
  @retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
104
108
  @lock_retry_attempts = 10
109
+ @supervisor_monitor_interval = 1.0
110
+ @dead_process_cleanup_interval = 60.0
105
111
  @process_heartbeat_interval = 15.0
106
112
  @process_alive_threshold = 60.0
107
113
  @shutdown_timeout = 15.0
@@ -138,6 +144,10 @@ module SolidObjects
138
144
  raise ArgumentError, "table_name_prefix must contain lowercase letters, digits, and underscores"
139
145
  end
140
146
 
147
+ unless supervisor_monitor_interval.positive?
148
+ raise ArgumentError, "supervisor_monitor_interval must be positive"
149
+ end
150
+
141
151
  unless lease_duration > lease_renewal_interval
142
152
  raise ArgumentError, "lease_duration must be greater than lease_renewal_interval"
143
153
  end
@@ -32,6 +32,43 @@ module SolidObjects
32
32
  @fixed_connection = connection_pool ? nil : connection
33
33
  end
34
34
 
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
+
35
72
  # @rbs () -> bool
36
73
  def supports_skip_locked?
37
74
  false
@@ -13,6 +13,34 @@ module SolidObjects
13
13
  "FOR UPDATE SKIP LOCKED"
14
14
  end
15
15
 
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
+
16
44
  # @rbs () -> String
17
45
  def current_time_expression
18
46
  "CURRENT_TIMESTAMP(6)"
@@ -3,6 +3,21 @@
3
3
  module SolidObjects
4
4
  module DatabaseAdapters
5
5
  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
+
6
21
  # @rbs () -> bool
7
22
  def supports_skip_locked?
8
23
  true
@@ -8,6 +8,11 @@ module SolidObjects
8
8
  LOCK_RETRY_MUTEX = Thread::Mutex.new
9
9
  LOCK_RETRY_CONDITION = Thread::ConditionVariable.new
10
10
 
11
+ # @rbs () -> Gem::Version?
12
+ def minimum_server_version
13
+ Gem::Version.new("3.35")
14
+ end
15
+
11
16
  # @rbs () -> String
12
17
  def current_time_expression
13
18
  "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
@@ -160,13 +165,15 @@ module SolidObjects
160
165
  end
161
166
  end
162
167
 
168
+ # The deadline can expire between the check above and this wait, which
169
+ # would otherwise ask for a negative interval.
163
170
  # @rbs () -> void
164
171
  def wait_before_retry
172
+ interval = [ LOCK_RETRY_INTERVAL, SyncDeadline.remaining ].min
173
+ return unless interval.positive?
174
+
165
175
  LOCK_RETRY_MUTEX.synchronize do
166
- LOCK_RETRY_CONDITION.wait(
167
- LOCK_RETRY_MUTEX,
168
- [ LOCK_RETRY_INTERVAL, SyncDeadline.remaining ].min
169
- )
176
+ LOCK_RETRY_CONDITION.wait(LOCK_RETRY_MUTEX, interval)
170
177
  end
171
178
  end
172
179
  end
@@ -111,6 +111,7 @@ module SolidObjects
111
111
  configuration_check,
112
112
  schema_check,
113
113
  check_authorization,
114
+ check_database_server,
114
115
  schema_check.failed? ? skipped_runtime : check_runtime,
115
116
  ready_for_round_trip?(configuration_check, schema_check) ?
116
117
  check_sync_round_trip :
@@ -191,6 +192,21 @@ module SolidObjects
191
192
  pass(:authorization, "#{allowed.length} of 5 policies allowed a neutral context")
192
193
  end
193
194
 
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
+
194
210
  # @rbs () -> Check
195
211
  def check_runtime
196
212
  cutoff = SolidObjects.database_adapter.database_now -