Skip to content

Commit 041f0d5

Browse files
committed
perf: cut database round trips per sync call
Read the database clock once per transaction rather than once per step, and resolve the SQLite busy wait from the pool configuration rather than querying the connection for it. A synchronous call now issues 49 queries instead of 66, which matters most on PostgreSQL and MySQL where every query is a network round trip. Suspension still writes PRAGMA busy_timeout, because clearing the handler in Ruby instead changes where contention fails: BEGIN IMMEDIATE fails outright rather than the statement inside it.
1 parent cb873fb commit 041f0d5

7 files changed

Lines changed: 130 additions & 10 deletions

File tree

CHANGELOG.md

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

3+
## Unreleased
4+
5+
- Read the database clock once per transaction instead of once per step, and
6+
resolve the SQLite busy wait from configuration instead of querying the
7+
connection for it. A synchronous call now issues 49 database queries instead
8+
of 66, which matters most on PostgreSQL and MySQL where every query is a
9+
network round trip.
10+
- Apply every migration in the benchmark harness. It applied only the initial
11+
migration, so the `state_revision` column added in 0.4.0 was missing, every
12+
message failed at commit, and the synchronous benchmarks timed out.
13+
314
## 0.5.1 - 2026-08-07
415

516
- Restore the SQLite busy wait that a synchronous invocation suspends for its

docs/benchmarks.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,13 @@ scenario used four worker threads.
4141
| Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
4242
| Activation reuse | 98.0%, four activations for 200 messages |
4343
| Queries for one message turn | 29 |
44+
| Queries for one synchronous call | 49 |
45+
46+
A synchronous call costs far more queries than a worker turn because the caller
47+
also registers or heartbeats its caller process, claims the activation, and
48+
observes the result. Query count, not query time, dominates synchronous latency
49+
on a networked database: measured locally against SQLite, database time is
50+
roughly 5% of a call and the remaining 95% is Ruby.
4451

4552
The difference between the SQLite development result and the MySQL adoption
4653
result is why Solid Objects does not publish one latency promise. Network

lib/solid_objects/database_adapter.rb

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
module SolidObjects
66
class DatabaseAdapter
7+
TRANSACTION_CLOCK = :solid_objects_transaction_clock
8+
TRANSACTION_CLOCK_SCOPE = :solid_objects_transaction_clock_scope
9+
710
class << self
811
# @rbs (untyped) -> DatabaseAdapter
912
def for(connection)
@@ -46,10 +49,9 @@ def current_time_expression
4649

4750
# @rbs () -> Time
4851
def database_now
49-
value = with_connection do |connection|
50-
connection.select_value("SELECT #{current_time_expression}")
51-
end
52-
value.is_a?(Time) ? value.utc : Time.parse("#{value} UTC").utc
52+
return read_database_now unless ActiveSupport::IsolatedExecutionState[TRANSACTION_CLOCK_SCOPE]
53+
54+
ActiveSupport::IsolatedExecutionState[TRANSACTION_CLOCK] ||= read_database_now
5355
end
5456

5557
# @rbs () { () -> untyped } -> untyped
@@ -70,7 +72,7 @@ def transaction(&block)
7072
with_transaction_deadline(connection) do
7173
connection.transaction(requires_new: true) do
7274
configure_transaction_deadline(connection)
73-
block.call
75+
with_transaction_clock { block.call }
7476
end
7577
end
7678
end
@@ -91,6 +93,27 @@ def lock_candidates(relation)
9193

9294
attr_reader :connection_pool, :fixed_connection
9395

96+
# @rbs () { () -> untyped } -> untyped
97+
def with_transaction_clock
98+
return yield if ActiveSupport::IsolatedExecutionState[TRANSACTION_CLOCK_SCOPE]
99+
100+
ActiveSupport::IsolatedExecutionState[TRANSACTION_CLOCK_SCOPE] = true
101+
begin
102+
yield
103+
ensure
104+
ActiveSupport::IsolatedExecutionState[TRANSACTION_CLOCK_SCOPE] = false
105+
ActiveSupport::IsolatedExecutionState[TRANSACTION_CLOCK] = nil
106+
end
107+
end
108+
109+
# @rbs () -> Time
110+
def read_database_now
111+
value = with_connection do |connection|
112+
connection.select_value("SELECT #{current_time_expression}")
113+
end
114+
value.is_a?(Time) ? value.utc : Time.parse("#{value} UTC").utc
115+
end
116+
94117
# @rbs (untyped) { () -> untyped } -> untyped
95118
def with_transaction_deadline(_connection)
96119
yield

lib/solid_objects/database_adapters/sqlite.rb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ def with_transaction_deadline(connection)
7171
return yield unless busy_wait
7272

7373
begin
74-
connection.execute("PRAGMA busy_timeout = 0")
74+
suspend_busy_wait(connection, busy_wait)
7575
yield
7676
ensure
7777
restore_busy_wait(connection, busy_wait)
@@ -80,13 +80,18 @@ def with_transaction_deadline(connection)
8080

8181
# @rbs (untyped) -> Hash[Symbol, untyped]?
8282
def restorable_busy_wait(connection)
83+
handler_timeout = configured_busy_handler_timeout(connection)
84+
return { handler_timeout: } if handler_timeout
85+
8386
pragma_timeout = connection.select_value("PRAGMA busy_timeout").to_i
84-
return { pragma_timeout: } if pragma_timeout.positive?
87+
return nil unless pragma_timeout.positive?
8588

86-
handler_timeout = configured_busy_handler_timeout(connection)
87-
return nil unless handler_timeout
89+
{ pragma_timeout: }
90+
end
8891

89-
{ pragma_timeout:, handler_timeout: }
92+
# @rbs (untyped, Hash[Symbol, untyped]) -> void
93+
def suspend_busy_wait(connection, _busy_wait)
94+
connection.execute("PRAGMA busy_timeout = 0")
9095
end
9196

9297
# @rbs (untyped, Hash[Symbol, untyped]) -> void

sig/generated/lib/solid_objects/database_adapter.rbs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
module SolidObjects
44
class DatabaseAdapter
5+
TRANSACTION_CLOCK: ::Symbol
6+
7+
TRANSACTION_CLOCK_SCOPE: ::Symbol
8+
59
# @rbs (untyped) -> DatabaseAdapter
610
def self.for: (untyped) -> DatabaseAdapter
711

@@ -42,6 +46,12 @@ module SolidObjects
4246

4347
attr_reader fixed_connection: untyped
4448

49+
# @rbs () { () -> untyped } -> untyped
50+
def with_transaction_clock: () { () -> untyped } -> untyped
51+
52+
# @rbs () -> Time
53+
def read_database_now: () -> Time
54+
4555
# @rbs (untyped) { () -> untyped } -> untyped
4656
def with_transaction_deadline: (untyped) { () -> untyped } -> untyped
4757

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ module SolidObjects
2929
# @rbs (untyped) -> Hash[Symbol, untyped]?
3030
def restorable_busy_wait: (untyped) -> Hash[Symbol, untyped]?
3131

32+
# @rbs (untyped, Hash[Symbol, untyped]) -> void
33+
def suspend_busy_wait: (untyped, Hash[Symbol, untyped]) -> void
34+
3235
# @rbs (untyped, Hash[Symbol, untyped]) -> void
3336
def restore_busy_wait: (untyped, Hash[Symbol, untyped]) -> void
3437

test/integration/synchronous_invocation_test.rb

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -502,6 +502,56 @@ def wait(timeout:)
502502
release_sqlite_write_lock(lock) if lock
503503
end
504504

505+
test "sync does not re-read the SQLite busy wait it already knows how to restore" do
506+
skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i)
507+
CounterActor.ref("pragma-warm").increment
508+
statements = []
509+
subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
510+
statements << event.payload[:sql]
511+
end
512+
513+
CounterActor.ref("pragma-warm").increment
514+
515+
ActiveSupport::Notifications.unsubscribe(subscription)
516+
assert_empty statements.grep(/\APRAGMA busy_timeout\z/i),
517+
"the configured busy wait is known without asking the database for it"
518+
refute_empty statements.grep(/\APRAGMA busy_timeout = 0\z/i),
519+
"the busy wait must still be suspended for the deadline"
520+
end
521+
522+
test "a transaction reads the database clock once and shares the reading" do
523+
readings = []
524+
clock_reads = count_clock_reads do
525+
SolidObjects.database_adapter.transaction do
526+
3.times { readings << SolidObjects.database_adapter.database_now }
527+
end
528+
end
529+
530+
assert_equal 1, clock_reads
531+
assert_equal 1, readings.uniq.length
532+
end
533+
534+
test "the shared clock reading does not outlive its transaction" do
535+
reads = count_clock_reads do
536+
2.times do
537+
SolidObjects.database_adapter.transaction { SolidObjects.database_adapter.database_now }
538+
end
539+
end
540+
541+
assert_equal 2, reads
542+
assert_equal 1, count_clock_reads { SolidObjects.database_adapter.database_now }
543+
end
544+
545+
test "sync stops re-reading the database clock for every step" do
546+
reference = CounterActor.ref("clock-warm")
547+
reference.increment
548+
549+
clock_reads = count_clock_reads { reference.increment }
550+
551+
assert_operator clock_reads, :<=, 4,
552+
"a synchronous call should read the clock once per transaction, not once per step"
553+
end
554+
505555
test "sync discovers the configured SQLite busy wait it has to restore" do
506556
skip unless SolidObjects::Record.connection.adapter_name.match?(/sqlite/i)
507557

@@ -853,6 +903,17 @@ def invoke_with_immediate_sqlite_lock_failure(message_reference)
853903
captured
854904
end
855905

906+
def count_clock_reads
907+
reads = 0
908+
subscription = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
909+
reads += 1 if event.payload[:sql].match?(/STRFTIME|CURRENT_TIMESTAMP/i)
910+
end
911+
yield
912+
reads
913+
ensure
914+
ActiveSupport::Notifications.unsubscribe(subscription) if subscription
915+
end
916+
856917
def process_write?(payload)
857918
payload.fetch(:sql).match?(/\A(?:INSERT|UPDATE)/) &&
858919
payload.fetch(:sql).include?(SolidObjects::Process.table_name)

0 commit comments

Comments
 (0)