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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +23 -0
- data/README.md +11 -2
- data/benchmark/idle_polling.rb +98 -0
- data/benchmark/support.rb +2 -0
- data/docs/adr/0011-wake-up-strategy.md +6 -0
- data/docs/architecture.md +2 -2
- data/docs/benchmarks.md +38 -0
- data/docs/development.md +1 -0
- data/docs/operations.md +24 -0
- data/docs/roadmap.md +8 -5
- data/lib/solid_objects/broadcast_executor.rb +35 -4
- data/lib/solid_objects/configuration.rb +4 -0
- data/lib/solid_objects/effect_executor.rb +34 -3
- data/lib/solid_objects/polling_backoff.rb +45 -0
- data/lib/solid_objects/process_registry.rb +51 -0
- data/lib/solid_objects/reminder_scheduler.rb +35 -4
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up.rb +36 -4
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
- data/lib/solid_objects/worker.rb +39 -3
- data/lib/solid_objects.rb +2 -0
- data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
- data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
- data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
- data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
- data/sig/generated/lib/solid_objects/worker.rbs +7 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5bd1724baaa40d79ec3693558ef7aa8e37830e4c5afd1da1d5774356d1e584e
|
|
4
|
+
data.tar.gz: 1e7a2307cae51316b9b4c4f56ea6fc986a622e3665541514137d87bbf72b2e9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
-
|
|
1303
|
-
|
|
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,
|
|
369
|
-
PostgreSQL `LISTEN/NOTIFY
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
73
|
+
if worked
|
|
74
|
+
polling_backoff.reset(:work)
|
|
75
|
+
next
|
|
76
|
+
end
|
|
56
77
|
|
|
57
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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:)
|
|
@@ -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
|
|
34
|
+
mutex.synchronize do
|
|
35
|
+
@generation += 1
|
|
36
|
+
condition.broadcast
|
|
37
|
+
end
|
|
17
38
|
end
|
|
18
39
|
|
|
19
|
-
# @rbs (
|
|
20
|
-
def
|
|
21
|
-
mutex.synchronize {
|
|
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
|
|
@@ -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
|
data/lib/solid_objects/worker.rb
CHANGED
|
@@ -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
|
-
|
|
98
|
+
if processed.positive?
|
|
99
|
+
polling_backoff.reset(:work)
|
|
100
|
+
next
|
|
101
|
+
end
|
|
76
102
|
|
|
77
|
-
|
|
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 (
|
|
16
|
-
def
|
|
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.
|
|
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-
|
|
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
|