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.
Files changed (41) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +49 -10
  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 +32 -0
  11. data/docs/roadmap.md +8 -5
  12. data/lib/solid_objects/actor.rb +54 -4
  13. data/lib/solid_objects/administration.rb +44 -0
  14. data/lib/solid_objects/broadcast_executor.rb +35 -4
  15. data/lib/solid_objects/configuration.rb +4 -0
  16. data/lib/solid_objects/effect_executor.rb +34 -3
  17. data/lib/solid_objects/executor.rb +5 -5
  18. data/lib/solid_objects/polling_backoff.rb +45 -0
  19. data/lib/solid_objects/process_registry.rb +51 -0
  20. data/lib/solid_objects/reminder_scheduler.rb +35 -4
  21. data/lib/solid_objects/version.rb +1 -1
  22. data/lib/solid_objects/wake_up.rb +36 -4
  23. data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
  24. data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
  25. data/lib/solid_objects/worker.rb +39 -3
  26. data/lib/solid_objects.rb +9 -0
  27. data/sig/generated/lib/solid_objects/actor.rbs +35 -6
  28. data/sig/generated/lib/solid_objects/administration.rbs +13 -0
  29. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
  30. data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
  31. data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
  32. data/sig/generated/lib/solid_objects/executor.rbs +4 -4
  33. data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
  34. data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
  35. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
  36. data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
  37. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
  38. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
  39. data/sig/generated/lib/solid_objects/worker.rbs +7 -0
  40. data/sig/generated/lib/solid_objects.rbs +3 -0
  41. metadata +7 -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: c1043a832a75d5e7d2c9e59b332c3c6bce85a322204fdc2a056dc5198018d34d
4
+ data.tar.gz: 4b1505d23bab1aeb57dc722a8a613fd3c39911d2756e24c37714466e4c821b22
5
5
  SHA512:
6
- metadata.gz: 335cec6b8f87328e9a88eee32bbd8aaca5b019cb0ef76dfa70f5efa261afb81a2a034d3bc6a11189f22c4809583d9cce1538c6ecb1ff8cc8319c1d195fec8295
7
- data.tar.gz: 445c227d5badd737cb44f9b44b87917b73c3e2a55a66acb6a4cd1fe09a39febda314f00188cd35eabc70acef5d5b95503c3cedf5c35632e22948fa62667a62db
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
  [![CI](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml/badge.svg)](https://github.com/cardmagic/solid_objects/actions/workflows/ci.yml)
4
4
 
5
- **Cloudflare Durable Objects, ported to Rails.**
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. It also means this is a data-loss bug:
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
- Arm one alarm for the earliest item instead, and let the handler drain
908
- everything now due before arming the next:
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
- `deliver` drains every due item rather than one, so a single alarm serves a
935
- whole queue and a missed or coalesced occurrence cannot strand an entry. Use a
936
- distinct reminder name only when you genuinely need two independent alarms on
937
- one actor, such as `:deliver` and `:sweep`.
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
- - cross-process wake-up uses polling; PostgreSQL notifications and optional
1303
- Redis acceleration are not implemented;
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, 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,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
- `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
@@ -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
- ReminderIntent = Data.define(:name, :at, :arguments, :interval_seconds, :missed_policy)
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
- # @rbs (at: Time, ?every: Numeric?, ?missed: Symbol | String) -> OperationDispatcher
201
- def schedule(at:, every: nil, missed: :latest)
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.to_s,
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
- 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