Skip to content

perf: cut database round trips per sync call - #7

Merged
cardmagic merged 4 commits into
mainfrom
agent/reduce-sync-round-trips
Aug 9, 2026
Merged

perf: cut database round trips per sync call#7
cardmagic merged 4 commits into
mainfrom
agent/reduce-sync-round-trips

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Reduces the database work in a synchronous call, and fixes the benchmark harness that made
any of this impossible to measure.

The benchmark harness was broken

benchmark/support.rb applied only the initial migration, so the state_revision column
added in 0.4.0 was missing. Every message failed at commit with
ActiveModel::UnknownAttributeError, retried with backoff, and the synchronous benchmarks
timed out reporting waiting_on=not_yet_available. No benchmark in the repo could run.
Fixed by applying both migrations, as test/database_test_helper.rb already does.

What the measurements showed

With the harness working, a warm synchronous call issues 66 queries but spends only
0.6-1.4ms in SQL, so 92-95% of wall time is Ruby, not the database. Two overheads
dominated the query count:

Overhead Queries per call
PRAGMA busy_timeout read + write around every deadline-bound transaction 28
SELECT STRFTIME to read the database clock 7

What this changes

The database clock is read once per transaction, not once per step. Callers inside one
transaction now share a single reading, which is also the more correct semantic for lease
and fencing comparisons. Clock reads per synchronous call drop from 7 to 4, which is exactly
one per transaction: enqueue, claim, and two fenced transactions.

The busy wait is resolved from the pool configuration instead of being queried.
configured_busy_handler_timeout is pure Ruby, so checking it first removes the
PRAGMA busy_timeout read entirely. The pragma read now happens only when no timeout is
configured, which is the case where there is no Ruby busy handler to restore.

Net: 66 queries to 49 per synchronous call, a 26% reduction. On SQLite that barely moves
p50, because SQL is 5% of the time. On PostgreSQL or MySQL, where every query is a network
round trip, query count is the latency: docs/benchmarks.md records a 60ms median MySQL
write, which is what 66 round trips costs.

What this deliberately does not change

The remaining 14 PRAGMA busy_timeout = 0 writes stay. Replacing them with a Ruby-level
busy_handler(nil) is measurably better but changes behaviour: with the pragma,
BEGIN IMMEDIATE succeeds under contention and the statement inside it fails; with the
handler cleared, BEGIN IMMEDIATE fails outright. Two existing contention tests assert
retry attempts against the processes table and fail under the second behaviour.

The prize is real and measured, so it is worth doing separately with proper scrutiny:

Configuration Queries/call p95 p99
Before this PR 66 5.79ms 33.80ms
This PR 49 3.96ms 34.85ms
Also removing the pragma writes 35 2.47ms 2.73ms

The p99 tail belongs entirely to those writes. This PR does not fix it.

Effects

  • API: none. No public signature changed.
  • Correctness: one clock reading per transaction instead of several. Leases and fencing
    now compare against a consistent instant within a transaction, and each transaction still
    reads a fresh one. Suspension and restoration of the busy wait are unchanged.
  • Security: none.
  • Migration: none.
  • Compatibility: the clock change is adapter-agnostic; the busy-wait change is SQLite
    only. If both a timeout: and an explicit pragmas: {busy_timeout:} are configured, the
    configured handler now wins on restore. That combination was already ambiguous.

Validation

bundle exec rake

245 runs, 979 assertions, 0 failures. Standard Ruby, RuboCop, RBS, Steep, and Brakeman clean.
PostgreSQL and MySQL suites were not run locally because neither server is available; CI covers them.

Tests

Four tests, each written failing first:

  • a transaction reads the database clock once and shares the reading
  • the shared clock reading does not outlive its transaction
  • sync stops re-reading the database clock for every step
  • sync does not re-read the SQLite busy wait it already knows how to restore

benchmark/support.rb applied only the initial migration, so the state_revision column added in 0.4.0 was missing. Every message failed at commit with ActiveModel::UnknownAttributeError, retried with backoff, and the synchronous benchmarks timed out reporting waiting_on=not_yet_available. Apply both migrations, as the test helper already does.
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.
@greptile-apps

greptile-apps Bot commented Aug 9, 2026

Copy link
Copy Markdown

Greptile Summary

The PR reduces synchronous database round trips by sharing one database-clock reading per transaction and resolving SQLite busy-wait restoration from connection configuration. It also updates the benchmark schema setup, benchmark documentation, release metadata, generated signatures, and integration coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains.

Important Files Changed

Filename Overview
lib/solid_objects/database_adapter.rb Caches the database clock in isolated execution state for the duration of an adapter transaction.
lib/solid_objects/database_adapters/sqlite.rb Uses the configured SQLite handler timeout before falling back to a live PRAGMA lookup.
benchmark/support.rb Applies the state-revision migration required by the current model schema.
test/integration/synchronous_invocation_test.rb Adds coverage for transaction-scoped clock reads and avoidance of redundant SQLite busy-timeout reads.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Begin adapter transaction] --> B[Create transaction clock scope]
  B --> C[First database_now call]
  C --> D[Read database clock]
  D --> E[Cache transaction timestamp]
  E --> F[Later database_now calls]
  F --> G[Reuse cached timestamp]
  G --> H[Commit or rollback]
  H --> I[Clear clock scope and cache]
Loading

Reviews (2): Last reviewed commit: "refactor: inline the busy wait suspensio..." | Re-trigger Greptile

Comment thread lib/solid_objects/database_adapters/sqlite.rb Outdated
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@cardmagic
cardmagic merged commit 4385728 into main Aug 9, 2026
11 checks passed
@cardmagic
cardmagic deleted the agent/reduce-sync-round-trips branch August 10, 2026 13:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant