Skip to content

Commit 00415eb

Browse files
authored
Merge pull request #39 from cardmagic/agent/idle-polling-backoff
Reduce idle polling CPU for 0.13.1
2 parents 461eec4 + 17d78f1 commit 00415eb

39 files changed

Lines changed: 1021 additions & 41 deletions

CHANGELOG.md

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

3+
## 0.13.1 - 2026-08-16
4+
5+
- Back idle actor, effect, reminder, and broadcast polling off exponentially
6+
from the configured fast interval to a new one-second idle ceiling. Any
7+
processed work or wake-up resets the role immediately, and actor polling
8+
remains capped by the lease-renewal interval.
9+
- Expose each role's `current_polling_interval` and emit
10+
`solid_objects.polling.interval_changed` instrumentation for every idle,
11+
work, and wake-up transition.
12+
- Warn once when live processes share the database without a configured
13+
cross-process wake-up adapter.
14+
- Make the in-process wake-up generation-aware so a signal committed between
15+
an empty claim and the wait is not missed. PostgreSQL and Redis adapters now
16+
expose the same watch contract.
17+
- Add a reproducible four-role SQLite idle benchmark and repair the benchmark
18+
schema setup for the current operation columns.
19+
- **Behavior change:** `polling_interval` is now the fast interval after
20+
activity, not a constant idle cadence. Existing explicit values back off to
21+
`idle_polling_interval`, which defaults to one second. Set both options to
22+
the same value to preserve a fixed cadence. Existing custom wake-up adapters
23+
that return `nil` remain at the fast cadence until they return `false` for a
24+
timeout and `true` for a notification.
25+
326
## 0.13.0 - 2026-08-15
427

528
- **Breaking:** make observables invalidation-only by default. An ordinary

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.13.0)
4+
solid_objects (0.13.1)
55
actioncable (>= 8.0)
66
actionpack (>= 8.0)
77
actionview (>= 8.0)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.13.0)
387+
solid_objects (0.13.1)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1014,6 +1014,7 @@ Important defaults:
10141014
| Setting | Default |
10151015
| --- | ---: |
10161016
| `polling_interval` | 0.1 seconds |
1017+
| `idle_polling_interval` | 1 second |
10171018
| `sync_polling_interval` | 0.05 seconds |
10181019
| `lease_duration` | 30 seconds |
10191020
| `lease_renewal_interval` | 10 seconds |
@@ -1038,6 +1039,14 @@ Payload, state, and result limits; retry delay; table prefix; logging; wake-up;
10381039
broadcast; database; and authorization adapters are also configurable. Invalid
10391040
lease intervals, component counts, and size limits fail fast at boot.
10401041

1042+
`polling_interval` is the fast interval after work or a wake-up. Consecutive
1043+
empty passes double it up to `idle_polling_interval`. Actor workers never wait
1044+
longer than `lease_renewal_interval`. Set the fast and idle values equal for a
1045+
fixed cadence. The default wake-up reaches only the current Ruby process;
1046+
configure PostgreSQL notifications or optional Redis Pub/Sub when separate
1047+
processes need low-latency delivery. The runtime warns once when it sees that
1048+
topology without an adapter.
1049+
10411050
## Workers and operations
10421051

10431052
`solid_objects start` runs actor, effect, reminder, and broadcast roles under
@@ -1299,8 +1308,8 @@ Partially implemented:
12991308

13001309
- the supervisor starts and drains roles but does not replace a crashed role or
13011310
run periodic maintenance automatically;
1302-
- cross-process wake-up uses polling; PostgreSQL notifications and optional
1303-
Redis acceleration are not implemented;
1311+
- PostgreSQL notifications and optional Redis acceleration are implemented,
1312+
but adapter selection remains explicit and polling is the durable fallback;
13041313
- live observable and component replacement work, while Turbo append actions
13051314
remain future work;
13061315
- local admission limits exist, but distributed rate limits and global

benchmark/idle_polling.rb

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# frozen_string_literal: true
2+
3+
require "json"
4+
require "rbconfig"
5+
require_relative "support"
6+
7+
class CountingWakeUp < SolidObjects::WakeUp
8+
def initialize
9+
super
10+
@count_mutex = Mutex.new
11+
@poll_count = 0
12+
end
13+
14+
def wait(timeout:, generation: nil)
15+
@count_mutex.synchronize { @poll_count += 1 }
16+
super
17+
end
18+
19+
def reset_count
20+
@count_mutex.synchronize { @poll_count = 0 }
21+
end
22+
23+
def poll_count
24+
@count_mutex.synchronize { @poll_count }
25+
end
26+
end
27+
28+
def measure(interval:, warmup:, duration:, wake_up:)
29+
SolidObjects.configuration.polling_interval = interval
30+
SolidObjects.configuration.idle_polling_interval = 1.0
31+
components = [
32+
SolidObjects::Worker.new,
33+
SolidObjects::EffectExecutor.new,
34+
SolidObjects::ReminderScheduler.new,
35+
SolidObjects::BroadcastExecutor.new
36+
]
37+
threads = components.map { |component| Thread.new { component.run } }
38+
39+
sleep warmup
40+
wake_up.reset_count
41+
cpu_started_at = Process.times
42+
wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
43+
sleep duration
44+
wall_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at
45+
cpu_finished_at = Process.times
46+
cpu_elapsed = cpu_finished_at.utime + cpu_finished_at.stime -
47+
cpu_started_at.utime - cpu_started_at.stime
48+
poll_count = wake_up.poll_count
49+
50+
{
51+
polling_interval: interval,
52+
idle_polling_interval: SolidObjects.configuration.idle_polling_interval,
53+
current_intervals: components.map(&:current_polling_interval),
54+
polls: poll_count,
55+
polls_per_second: (poll_count / wall_elapsed).round(3),
56+
idle_cpu_percent: ((cpu_elapsed / wall_elapsed) * 100).round(3)
57+
}
58+
ensure
59+
components&.each(&:request_shutdown)
60+
threads&.each { |thread| thread.join(2) }
61+
components&.each(&:stop)
62+
end
63+
64+
intervals = ENV.fetch("INTERVALS", "0.02,0.1,0.5").split(",").map do |value|
65+
Float(value).tap { |interval| raise ArgumentError, "intervals must be positive" unless interval.positive? }
66+
end
67+
warmup = Float(ENV.fetch("WARMUP", "3"))
68+
duration = Float(ENV.fetch("DURATION", "10"))
69+
raise ArgumentError, "warmup must be positive" unless warmup.positive?
70+
raise ArgumentError, "duration must be positive" unless duration.positive?
71+
72+
SolidObjectsBenchmark.setup
73+
wake_up = CountingWakeUp.new
74+
SolidObjects.configuration.wake_up_adapter = wake_up
75+
database_version = ActiveRecord::Base.connection.select_value("SELECT sqlite_version()")
76+
results = intervals.map { |interval| measure(interval:, warmup:, duration:, wake_up:) }
77+
puts JSON.pretty_generate(
78+
measured_at: Time.now.utc.iso8601,
79+
package_version: SolidObjects::VERSION,
80+
runtime: {
81+
ruby: RUBY_DESCRIPTION,
82+
platform: RUBY_PLATFORM,
83+
cpu: RbConfig::CONFIG.fetch("host_cpu")
84+
},
85+
database: {
86+
adapter: "sqlite",
87+
version: database_version,
88+
path: SolidObjectsBenchmark::DATABASE_PATH
89+
},
90+
methodology: {
91+
roles: %w[actors effects reminders broadcasts],
92+
warmup_seconds: warmup,
93+
duration_seconds: duration,
94+
cpu_percent: "process user plus system CPU time divided by wall time"
95+
},
96+
results:
97+
)
98+
SolidObjectsBenchmark.teardown

benchmark/support.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,8 +402,10 @@ def establish_connection
402402
def migrate
403403
require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
404404
require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
405+
require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns"
405406
CreateSolidObjectsTables.new.migrate(:up)
406407
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
408+
RenameMessageDispatchColumns.new.migrate(:up)
407409
end
408410

409411
# @rbs () -> void

docs/adr/0011-wake-up-strategy.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ Polling adds latency and database queries. PostgreSQL notifications are transact
1111

1212
Database rows remain the only durable source of work and results. Wake-up
1313
adapters only prompt workers and synchronous waiters to re-query those rows.
14+
Actor, effect, reminder, and broadcast roles double consecutive empty waits
15+
from `polling_interval` to `idle_polling_interval`. Work and notifications reset
16+
the wait immediately. Actor workers clamp the ceiling to
17+
`lease_renewal_interval`.
1418

1519
The interface supports:
1620

@@ -40,4 +44,6 @@ Timeout does not cancel durable work.
4044
- A reconnecting PostgreSQL listener must commit `LISTEN`, inspect current state, and then wait.
4145
- Redis loss only increases latency and never loses durable work.
4246
- Every adapter retains periodic polling to close startup, reconnect, and missed-message races.
47+
- A process that returns `false` from a timed wait participates in backoff; an older custom adapter that returns `nil` keeps the fast cadence.
48+
- A multi-process deployment without an adapter trades idle database load for up to the current idle polling interval of notification latency and logs that topology once.
4349
- Notification payloads never contain actor arguments or results.

docs/architecture.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -365,8 +365,8 @@ it drains earlier messages and the target through the same activation and
365365
executor used by workers. If another process owns the actor, the caller waits
366366
for the row to become completed, rejected, dead-lettered, destroyed, or timed
367367
out. Every wait re-queries durable rows. The implemented wake-up interface
368-
provides same-process signaling, bounded polling, and dependency injection.
369-
PostgreSQL `LISTEN/NOTIFY` and optional Redis Pub/Sub are planned adapters.
368+
provides generation-aware same-process signaling, adaptive bounded polling,
369+
PostgreSQL `LISTEN/NOTIFY`, and optional Redis Pub/Sub.
370370

371371
The normal path does not wait for a worker polling interval because the caller
372372
assists execution immediately. End-to-end latency still includes earlier

docs/benchmarks.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,44 @@ They include the runtime's Active Record and database query overhead and will
55
vary with hardware, schema size, connection pools, durability settings, and
66
contention.
77

8+
## Idle SQLite polling
9+
10+
Run the four-role idle harness with:
11+
12+
```bash
13+
bundle exec ruby -Ilib benchmark/idle_polling.rb
14+
```
15+
16+
It warms each interval for three seconds, measures for ten seconds, and reports
17+
process user plus system CPU time divided by wall time. Measured August 16,
18+
2026 on an Apple M5 with Ruby 4.0.6 and SQLite 3.53.2. The before run used
19+
0.13.0; the after run used the prepared 0.13.1 tree.
20+
21+
| Fast interval | Before polls/s | Before CPU | After polls/s | After CPU |
22+
| ---: | ---: | ---: | ---: | ---: |
23+
| 20 ms | 165.340 | 8.401% | 3.998 | 0.947% |
24+
| 100 ms | 38.396 | 2.925% | 3.999 | 0.482% |
25+
| 500 ms | 7.998 | 2.061% | 3.996 | 0.283% |
26+
27+
The after run reached the one-second ceiling for the actor, effect, reminder,
28+
and broadcast roles. These are developer-laptop measurements, not a CPU
29+
guarantee; timer scheduling, YJIT, the SQLite file, and unrelated host activity
30+
affect short samples.
31+
32+
Five SQLite samples measured durable enqueue through committed completion after
33+
2.5 seconds of idleness. The polling-only multi-process harness submits just
34+
after an empty pass, so it measures approximately the full polling wait rather
35+
than average arrival latency.
36+
37+
| Topology | 0.13.0 p50 | Prepared 0.13.1 p50 |
38+
| --- | ---: | ---: |
39+
| One process, in-process wake-up | 43.360 ms | 50.339 ms |
40+
| Two processes, polling only | 117.787 ms | 1,028.006 ms |
41+
42+
The local wake-up keeps the one-process path prompt after backoff. The
43+
polling-only row is the explicit tradeoff: use PostgreSQL notifications or
44+
optional Redis Pub/Sub when separate processes need low-latency delivery.
45+
846
## Production-shaped adoption measurement
947

1048
An adoption evaluation measured Solid Objects 0.2.0 from a macOS Rails process

docs/development.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ COUNT=500 CONCURRENCY=4 bundle exec ruby -Ilib benchmark/concurrent_actors.rb
140140
COUNT=100 bundle exec ruby -Ilib benchmark/sync_latency.rb
141141
COUNT=500 bundle exec ruby -Ilib benchmark/activation_cache.rb
142142
bundle exec ruby -Ilib benchmark/query_count.rb
143+
bundle exec ruby -Ilib benchmark/idle_polling.rb
143144
```
144145

145146
SQLite is the default. Set `SOLID_OBJECTS_DATABASE_URL` to benchmark a dedicated

docs/operations.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ Important controls include:
6969
- `lease_duration`
7070
- `lease_renewal_interval`
7171
- `polling_interval`
72+
- `idle_polling_interval`
7273
- `max_mailbox_length`
7374
- payload, state, and result byte limits
7475
- retry attempts and delay
@@ -81,6 +82,29 @@ Keep lease duration comfortably above renewal interval and expected database
8182
pause time. A handler can exceed the pass-duration budget because Ruby code is
8283
not safely preempted; alert on message duration and isolate untrusted work.
8384

85+
## Polling and wake-up adapters
86+
87+
`polling_interval` is the fast interval after work or a wake-up. Consecutive
88+
empty actor, effect, reminder, and broadcast passes double that role's wait up
89+
to `idle_polling_interval`, which defaults to one second. Actor workers clamp
90+
the ceiling to `lease_renewal_interval` while they may hold cached activations.
91+
Set the fast and idle values equal for a fixed cadence.
92+
93+
The default wake-up interrupts waits only in the current Ruby process. When a
94+
live process record shows that the database is shared across processes and no
95+
adapter is configured, the runtime logs
96+
`solid_objects.polling_only_cross_process_wake_up` once. Configure
97+
`WakeUpAdapters::Postgresql` or `WakeUpAdapters::Redis` when separate processes
98+
need prompt delivery. Without one, newly committed work can wait up to the
99+
current idle polling interval.
100+
101+
Each role exposes `current_polling_interval`.
102+
`solid_objects.polling.interval_changed` reports the role, reason, previous
103+
interval, and current interval. The polling-only warning is also emitted as
104+
`solid_objects.polling.only_cross_process_wake_up` instrumentation. Custom
105+
adapters should return `true` for a notification and `false` for a timeout; an
106+
older adapter that returns `nil` remains compatible and keeps the fast cadence.
107+
84108
## Graceful shutdown
85109

86110
The supervisor requests shutdown, stops new claims, lets active loops return,

0 commit comments

Comments
 (0)