solid_objects 0.13.0 → 0.13.1

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 (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +23 -0
  3. data/README.md +11 -2
  4. data/benchmark/idle_polling.rb +98 -0
  5. data/benchmark/support.rb +2 -0
  6. data/docs/adr/0011-wake-up-strategy.md +6 -0
  7. data/docs/architecture.md +2 -2
  8. data/docs/benchmarks.md +38 -0
  9. data/docs/development.md +1 -0
  10. data/docs/operations.md +24 -0
  11. data/docs/roadmap.md +8 -5
  12. data/lib/solid_objects/broadcast_executor.rb +35 -4
  13. data/lib/solid_objects/configuration.rb +4 -0
  14. data/lib/solid_objects/effect_executor.rb +34 -3
  15. data/lib/solid_objects/polling_backoff.rb +45 -0
  16. data/lib/solid_objects/process_registry.rb +51 -0
  17. data/lib/solid_objects/reminder_scheduler.rb +35 -4
  18. data/lib/solid_objects/version.rb +1 -1
  19. data/lib/solid_objects/wake_up.rb +36 -4
  20. data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
  21. data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
  22. data/lib/solid_objects/worker.rb +39 -3
  23. data/lib/solid_objects.rb +2 -0
  24. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
  25. data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
  26. data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
  27. data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
  28. data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
  29. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
  30. data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
  31. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
  32. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
  33. data/sig/generated/lib/solid_objects/worker.rbs +7 -0
  34. metadata +5 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8dc01a4c7b7f44d7dbd68b1194abd64a2c5515d5280a1e9cae15b119ff18508c
4
- data.tar.gz: 68099ea645dff1d976f9aeda77d79d04ed8280e18bfc6d4235daca972ffafad4
3
+ metadata.gz: e5bd1724baaa40d79ec3693558ef7aa8e37830e4c5afd1da1d5774356d1e584e
4
+ data.tar.gz: 1e7a2307cae51316b9b4c4f56ea6fc986a622e3665541514137d87bbf72b2e9a
5
5
  SHA512:
6
- metadata.gz: 335cec6b8f87328e9a88eee32bbd8aaca5b019cb0ef76dfa70f5efa261afb81a2a034d3bc6a11189f22c4809583d9cce1538c6ecb1ff8cc8319c1d195fec8295
7
- data.tar.gz: 445c227d5badd737cb44f9b44b87917b73c3e2a55a66acb6a4cd1fe09a39febda314f00188cd35eabc70acef5d5b95503c3cedf5c35632e22948fa62667a62db
6
+ metadata.gz: b1b5284ef616f306bea2136d7becf4a3c8e4aa2adbcc24eb5da08c3f33e85f34551f687ff7133cd3781abba79d4f0dac919657d9150bfe3ebd021417542d505b
7
+ data.tar.gz: 6971c0ee78d9eab66776a5ba6d60dcc403d3346c57cffca795797f50048fc419b6473d3d620c4d025238916757f52ed79ed1ec153feaa3dda84744d740b08045
data/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
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
+
3
26
  ## 0.13.0 - 2026-08-15
4
27
 
5
28
  - **Breaking:** make observables invalidation-only by default. An ordinary
data/README.md CHANGED
@@ -1014,6 +1014,7 @@ Important defaults:
1014
1014
  | Setting | Default |
1015
1015
  | --- | ---: |
1016
1016
  | `polling_interval` | 0.1 seconds |
1017
+ | `idle_polling_interval` | 1 second |
1017
1018
  | `sync_polling_interval` | 0.05 seconds |
1018
1019
  | `lease_duration` | 30 seconds |
1019
1020
  | `lease_renewal_interval` | 10 seconds |
@@ -1038,6 +1039,14 @@ Payload, state, and result limits; retry delay; table prefix; logging; wake-up;
1038
1039
  broadcast; database; and authorization adapters are also configurable. Invalid
1039
1040
  lease intervals, component counts, and size limits fail fast at boot.
1040
1041
 
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
+
1041
1050
  ## Workers and operations
1042
1051
 
1043
1052
  `solid_objects start` runs actor, effect, reminder, and broadcast roles under
@@ -1299,8 +1308,8 @@ Partially implemented:
1299
1308
 
1300
1309
  - the supervisor starts and drains roles but does not replace a crashed role or
1301
1310
  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;
1304
1313
  - live observable and component replacement work, while Turbo append actions
1305
1314
  remain future work;
1306
1315
  - local admission limits exist, but distributed rate limits and global
@@ -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
data/benchmark/support.rb CHANGED
@@ -402,8 +402,10 @@ module SolidObjectsBenchmark
402
402
  def migrate
403
403
  require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
404
404
  require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
405
+ require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns"
405
406
  CreateSolidObjectsTables.new.migrate(:up)
406
407
  AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
408
+ RenameMessageDispatchColumns.new.migrate(:up)
407
409
  end
408
410
 
409
411
  # @rbs () -> void
@@ -11,6 +11,10 @@ Polling adds latency and database queries. PostgreSQL notifications are transact
11
11
 
12
12
  Database rows remain the only durable source of work and results. Wake-up
13
13
  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`.
14
18
 
15
19
  The interface supports:
16
20
 
@@ -40,4 +44,6 @@ Timeout does not cancel durable work.
40
44
  - A reconnecting PostgreSQL listener must commit `LISTEN`, inspect current state, and then wait.
41
45
  - Redis loss only increases latency and never loses durable work.
42
46
  - 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.
43
49
  - Notification payloads never contain actor arguments or results.
data/docs/architecture.md CHANGED
@@ -365,8 +365,8 @@ it drains earlier messages and the target through the same activation and
365
365
  executor used by workers. If another process owns the actor, the caller waits
366
366
  for the row to become completed, rejected, dead-lettered, destroyed, or timed
367
367
  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.
370
370
 
371
371
  The normal path does not wait for a worker polling interval because the caller
372
372
  assists execution immediately. End-to-end latency still includes earlier
data/docs/benchmarks.md CHANGED
@@ -5,6 +5,44 @@ They include the runtime's Active Record and database query overhead and will
5
5
  vary with hardware, schema size, connection pools, durability settings, and
6
6
  contention.
7
7
 
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
+
8
46
  ## Production-shaped adoption measurement
9
47
 
10
48
  An adoption evaluation measured Solid Objects 0.2.0 from a macOS Rails process
data/docs/development.md CHANGED
@@ -140,6 +140,7 @@ COUNT=500 CONCURRENCY=4 bundle exec ruby -Ilib benchmark/concurrent_actors.rb
140
140
  COUNT=100 bundle exec ruby -Ilib benchmark/sync_latency.rb
141
141
  COUNT=500 bundle exec ruby -Ilib benchmark/activation_cache.rb
142
142
  bundle exec ruby -Ilib benchmark/query_count.rb
143
+ bundle exec ruby -Ilib benchmark/idle_polling.rb
143
144
  ```
144
145
 
145
146
  SQLite is the default. Set `SOLID_OBJECTS_DATABASE_URL` to benchmark a dedicated
data/docs/operations.md CHANGED
@@ -69,6 +69,7 @@ Important controls include:
69
69
  - `lease_duration`
70
70
  - `lease_renewal_interval`
71
71
  - `polling_interval`
72
+ - `idle_polling_interval`
72
73
  - `max_mailbox_length`
73
74
  - payload, state, and result byte limits
74
75
  - retry attempts and delay
@@ -81,6 +82,29 @@ Keep lease duration comfortably above renewal interval and expected database
81
82
  pause time. A handler can exceed the pass-duration budget because Ruby code is
82
83
  not safely preempted; alert on message duration and isolate untrusted work.
83
84
 
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
+
84
108
  ## Graceful shutdown
85
109
 
86
110
  The supervisor requests shutdown, stops new claims, lets active loops return,
data/docs/roadmap.md CHANGED
@@ -83,11 +83,14 @@
83
83
  What is not done is making any of them automatic. In-process signaling cannot
84
84
  cross process boundaries, so by default a commit in a web process does not
85
85
  wake a broadcast executor in a worker process and that delivery waits up to
86
- `polling_interval`, 100 ms. An adapter removes that floor, measured at 103.7 ms
87
- to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis, but each stays
88
- opt-in for a reason: the PostgreSQL adapter opens a connection per waiting
89
- thread outside the pool and `LISTEN` does not survive a transaction-pooling
90
- proxy such as PgBouncer, and Redis is not a dependency of this gem.
86
+ the current adaptive polling interval, up to the one-second
87
+ `idle_polling_interval` default. The runtime warns once when it observes this
88
+ topology without an adapter. An adapter removes that floor, measured before
89
+ adaptive polling at 103.7 ms to 2.9 ms at p50 on PostgreSQL and 103.8 ms to
90
+ 5.7 ms on Redis, but each stays opt-in for a reason: the PostgreSQL adapter
91
+ opens a connection per waiting thread outside the pool and `LISTEN` does not
92
+ survive a transaction-pooling proxy such as PgBouncer, and Redis is not a
93
+ dependency of this gem.
91
94
  `WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
92
95
  default elsewhere; it never selects Redis. An application that configures
93
96
  nothing keeps polling, and MySQL applications keep polling unless they
@@ -1,11 +1,14 @@
1
1
  # rbs_inline: enabled
2
2
 
3
+ require "solid_objects/polling_backoff"
4
+
3
5
  module SolidObjects
4
6
  class BroadcastExecutor
5
7
  # @rbs @process_registry: ProcessRegistry
6
8
  # @rbs @database_adapter: DatabaseAdapter
7
9
  # @rbs @stopped: bool
8
10
  # @rbs @shutdown_requested: bool
11
+ # @rbs @polling_backoff: PollingBackoff
9
12
 
10
13
  # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
11
14
  def initialize(
@@ -17,6 +20,17 @@ module SolidObjects
17
20
  process_registry.register(kind: "broadcast")
18
21
  @stopped = false
19
22
  @shutdown_requested = false
23
+ @polling_backoff = PollingBackoff.new(
24
+ minimum_interval: SolidObjects.configuration.polling_interval,
25
+ maximum_interval: SolidObjects.configuration.idle_polling_interval,
26
+ on_change: ->(transition) do
27
+ SolidObjects.instrument(
28
+ :"polling.interval_changed",
29
+ role: "broadcasts",
30
+ **transition
31
+ )
32
+ end
33
+ )
20
34
  end
21
35
 
22
36
  # @rbs () -> bool
@@ -46,11 +60,23 @@ module SolidObjects
46
60
 
47
61
  # @rbs () -> void
48
62
  def run
63
+ ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up
64
+
49
65
  until shutdown_requested?
66
+ wake_up = SolidObjects.wake_up
67
+ watch = wake_up.respond_to?(:watch) ? wake_up.watch : wake_up
50
68
  worked = run_once
51
- next if worked
52
-
53
- SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
69
+ if worked
70
+ polling_backoff.reset(:work)
71
+ next
72
+ end
73
+
74
+ notified = watch.wait(timeout: current_polling_interval)
75
+ if notified == false
76
+ polling_backoff.record_idle
77
+ else
78
+ polling_backoff.reset(:wake_up)
79
+ end
54
80
  end
55
81
  ensure
56
82
  stop
@@ -72,9 +98,14 @@ module SolidObjects
72
98
  @shutdown_requested
73
99
  end
74
100
 
101
+ # @rbs () -> Float
102
+ def current_polling_interval
103
+ polling_backoff.current_interval
104
+ end
105
+
75
106
  private
76
107
 
77
- attr_reader :process_registry, :database_adapter
108
+ attr_reader :process_registry, :database_adapter, :polling_backoff
78
109
 
79
110
  # @rbs () -> Broadcast?
80
111
  def claim_next
@@ -4,6 +4,7 @@ module SolidObjects
4
4
  class Configuration
5
5
  # @rbs @table_name_prefix: String
6
6
  # @rbs @polling_interval: Float
7
+ # @rbs @idle_polling_interval: Float
7
8
  # @rbs @sync_polling_interval: Float
8
9
  # @rbs @lease_duration: Float
9
10
  # @rbs @lease_renewal_interval: Float
@@ -49,6 +50,7 @@ module SolidObjects
49
50
 
50
51
  attr_accessor :table_name_prefix,
51
52
  :polling_interval,
53
+ :idle_polling_interval,
52
54
  :sync_polling_interval,
53
55
  :lease_duration,
54
56
  :lease_renewal_interval,
@@ -99,6 +101,7 @@ module SolidObjects
99
101
  def initialize
100
102
  @table_name_prefix = "solid_objects_"
101
103
  @polling_interval = 0.1
104
+ @idle_polling_interval = 1.0
102
105
  @sync_polling_interval = 0.05
103
106
  @lease_duration = 30.0
104
107
  @lease_renewal_interval = 10.0
@@ -241,6 +244,7 @@ module SolidObjects
241
244
  def positive_values
242
245
  {
243
246
  polling_interval:,
247
+ idle_polling_interval:,
244
248
  sync_polling_interval:,
245
249
  lease_duration:,
246
250
  lease_renewal_interval:,
@@ -1,5 +1,7 @@
1
1
  # rbs_inline: enabled
2
2
 
3
+ require "solid_objects/polling_backoff"
4
+
3
5
  module SolidObjects
4
6
  EffectContext = Data.define(:id, :attempt, :source_message_id, :actor_type, :actor_id)
5
7
 
@@ -10,6 +12,7 @@ module SolidObjects
10
12
  # @rbs @database_adapter: DatabaseAdapter
11
13
  # @rbs @stopped: bool
12
14
  # @rbs @shutdown_requested: bool
15
+ # @rbs @polling_backoff: PollingBackoff
13
16
 
14
17
  # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
15
18
  def initialize(
@@ -21,6 +24,17 @@ module SolidObjects
21
24
  process_registry.register(kind: "effect")
22
25
  @stopped = false
23
26
  @shutdown_requested = false
27
+ @polling_backoff = PollingBackoff.new(
28
+ minimum_interval: SolidObjects.configuration.polling_interval,
29
+ maximum_interval: SolidObjects.configuration.idle_polling_interval,
30
+ on_change: ->(transition) do
31
+ SolidObjects.instrument(
32
+ :"polling.interval_changed",
33
+ role: "effects",
34
+ **transition
35
+ )
36
+ end
37
+ )
24
38
  end
25
39
 
26
40
  # @rbs () -> bool
@@ -50,11 +64,23 @@ module SolidObjects
50
64
 
51
65
  # @rbs () -> void
52
66
  def run
67
+ ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up
68
+
53
69
  until shutdown_requested?
70
+ wake_up = SolidObjects.wake_up
71
+ watch = wake_up.respond_to?(:watch) ? wake_up.watch : wake_up
54
72
  worked = run_once
55
- next if worked
73
+ if worked
74
+ polling_backoff.reset(:work)
75
+ next
76
+ end
56
77
 
57
- SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
78
+ notified = watch.wait(timeout: current_polling_interval)
79
+ if notified == false
80
+ polling_backoff.record_idle
81
+ else
82
+ polling_backoff.reset(:wake_up)
83
+ end
58
84
  end
59
85
  ensure
60
86
  stop
@@ -76,9 +102,14 @@ module SolidObjects
76
102
  @shutdown_requested
77
103
  end
78
104
 
105
+ # @rbs () -> Float
106
+ def current_polling_interval
107
+ polling_backoff.current_interval
108
+ end
109
+
79
110
  private
80
111
 
81
- attr_reader :process_registry, :database_adapter
112
+ attr_reader :process_registry, :database_adapter, :polling_backoff
82
113
 
83
114
  # @rbs () -> Effect?
84
115
  def claim_next
@@ -0,0 +1,45 @@
1
+ # rbs_inline: enabled
2
+
3
+ module SolidObjects
4
+ class PollingBackoff
5
+ # @rbs @minimum_interval: Float
6
+ # @rbs @maximum_interval: Float
7
+ # @rbs @current_interval: Float
8
+ # @rbs @on_change: Proc?
9
+
10
+ attr_reader :current_interval
11
+
12
+ # @rbs (minimum_interval: Numeric, maximum_interval: Numeric, ?on_change: Proc?) -> void
13
+ def initialize(minimum_interval:, maximum_interval:, on_change: nil)
14
+ @minimum_interval = minimum_interval.to_f
15
+ @maximum_interval = [ @minimum_interval, maximum_interval.to_f ].max
16
+ @current_interval = @minimum_interval
17
+ @on_change = on_change
18
+ end
19
+
20
+ # @rbs () -> void
21
+ def record_idle
22
+ change([ current_interval * 2, @maximum_interval ].min, :idle)
23
+ end
24
+
25
+ # @rbs (:work | :wake_up) -> void
26
+ def reset(reason)
27
+ change(@minimum_interval, reason)
28
+ end
29
+
30
+ private
31
+
32
+ # @rbs (Float, :idle | :work | :wake_up) -> void
33
+ def change(interval, reason)
34
+ return if interval == current_interval
35
+
36
+ previous_interval = current_interval
37
+ @current_interval = interval
38
+ @on_change&.call(
39
+ previous_interval:,
40
+ current_interval: interval,
41
+ reason:
42
+ )
43
+ end
44
+ end
45
+ end
@@ -4,6 +4,9 @@ require "socket"
4
4
 
5
5
  module SolidObjects
6
6
  class ProcessRegistry
7
+ @polling_warning_mutex = Mutex.new
8
+ @polling_warning_emitted = false
9
+
7
10
  class << self
8
11
  # @rbs (?now: Time) -> Integer
9
12
  def cleanup_dead(now: SolidObjects.database_adapter.database_now)
@@ -53,8 +56,56 @@ module SolidObjects
53
56
  true
54
57
  end
55
58
 
59
+ # @rbs () -> void
60
+ def warn_if_polling_is_only_cross_process_wake_up
61
+ return if SolidObjects.configuration.wake_up_adapter
62
+
63
+ polling_warning_mutex.synchronize do
64
+ return if polling_warning_emitted?
65
+ return unless another_live_process?
66
+
67
+ payload = {
68
+ event: "solid_objects.polling_only_cross_process_wake_up",
69
+ polling_interval: SolidObjects.configuration.polling_interval,
70
+ idle_polling_interval: SolidObjects.configuration.idle_polling_interval
71
+ }
72
+ SolidObjects.configuration.logger.warn(payload)
73
+ SolidObjects.instrument(
74
+ :"polling.only_cross_process_wake_up",
75
+ **payload.except(:event)
76
+ )
77
+ @polling_warning_emitted = true
78
+ end
79
+ end
80
+
81
+ # @rbs () -> void
82
+ def reset_polling_warning!
83
+ polling_warning_mutex.synchronize { @polling_warning_emitted = false }
84
+ end
85
+
56
86
  private
57
87
 
88
+ # @rbs () -> bool
89
+ def another_live_process?
90
+ alive_after = SolidObjects.database_adapter.database_now -
91
+ SolidObjects.configuration.process_alive_threshold
92
+ Process
93
+ .where.not(shutdown_state: "stopped")
94
+ .where(last_heartbeat_at: alive_after..)
95
+ .where("hostname <> ? OR pid <> ?", Socket.gethostname, ::Process.pid)
96
+ .exists?
97
+ end
98
+
99
+ # @rbs () -> bool
100
+ def polling_warning_emitted?
101
+ @polling_warning_emitted
102
+ end
103
+
104
+ # @rbs () -> Mutex
105
+ def polling_warning_mutex
106
+ @polling_warning_mutex ||= Mutex.new
107
+ end
108
+
58
109
  # @rbs (Process, Time) -> void
59
110
  def cleanup_process(process_record, now)
60
111
  deregister(process_record, now:)
@@ -1,11 +1,14 @@
1
1
  # rbs_inline: enabled
2
2
 
3
+ require "solid_objects/polling_backoff"
4
+
3
5
  module SolidObjects
4
6
  class ReminderScheduler
5
7
  # @rbs @process_registry: ProcessRegistry
6
8
  # @rbs @database_adapter: DatabaseAdapter
7
9
  # @rbs @stopped: bool
8
10
  # @rbs @shutdown_requested: bool
11
+ # @rbs @polling_backoff: PollingBackoff
9
12
 
10
13
  # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
11
14
  def initialize(
@@ -17,6 +20,17 @@ module SolidObjects
17
20
  process_registry.register(kind: "reminder")
18
21
  @stopped = false
19
22
  @shutdown_requested = false
23
+ @polling_backoff = PollingBackoff.new(
24
+ minimum_interval: SolidObjects.configuration.polling_interval,
25
+ maximum_interval: SolidObjects.configuration.idle_polling_interval,
26
+ on_change: ->(transition) do
27
+ SolidObjects.instrument(
28
+ :"polling.interval_changed",
29
+ role: "reminders",
30
+ **transition
31
+ )
32
+ end
33
+ )
20
34
  end
21
35
 
22
36
  # @rbs (?now: Time?) -> bool
@@ -45,11 +59,23 @@ module SolidObjects
45
59
 
46
60
  # @rbs () -> void
47
61
  def run
62
+ ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up
63
+
48
64
  until shutdown_requested?
65
+ wake_up = SolidObjects.wake_up
66
+ watch = wake_up.respond_to?(:watch) ? wake_up.watch : wake_up
49
67
  worked = run_once
50
- next if worked
51
-
52
- SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
68
+ if worked
69
+ polling_backoff.reset(:work)
70
+ next
71
+ end
72
+
73
+ notified = watch.wait(timeout: current_polling_interval)
74
+ if notified == false
75
+ polling_backoff.record_idle
76
+ else
77
+ polling_backoff.reset(:wake_up)
78
+ end
53
79
  end
54
80
  ensure
55
81
  stop
@@ -71,9 +97,14 @@ module SolidObjects
71
97
  @shutdown_requested
72
98
  end
73
99
 
100
+ # @rbs () -> Float
101
+ def current_polling_interval
102
+ polling_backoff.current_interval
103
+ end
104
+
74
105
  private
75
106
 
76
- attr_reader :process_registry, :database_adapter
107
+ attr_reader :process_registry, :database_adapter, :polling_backoff
77
108
 
78
109
  # @rbs (now: Time?) -> Reminder?
79
110
  def claim_next(now:)
@@ -1,5 +1,5 @@
1
1
  # rbs_inline: enabled
2
2
 
3
3
  module SolidObjects
4
- VERSION = "0.13.0"
4
+ VERSION = "0.13.1"
5
5
  end
@@ -2,23 +2,55 @@
2
2
 
3
3
  module SolidObjects
4
4
  class WakeUp
5
+ class Watch
6
+ # @rbs @wake_up: WakeUp
7
+ # @rbs @generation: Integer
8
+
9
+ # @rbs (WakeUp, Integer) -> void
10
+ def initialize(wake_up, generation)
11
+ @wake_up = wake_up
12
+ @generation = generation
13
+ end
14
+
15
+ # @rbs (timeout: Numeric) -> bool
16
+ def wait(timeout:)
17
+ @wake_up.wait(timeout:, generation: @generation)
18
+ end
19
+ end
20
+
5
21
  # @rbs @mutex: Mutex
6
22
  # @rbs @condition: Thread::ConditionVariable
23
+ # @rbs @generation: Integer
7
24
 
8
25
  # @rbs () -> void
9
26
  def initialize
10
27
  @mutex = Mutex.new
11
28
  @condition = Thread::ConditionVariable.new
29
+ @generation = 0
12
30
  end
13
31
 
14
32
  # @rbs () -> void
15
33
  def signal
16
- mutex.synchronize { condition.broadcast }
34
+ mutex.synchronize do
35
+ @generation += 1
36
+ condition.broadcast
37
+ end
17
38
  end
18
39
 
19
- # @rbs (timeout: Numeric) -> void
20
- def wait(timeout:)
21
- mutex.synchronize { condition.wait(mutex, timeout) }
40
+ # @rbs () -> Watch
41
+ def watch
42
+ mutex.synchronize { Watch.new(self, @generation) }
43
+ end
44
+
45
+ # @rbs (timeout: Numeric, ?generation: Integer?) -> bool
46
+ def wait(timeout:, generation: nil)
47
+ mutex.synchronize do
48
+ return true if generation && @generation != generation
49
+
50
+ generation ||= @generation
51
+ condition.wait(mutex, timeout)
52
+ @generation != generation
53
+ end
22
54
  end
23
55
 
24
56
  private
@@ -45,6 +45,12 @@ module SolidObjects
45
45
  false
46
46
  end
47
47
 
48
+ # @rbs () -> self
49
+ def watch
50
+ listen
51
+ self
52
+ end
53
+
48
54
  # Starts listening before a caller blocks, so a notification sent between
49
55
  # startup and the first wait is not missed.
50
56
  # @rbs () -> bool
@@ -12,6 +12,22 @@ module SolidObjects
12
12
  # polling interval remains the upper bound, so a missed or failed
13
13
  # notification costs latency rather than correctness.
14
14
  class Redis
15
+ class Watch
16
+ # @rbs @adapter: Redis
17
+ # @rbs @generation: Integer
18
+
19
+ # @rbs (Redis, Integer) -> void
20
+ def initialize(adapter, generation)
21
+ @adapter = adapter
22
+ @generation = generation
23
+ end
24
+
25
+ # @rbs (timeout: Numeric) -> bool
26
+ def wait(timeout:)
27
+ @adapter.wait(timeout:, generation: @generation)
28
+ end
29
+ end
30
+
15
31
  CHANNEL = "solid_objects_wake_up"
16
32
  FAILED_WAIT_INTERVAL = 0.05
17
33
  SUBSCRIBE_TIMEOUT = 5.0
@@ -52,9 +68,9 @@ module SolidObjects
52
68
  # The counter is snapshotted before subscribing, and re-checked before
53
69
  # blocking, so a signal delivered while this caller was still getting
54
70
  # ready is observed rather than absorbed into the new baseline.
55
- # @rbs (timeout: Numeric) -> bool
56
- def wait(timeout:)
57
- signalled = mutex.synchronize { @signalled }
71
+ # @rbs (timeout: Numeric, ?generation: Integer?) -> bool
72
+ def wait(timeout:, generation: nil)
73
+ signalled = generation || mutex.synchronize { @signalled }
58
74
  return paced_failure(timeout) unless listen
59
75
 
60
76
  mutex.synchronize do
@@ -65,6 +81,12 @@ module SolidObjects
65
81
  end
66
82
  end
67
83
 
84
+ # @rbs () -> Watch
85
+ def watch
86
+ listen
87
+ mutex.synchronize { Watch.new(self, @signalled) }
88
+ end
89
+
68
90
  # Redis delivers to a subscribed connection only, and a subscribed
69
91
  # connection cannot serve other callers, so one background subscription
70
92
  # per process fans out to every waiting role in memory. Subscribing
@@ -5,6 +5,7 @@ require "solid_objects/activation_manager"
5
5
  require "solid_objects/activation"
6
6
  require "solid_objects/executor"
7
7
  require "solid_objects/lease_renewer"
8
+ require "solid_objects/polling_backoff"
8
9
 
9
10
  module SolidObjects
10
11
  class Worker
@@ -13,6 +14,7 @@ module SolidObjects
13
14
  # @rbs @activations: Hash[Integer, Activation]
14
15
  # @rbs @stopped: bool
15
16
  # @rbs @shutdown_requested: bool
17
+ # @rbs @polling_backoff: PollingBackoff
16
18
 
17
19
  # @rbs (?process_registry: ProcessRegistry) -> void
18
20
  def initialize(process_registry: ProcessRegistry.new)
@@ -22,6 +24,23 @@ module SolidObjects
22
24
  @activations = {}
23
25
  @stopped = false
24
26
  @shutdown_requested = false
27
+ @polling_backoff = PollingBackoff.new(
28
+ minimum_interval: [
29
+ SolidObjects.configuration.polling_interval,
30
+ SolidObjects.configuration.lease_renewal_interval
31
+ ].min,
32
+ maximum_interval: [
33
+ SolidObjects.configuration.idle_polling_interval,
34
+ SolidObjects.configuration.lease_renewal_interval
35
+ ].min,
36
+ on_change: ->(transition) do
37
+ SolidObjects.instrument(
38
+ :"polling.interval_changed",
39
+ role: "actors",
40
+ **transition
41
+ )
42
+ end
43
+ )
25
44
  end
26
45
 
27
46
  # @rbs () -> Integer
@@ -70,11 +89,23 @@ module SolidObjects
70
89
 
71
90
  # @rbs () -> void
72
91
  def run
92
+ ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up
93
+
73
94
  until shutdown_requested?
95
+ wake_up = SolidObjects.wake_up
96
+ watch = wake_up.respond_to?(:watch) ? wake_up.watch : wake_up
74
97
  processed = run_once
75
- next if processed.positive?
98
+ if processed.positive?
99
+ polling_backoff.reset(:work)
100
+ next
101
+ end
76
102
 
77
- SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
103
+ notified = watch.wait(timeout: current_polling_interval)
104
+ if notified == false
105
+ polling_backoff.record_idle
106
+ else
107
+ polling_backoff.reset(:wake_up)
108
+ end
78
109
  end
79
110
  ensure
80
111
  stop
@@ -107,9 +138,14 @@ module SolidObjects
107
138
  @shutdown_requested
108
139
  end
109
140
 
141
+ # @rbs () -> Float
142
+ def current_polling_interval
143
+ polling_backoff.current_interval
144
+ end
145
+
110
146
  private
111
147
 
112
- attr_reader :process_registry, :activation_manager, :activations
148
+ attr_reader :process_registry, :activation_manager, :activations, :polling_backoff
113
149
 
114
150
  # @rbs () -> Activation?
115
151
  def cached_ready_activation
data/lib/solid_objects.rb CHANGED
@@ -52,6 +52,7 @@ require "solid_objects/wake_up"
52
52
  require "solid_objects/wake_up_adapters/postgresql"
53
53
  require "solid_objects/wake_up_adapters/redis"
54
54
  require "solid_objects/wake_up_adapters"
55
+ require "solid_objects/polling_backoff"
55
56
  require "solid_objects/effect_registry"
56
57
  require "solid_objects/commit_action_registry"
57
58
  require "solid_objects/lease"
@@ -153,6 +154,7 @@ module SolidObjects
153
154
 
154
155
  # @rbs () -> void
155
156
  def reset!
157
+ ProcessRegistry.reset_polling_warning! if defined?(ProcessRegistry)
156
158
  @configuration = Configuration.new
157
159
  @registry = ActorRegistry.new
158
160
  @client = nil
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidObjects
4
4
  class BroadcastExecutor
5
+ @polling_backoff: PollingBackoff
6
+
5
7
  @shutdown_requested: bool
6
8
 
7
9
  @stopped: bool
@@ -31,12 +33,17 @@ module SolidObjects
31
33
  # @rbs () -> bool
32
34
  def shutdown_requested?: () -> bool
33
35
 
36
+ # @rbs () -> Float
37
+ def current_polling_interval: () -> Float
38
+
34
39
  private
35
40
 
36
41
  attr_reader process_registry: untyped
37
42
 
38
43
  attr_reader database_adapter: untyped
39
44
 
45
+ attr_reader polling_backoff: untyped
46
+
40
47
  # @rbs () -> Broadcast?
41
48
  def claim_next: () -> Broadcast?
42
49
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Configuration
5
+ @table_name_prefix: String
6
+
5
7
  @process_alive_threshold: Float
6
8
 
7
9
  @shutdown_timeout: Float
@@ -56,10 +58,10 @@ module SolidObjects
56
58
 
57
59
  @authorize_administration: Proc
58
60
 
59
- @table_name_prefix: String
60
-
61
61
  @polling_interval: Float
62
62
 
63
+ @idle_polling_interval: Float
64
+
63
65
  @sync_polling_interval: Float
64
66
 
65
67
  @lease_duration: Float
@@ -94,6 +96,8 @@ module SolidObjects
94
96
 
95
97
  attr_accessor polling_interval: untyped
96
98
 
99
+ attr_accessor idle_polling_interval: untyped
100
+
97
101
  attr_accessor sync_polling_interval: untyped
98
102
 
99
103
  attr_accessor lease_duration: untyped
@@ -23,6 +23,8 @@ module SolidObjects
23
23
  class EffectExecutor
24
24
  ACTOR_MESSAGE_EFFECT: ::String
25
25
 
26
+ @polling_backoff: PollingBackoff
27
+
26
28
  @shutdown_requested: bool
27
29
 
28
30
  @stopped: bool
@@ -52,12 +54,17 @@ module SolidObjects
52
54
  # @rbs () -> bool
53
55
  def shutdown_requested?: () -> bool
54
56
 
57
+ # @rbs () -> Float
58
+ def current_polling_interval: () -> Float
59
+
55
60
  private
56
61
 
57
62
  attr_reader process_registry: untyped
58
63
 
59
64
  attr_reader database_adapter: untyped
60
65
 
66
+ attr_reader polling_backoff: untyped
67
+
61
68
  # @rbs () -> Effect?
62
69
  def claim_next: () -> Effect?
63
70
 
@@ -0,0 +1,29 @@
1
+ # Generated from lib/solid_objects/polling_backoff.rb with RBS::Inline
2
+
3
+ module SolidObjects
4
+ class PollingBackoff
5
+ @minimum_interval: Float
6
+
7
+ @maximum_interval: Float
8
+
9
+ @current_interval: Float
10
+
11
+ @on_change: Proc?
12
+
13
+ attr_reader current_interval: untyped
14
+
15
+ # @rbs (minimum_interval: Numeric, maximum_interval: Numeric, ?on_change: Proc?) -> void
16
+ def initialize: (minimum_interval: Numeric, maximum_interval: Numeric, ?on_change: Proc?) -> void
17
+
18
+ # @rbs () -> void
19
+ def record_idle: () -> void
20
+
21
+ # @rbs (:work | :wake_up) -> void
22
+ def reset: (:work | :wake_up) -> void
23
+
24
+ private
25
+
26
+ # @rbs (Float, :idle | :work | :wake_up) -> void
27
+ def change: (Float, :idle | :work | :wake_up) -> void
28
+ end
29
+ end
@@ -8,6 +8,21 @@ module SolidObjects
8
8
  # @rbs (Process, ?now: Time) -> bool
9
9
  def self.deregister: (Process, ?now: Time) -> bool
10
10
 
11
+ # @rbs () -> void
12
+ def self.warn_if_polling_is_only_cross_process_wake_up: () -> void
13
+
14
+ # @rbs () -> void
15
+ def self.reset_polling_warning!: () -> void
16
+
17
+ # @rbs () -> bool
18
+ private def self.another_live_process?: () -> bool
19
+
20
+ # @rbs () -> bool
21
+ private def self.polling_warning_emitted?: () -> bool
22
+
23
+ # @rbs () -> Mutex
24
+ private def self.polling_warning_mutex: () -> Mutex
25
+
11
26
  # @rbs (Process, Time) -> void
12
27
  private def self.cleanup_process: (Process, Time) -> void
13
28
 
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidObjects
4
4
  class ReminderScheduler
5
+ @polling_backoff: PollingBackoff
6
+
5
7
  @shutdown_requested: bool
6
8
 
7
9
  @stopped: bool
@@ -31,12 +33,17 @@ module SolidObjects
31
33
  # @rbs () -> bool
32
34
  def shutdown_requested?: () -> bool
33
35
 
36
+ # @rbs () -> Float
37
+ def current_polling_interval: () -> Float
38
+
34
39
  private
35
40
 
36
41
  attr_reader process_registry: untyped
37
42
 
38
43
  attr_reader database_adapter: untyped
39
44
 
45
+ attr_reader polling_backoff: untyped
46
+
40
47
  # @rbs (now: Time?) -> Reminder?
41
48
  def claim_next: (now: Time?) -> Reminder?
42
49
 
@@ -2,18 +2,35 @@
2
2
 
3
3
  module SolidObjects
4
4
  class WakeUp
5
+ class Watch
6
+ @wake_up: WakeUp
7
+
8
+ @generation: Integer
9
+
10
+ # @rbs (WakeUp, Integer) -> void
11
+ def initialize: (WakeUp, Integer) -> void
12
+
13
+ # @rbs (timeout: Numeric) -> bool
14
+ def wait: (timeout: Numeric) -> bool
15
+ end
16
+
5
17
  @mutex: Mutex
6
18
 
7
19
  @condition: Thread::ConditionVariable
8
20
 
21
+ @generation: Integer
22
+
9
23
  # @rbs () -> void
10
24
  def initialize: () -> void
11
25
 
12
26
  # @rbs () -> void
13
27
  def signal: () -> void
14
28
 
15
- # @rbs (timeout: Numeric) -> void
16
- def wait: (timeout: Numeric) -> void
29
+ # @rbs () -> Watch
30
+ def watch: () -> Watch
31
+
32
+ # @rbs (timeout: Numeric, ?generation: Integer?) -> bool
33
+ def wait: (timeout: Numeric, ?generation: Integer?) -> bool
17
34
 
18
35
  private
19
36
 
@@ -31,6 +31,9 @@ module SolidObjects
31
31
  # @rbs (timeout: Numeric) -> bool
32
32
  def wait: (timeout: Numeric) -> bool
33
33
 
34
+ # @rbs () -> self
35
+ def watch: () -> self
36
+
34
37
  # Starts listening before a caller blocks, so a notification sent between
35
38
  # startup and the first wait is not missed.
36
39
  # @rbs () -> bool
@@ -10,6 +10,18 @@ module SolidObjects
10
10
  # polling interval remains the upper bound, so a missed or failed
11
11
  # notification costs latency rather than correctness.
12
12
  class Redis
13
+ class Watch
14
+ @adapter: Redis
15
+
16
+ @generation: Integer
17
+
18
+ # @rbs (Redis, Integer) -> void
19
+ def initialize: (Redis, Integer) -> void
20
+
21
+ # @rbs (timeout: Numeric) -> bool
22
+ def wait: (timeout: Numeric) -> bool
23
+ end
24
+
13
25
  CHANNEL: ::String
14
26
 
15
27
  FAILED_WAIT_INTERVAL: ::Float
@@ -43,8 +55,11 @@ module SolidObjects
43
55
  # The counter is snapshotted before subscribing, and re-checked before
44
56
  # blocking, so a signal delivered while this caller was still getting
45
57
  # ready is observed rather than absorbed into the new baseline.
46
- # @rbs (timeout: Numeric) -> bool
47
- def wait: (timeout: Numeric) -> bool
58
+ # @rbs (timeout: Numeric, ?generation: Integer?) -> bool
59
+ def wait: (timeout: Numeric, ?generation: Integer?) -> bool
60
+
61
+ # @rbs () -> Watch
62
+ def watch: () -> Watch
48
63
 
49
64
  # Redis delivers to a subscribed connection only, and a subscribed
50
65
  # connection cannot serve other callers, so one background subscription
@@ -2,6 +2,8 @@
2
2
 
3
3
  module SolidObjects
4
4
  class Worker
5
+ @polling_backoff: PollingBackoff
6
+
5
7
  @shutdown_requested: bool
6
8
 
7
9
  @stopped: bool
@@ -36,6 +38,9 @@ module SolidObjects
36
38
  # @rbs () -> bool
37
39
  def shutdown_requested?: () -> bool
38
40
 
41
+ # @rbs () -> Float
42
+ def current_polling_interval: () -> Float
43
+
39
44
  private
40
45
 
41
46
  attr_reader process_registry: untyped
@@ -44,6 +49,8 @@ module SolidObjects
44
49
 
45
50
  attr_reader activations: untyped
46
51
 
52
+ attr_reader polling_backoff: untyped
53
+
47
54
  # @rbs () -> Activation?
48
55
  def cached_ready_activation: () -> Activation?
49
56
 
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: solid_objects
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.0
4
+ version: 0.13.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Lucas Carlson
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-15 00:00:00.000000000 Z
11
+ date: 2026-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: actioncable
@@ -319,6 +319,7 @@ files:
319
319
  - benchmark/concurrent_actors.rb
320
320
  - benchmark/enqueue.rb
321
321
  - benchmark/hot_actor.rb
322
+ - benchmark/idle_polling.rb
322
323
  - benchmark/processing.rb
323
324
  - benchmark/query_count.rb
324
325
  - benchmark/support.rb
@@ -419,6 +420,7 @@ files:
419
420
  - lib/solid_objects/message_reference.rb
420
421
  - lib/solid_objects/operation_dispatcher.rb
421
422
  - lib/solid_objects/payload_broadcast.rb
423
+ - lib/solid_objects/polling_backoff.rb
422
424
  - lib/solid_objects/process_pruner.rb
423
425
  - lib/solid_objects/process_registry.rb
424
426
  - lib/solid_objects/reference.rb
@@ -504,6 +506,7 @@ files:
504
506
  - sig/generated/lib/solid_objects/message_reference.rbs
505
507
  - sig/generated/lib/solid_objects/operation_dispatcher.rbs
506
508
  - sig/generated/lib/solid_objects/payload_broadcast.rbs
509
+ - sig/generated/lib/solid_objects/polling_backoff.rbs
507
510
  - sig/generated/lib/solid_objects/process_pruner.rbs
508
511
  - sig/generated/lib/solid_objects/process_registry.rbs
509
512
  - sig/generated/lib/solid_objects/reference.rbs