solid_objects 0.13.0 → 0.13.2
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 +37 -0
- data/README.md +49 -10
- 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 +32 -0
- data/docs/roadmap.md +8 -5
- data/lib/solid_objects/actor.rb +54 -4
- data/lib/solid_objects/administration.rb +44 -0
- 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/executor.rb +5 -5
- 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 +9 -0
- data/sig/generated/lib/solid_objects/actor.rbs +35 -6
- data/sig/generated/lib/solid_objects/administration.rbs +13 -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/executor.rbs +4 -4
- 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
- data/sig/generated/lib/solid_objects.rbs +3 -0
- metadata +7 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: c1043a832a75d5e7d2c9e59b332c3c6bce85a322204fdc2a056dc5198018d34d
|
|
4
|
+
data.tar.gz: 4b1505d23bab1aeb57dc722a8a613fd3c39911d2756e24c37714466e4c821b22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2d7c216e135b8e34c19b9130a0fcad0df6246191ee097f9a560497d0dacc47fc480e549c1ddd6ab2bc6fe9bc513245236aa47cc1650a08906a04367d0e6bd008
|
|
7
|
+
data.tar.gz: d1dc2001e0b43a2df5eb24c5cd79c6e1df99b2d6c46c90146a349dd09a557db9dcdaa7c51e51a47f5db1667b364c2dfe64d5ba73fe877248a0143e022045c764
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.2 - 2026-08-17
|
|
4
|
+
|
|
5
|
+
- Accept a `key:` on `schedule`, naming a reminder for the item it is waiting
|
|
6
|
+
on rather than for its operation, so one actor can hold an alarm per queued
|
|
7
|
+
item. Scheduling the same key again moves that item's alarm and leaves the
|
|
8
|
+
others alone. Without a key the name is still the operation, so existing
|
|
9
|
+
reminders keep their names and their coalescing behaviour. A reminder
|
|
10
|
+
operation may no longer hold the colon that separates a key, which keeps
|
|
11
|
+
keyed and unkeyed names disjoint, and the length is checked on the composed
|
|
12
|
+
name rather than the key alone.
|
|
13
|
+
- Add an authorized `SolidObjects.administration.processes` query for
|
|
14
|
+
inspecting live and stale process rows through the runtime database adapter.
|
|
15
|
+
- Document rolling-deployment overlap as a reason for the polling-only warning.
|
|
16
|
+
|
|
17
|
+
## 0.13.1 - 2026-08-16
|
|
18
|
+
|
|
19
|
+
- Back idle actor, effect, reminder, and broadcast polling off exponentially
|
|
20
|
+
from the configured fast interval to a new one-second idle ceiling. Any
|
|
21
|
+
processed work or wake-up resets the role immediately, and actor polling
|
|
22
|
+
remains capped by the lease-renewal interval.
|
|
23
|
+
- Expose each role's `current_polling_interval` and emit
|
|
24
|
+
`solid_objects.polling.interval_changed` instrumentation for every idle,
|
|
25
|
+
work, and wake-up transition.
|
|
26
|
+
- Warn once when live processes share the database without a configured
|
|
27
|
+
cross-process wake-up adapter.
|
|
28
|
+
- Make the in-process wake-up generation-aware so a signal committed between
|
|
29
|
+
an empty claim and the wait is not missed. PostgreSQL and Redis adapters now
|
|
30
|
+
expose the same watch contract.
|
|
31
|
+
- Add a reproducible four-role SQLite idle benchmark and repair the benchmark
|
|
32
|
+
schema setup for the current operation columns.
|
|
33
|
+
- **Behavior change:** `polling_interval` is now the fast interval after
|
|
34
|
+
activity, not a constant idle cadence. Existing explicit values back off to
|
|
35
|
+
`idle_polling_interval`, which defaults to one second. Set both options to
|
|
36
|
+
the same value to preserve a fixed cadence. Existing custom wake-up adapters
|
|
37
|
+
that return `nil` remain at the fast cadence until they return `false` for a
|
|
38
|
+
timeout and `true` for a notification.
|
|
39
|
+
|
|
3
40
|
## 0.13.0 - 2026-08-15
|
|
4
41
|
|
|
5
42
|
- **Breaking:** make observables invalidation-only by default. An ordinary
|
data/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml)
|
|
4
4
|
|
|
5
|
-
**
|
|
5
|
+
**Self-hosted, distributed Durable Objects in Rails without a daemon using your existing SQL database.**
|
|
6
6
|
|
|
7
7
|
Solid Objects brings the Durable Objects programming model—addressable objects,
|
|
8
8
|
durable state, serialized turns, alarms, and live clients—to ordinary Rails
|
|
@@ -891,7 +891,7 @@ database enforces this with a unique index on `(instance_id, name)`.
|
|
|
891
891
|
|
|
892
892
|
This is the same model as Orleans reminders and Durable Objects alarms, and it
|
|
893
893
|
is what makes a reminder safe to re-arm from a handler that may run more than
|
|
894
|
-
once.
|
|
894
|
+
once. Without a key the name is the operation, so this is a data-loss bug:
|
|
895
895
|
|
|
896
896
|
```ruby
|
|
897
897
|
# Wrong. Every entry overwrites the previous entry's alarm.
|
|
@@ -904,8 +904,38 @@ end
|
|
|
904
904
|
Two entries leave one reminder. The earlier wake-up never happens, nothing
|
|
905
905
|
raises, and nothing is logged except a `solid_objects.reminder.replaced` event.
|
|
906
906
|
|
|
907
|
-
|
|
908
|
-
|
|
907
|
+
### An alarm per item, with `key:`
|
|
908
|
+
|
|
909
|
+
Pass `key:` when an actor is waiting on several things at once. The key is your
|
|
910
|
+
own identifier for the item, and it names that item's alarm, so each item gets
|
|
911
|
+
one:
|
|
912
|
+
|
|
913
|
+
```ruby
|
|
914
|
+
def add(entry:)
|
|
915
|
+
self.entries = entries + [ entry ]
|
|
916
|
+
schedule(at: entry.fetch("wait_until"), key: entry.fetch("id")).deliver
|
|
917
|
+
end
|
|
918
|
+
```
|
|
919
|
+
|
|
920
|
+
Two entries now leave two reminders. Scheduling the same key again moves that
|
|
921
|
+
item's alarm and leaves the others alone, which is what makes a keyed reminder
|
|
922
|
+
as safe to re-arm as an unkeyed one. The operation still decides which handler
|
|
923
|
+
runs; the key only decides which alarm is which.
|
|
924
|
+
|
|
925
|
+
A key must be non-empty, and the name it becomes must fit the 191-character
|
|
926
|
+
column, which is checked on the composed name rather than the key alone so a
|
|
927
|
+
long operation and a short key are caught too.
|
|
928
|
+
|
|
929
|
+
The key is separated from the operation by a colon, so an operation may not hold
|
|
930
|
+
one. Otherwise an unkeyed `deliver:item` and a `deliver` keyed `item` would be
|
|
931
|
+
one name, and the second would silently take the first one's alarm. A key may
|
|
932
|
+
hold colons of its own, because the operation before the first one cannot.
|
|
933
|
+
|
|
934
|
+
### One alarm for a whole queue
|
|
935
|
+
|
|
936
|
+
A key per item is not always what you want. An actor that only ever needs to
|
|
937
|
+
know "what is next" can keep one alarm and let the handler drain everything now
|
|
938
|
+
due before arming the next:
|
|
909
939
|
|
|
910
940
|
```ruby
|
|
911
941
|
def add(entry:)
|
|
@@ -931,10 +961,10 @@ def arm_next
|
|
|
931
961
|
end
|
|
932
962
|
```
|
|
933
963
|
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
964
|
+
That costs one reminder row instead of one per item, and a coalesced occurrence
|
|
965
|
+
cannot strand an entry because the handler drains by time rather than by alarm.
|
|
966
|
+
Prefer it when the queue is large and the items are interchangeable; prefer
|
|
967
|
+
`key:` when an item needs its own alarm that can be moved on its own.
|
|
938
968
|
|
|
939
969
|
Solid Objects has no `unschedule`. A reminder stops when its handler does not
|
|
940
970
|
re-arm it, and destroying an actor removes its reminders.
|
|
@@ -1014,6 +1044,7 @@ Important defaults:
|
|
|
1014
1044
|
| Setting | Default |
|
|
1015
1045
|
| --- | ---: |
|
|
1016
1046
|
| `polling_interval` | 0.1 seconds |
|
|
1047
|
+
| `idle_polling_interval` | 1 second |
|
|
1017
1048
|
| `sync_polling_interval` | 0.05 seconds |
|
|
1018
1049
|
| `lease_duration` | 30 seconds |
|
|
1019
1050
|
| `lease_renewal_interval` | 10 seconds |
|
|
@@ -1038,6 +1069,14 @@ Payload, state, and result limits; retry delay; table prefix; logging; wake-up;
|
|
|
1038
1069
|
broadcast; database; and authorization adapters are also configurable. Invalid
|
|
1039
1070
|
lease intervals, component counts, and size limits fail fast at boot.
|
|
1040
1071
|
|
|
1072
|
+
`polling_interval` is the fast interval after work or a wake-up. Consecutive
|
|
1073
|
+
empty passes double it up to `idle_polling_interval`. Actor workers never wait
|
|
1074
|
+
longer than `lease_renewal_interval`. Set the fast and idle values equal for a
|
|
1075
|
+
fixed cadence. The default wake-up reaches only the current Ruby process;
|
|
1076
|
+
configure PostgreSQL notifications or optional Redis Pub/Sub when separate
|
|
1077
|
+
processes need low-latency delivery. The runtime warns once when it sees that
|
|
1078
|
+
topology without an adapter.
|
|
1079
|
+
|
|
1041
1080
|
## Workers and operations
|
|
1042
1081
|
|
|
1043
1082
|
`solid_objects start` runs actor, effect, reminder, and broadcast roles under
|
|
@@ -1299,8 +1338,8 @@ Partially implemented:
|
|
|
1299
1338
|
|
|
1300
1339
|
- the supervisor starts and drains roles but does not replace a crashed role or
|
|
1301
1340
|
run periodic maintenance automatically;
|
|
1302
|
-
-
|
|
1303
|
-
|
|
1341
|
+
- PostgreSQL notifications and optional Redis acceleration are implemented,
|
|
1342
|
+
but adapter selection remains explicit and polling is the durable fallback;
|
|
1304
1343
|
- live observable and component replacement work, while Turbo append actions
|
|
1305
1344
|
remain future work;
|
|
1306
1345
|
- 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,37 @@ 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
|
+
The warning excludes process rows with the current hostname and PID. It can
|
|
102
|
+
therefore appear during a rolling deployment or restart overlap when an older
|
|
103
|
+
and newer process briefly share the database. A process that stopped without
|
|
104
|
+
graceful cleanup remains live until its heartbeat exceeds
|
|
105
|
+
`process_alive_threshold`; inspect `SolidObjects.administration.processes` to
|
|
106
|
+
distinguish a live overlap from a stale row without opening a second SQLite
|
|
107
|
+
connection.
|
|
108
|
+
|
|
109
|
+
Each role exposes `current_polling_interval`.
|
|
110
|
+
`solid_objects.polling.interval_changed` reports the role, reason, previous
|
|
111
|
+
interval, and current interval. The polling-only warning is also emitted as
|
|
112
|
+
`solid_objects.polling.only_cross_process_wake_up` instrumentation. Custom
|
|
113
|
+
adapters should return `true` for a notification and `false` for a timeout; an
|
|
114
|
+
older adapter that returns `nil` remains compatible and keeps the fast cadence.
|
|
115
|
+
|
|
84
116
|
## Graceful shutdown
|
|
85
117
|
|
|
86
118
|
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
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -4,7 +4,11 @@ module SolidObjects
|
|
|
4
4
|
class Actor
|
|
5
5
|
EffectIntent = Data.define(:name, :arguments, :success_operation, :failure_operation)
|
|
6
6
|
CommitActionIntent = Data.define(:name, :arguments)
|
|
7
|
-
|
|
7
|
+
# The reminders table holds a name in 191 characters.
|
|
8
|
+
REMINDER_NAME_LIMIT = 191
|
|
9
|
+
REMINDER_KEY_SEPARATOR = ":"
|
|
10
|
+
|
|
11
|
+
ReminderIntent = Data.define(:name, :operation, :at, :arguments, :interval_seconds, :missed_policy)
|
|
8
12
|
OutboundMessageIntent = Data.define(:actor_type, :actor_id, :operation, :arguments, :available_at, :idempotency_key)
|
|
9
13
|
|
|
10
14
|
class << self
|
|
@@ -197,8 +201,13 @@ module SolidObjects
|
|
|
197
201
|
nil
|
|
198
202
|
end
|
|
199
203
|
|
|
200
|
-
#
|
|
201
|
-
|
|
204
|
+
# A reminder is identified by its name, and without a key that name is the
|
|
205
|
+
# operation, so one actor holds one alarm per operation. A key gives an actor
|
|
206
|
+
# an alarm per item it is waiting on, which is what an actor holding a queue
|
|
207
|
+
# of scheduled work needs; the key is the caller's own identifier for the
|
|
208
|
+
# item, and scheduling the same key again moves that item's alarm.
|
|
209
|
+
# @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String, ?key: (String | Symbol | Integer)?) -> OperationDispatcher
|
|
210
|
+
def schedule(at:, every: nil, missed: :latest, key: nil)
|
|
202
211
|
interval_seconds = every&.to_f
|
|
203
212
|
if interval_seconds && !interval_seconds.positive?
|
|
204
213
|
raise ArgumentError, "reminder interval must be positive"
|
|
@@ -207,13 +216,15 @@ module SolidObjects
|
|
|
207
216
|
unless %w[all latest].include?(missed_policy)
|
|
208
217
|
raise ArgumentError, "missed reminder policy must be all or latest"
|
|
209
218
|
end
|
|
219
|
+
reminder_key = validated_reminder_key(key)
|
|
210
220
|
|
|
211
221
|
OperationDispatcher.new(
|
|
212
222
|
actor_type: self.class.actor_type,
|
|
213
223
|
handlers: self.class.definition.messages
|
|
214
224
|
) do |operation, arguments|
|
|
215
225
|
ReminderIntent.new(
|
|
216
|
-
name: operation
|
|
226
|
+
name: reminder_name(operation:, key: reminder_key),
|
|
227
|
+
operation: operation.to_s,
|
|
217
228
|
at:,
|
|
218
229
|
arguments: Serialization.dump(arguments),
|
|
219
230
|
interval_seconds:,
|
|
@@ -225,6 +236,45 @@ module SolidObjects
|
|
|
225
236
|
end
|
|
226
237
|
end
|
|
227
238
|
|
|
239
|
+
# @rbs ((String | Symbol | Integer)?) -> String?
|
|
240
|
+
def validated_reminder_key(key)
|
|
241
|
+
return nil if key.nil?
|
|
242
|
+
|
|
243
|
+
reminder_key = key.to_s
|
|
244
|
+
raise ArgumentError, "reminder key must not be empty" if reminder_key.empty?
|
|
245
|
+
|
|
246
|
+
reminder_key
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# A keyed name is the operation, a colon, and the key, so an operation
|
|
250
|
+
# holding a colon of its own would make two different schedules produce one
|
|
251
|
+
# name: an unkeyed "deliver:item" and a "deliver" keyed "item" would share a
|
|
252
|
+
# row, and the second would silently take the first one's alarm. Refusing a
|
|
253
|
+
# colon in the operation keeps unkeyed names free of colons, which leaves
|
|
254
|
+
# the two kinds of name disjoint and lets a key hold colons of its own.
|
|
255
|
+
#
|
|
256
|
+
# The length is checked on the composed name rather than the key alone,
|
|
257
|
+
# because a long operation and a short key can exceed the column just as
|
|
258
|
+
# easily as the reverse. Both are refused here rather than at the insert,
|
|
259
|
+
# once the turn is already doing work.
|
|
260
|
+
# @rbs (operation: Symbol | String, key: String?) -> String
|
|
261
|
+
def reminder_name(operation:, key:)
|
|
262
|
+
operation_name = operation.to_s
|
|
263
|
+
if operation_name.include?(REMINDER_KEY_SEPARATOR)
|
|
264
|
+
raise ArgumentError,
|
|
265
|
+
"reminder operation #{operation_name.inspect} must not contain #{REMINDER_KEY_SEPARATOR.inspect}"
|
|
266
|
+
end
|
|
267
|
+
return operation_name if key.nil?
|
|
268
|
+
|
|
269
|
+
name = "#{operation_name}#{REMINDER_KEY_SEPARATOR}#{key}"
|
|
270
|
+
if name.length > REMINDER_NAME_LIMIT
|
|
271
|
+
raise ArgumentError,
|
|
272
|
+
"reminder name #{name.length} characters exceeds the #{REMINDER_NAME_LIMIT} the database holds"
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
name
|
|
276
|
+
end
|
|
277
|
+
|
|
228
278
|
# @rbs (Reference, ?available_at: Time?, ?idempotency_key: String?) -> OperationDispatcher
|
|
229
279
|
def send_to(reference, available_at: nil, idempotency_key: nil)
|
|
230
280
|
actor_class = SolidObjects.registry.fetch(reference.actor_type)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
class Administration
|
|
5
|
+
# @rbs (?authorization_context: untyped) -> Array[Hash[Symbol, untyped]]
|
|
6
|
+
def processes(authorization_context: nil)
|
|
7
|
+
authorize!(authorization_context:)
|
|
8
|
+
now = SolidObjects.database_adapter.database_now
|
|
9
|
+
stale_at = now - SolidObjects.configuration.process_alive_threshold
|
|
10
|
+
|
|
11
|
+
Process.order(:kind, :started_at).map do |process_record|
|
|
12
|
+
{
|
|
13
|
+
id: process_record.id,
|
|
14
|
+
kind: process_record.kind,
|
|
15
|
+
hostname: process_record.hostname,
|
|
16
|
+
pid: process_record.pid,
|
|
17
|
+
metadata: Serialization.readonly_copy(process_record.metadata),
|
|
18
|
+
shutdown_state: process_record.shutdown_state,
|
|
19
|
+
shutdown_requested_at: process_record.shutdown_requested_at,
|
|
20
|
+
started_at: process_record.started_at,
|
|
21
|
+
last_heartbeat_at: process_record.last_heartbeat_at,
|
|
22
|
+
stopped_at: process_record.stopped_at,
|
|
23
|
+
stale: process_record.shutdown_state != "stopped" &&
|
|
24
|
+
process_record.last_heartbeat_at <= stale_at
|
|
25
|
+
}.freeze
|
|
26
|
+
end.freeze
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
# @rbs (?authorization_context: untyped) -> void
|
|
32
|
+
def authorize!(authorization_context: nil)
|
|
33
|
+
authorized = SolidObjects.configuration.authorize_administration.call(
|
|
34
|
+
action: :inspect,
|
|
35
|
+
resource: "processes",
|
|
36
|
+
resource_id: nil,
|
|
37
|
+
authorization_context:
|
|
38
|
+
)
|
|
39
|
+
return if authorized
|
|
40
|
+
|
|
41
|
+
raise Unauthorized, "actor administration is not authorized"
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -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
|