solid_objects 0.12.1 → 0.13.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +60 -0
  3. data/README.md +69 -13
  4. data/benchmark/idle_polling.rb +98 -0
  5. data/benchmark/support.rb +2 -0
  6. data/docs/adr/0009-realtime-updates.md +5 -1
  7. data/docs/adr/0011-wake-up-strategy.md +6 -0
  8. data/docs/architecture.md +5 -4
  9. data/docs/authorization.md +11 -4
  10. data/docs/benchmarks.md +38 -0
  11. data/docs/correctness.md +3 -3
  12. data/docs/dashboard.md +200 -0
  13. data/docs/database-schema.md +5 -4
  14. data/docs/development.md +1 -0
  15. data/docs/operations.md +24 -0
  16. data/docs/realtime.md +22 -14
  17. data/docs/roadmap.md +32 -11
  18. data/docs/security.md +7 -7
  19. data/examples/application/README.md +5 -5
  20. data/examples/application/app/actors/chat_room_actor.rb +1 -1
  21. data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
  22. data/lib/solid_objects/actor.rb +1 -1
  23. data/lib/solid_objects/broadcast_executor.rb +35 -4
  24. data/lib/solid_objects/configuration.rb +4 -0
  25. data/lib/solid_objects/effect_executor.rb +34 -3
  26. data/lib/solid_objects/polling_backoff.rb +45 -0
  27. data/lib/solid_objects/process_registry.rb +51 -0
  28. data/lib/solid_objects/reminder_scheduler.rb +35 -4
  29. data/lib/solid_objects/version.rb +1 -1
  30. data/lib/solid_objects/wake_up.rb +36 -4
  31. data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
  32. data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
  33. data/lib/solid_objects/web/action.rb +109 -0
  34. data/lib/solid_objects/web/application.rb +239 -0
  35. data/lib/solid_objects/web/csrf_protection.rb +130 -0
  36. data/lib/solid_objects/web/helpers.rb +227 -0
  37. data/lib/solid_objects/web/paginator.rb +64 -0
  38. data/lib/solid_objects/web/route.rb +55 -0
  39. data/lib/solid_objects/web/router.rb +46 -0
  40. data/lib/solid_objects/web/statistics.rb +78 -0
  41. data/lib/solid_objects/web.rb +222 -0
  42. data/lib/solid_objects/worker.rb +39 -3
  43. data/lib/solid_objects.rb +2 -0
  44. data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
  45. data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
  46. data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
  47. data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
  48. data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
  49. data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
  50. data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
  51. data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
  52. data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
  53. data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
  54. data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
  55. data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
  56. data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
  57. data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
  58. data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
  59. data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
  60. data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
  61. data/sig/generated/lib/solid_objects/web.rbs +129 -0
  62. data/sig/generated/lib/solid_objects/worker.rbs +7 -0
  63. data/web/assets/javascripts/application.js +118 -0
  64. data/web/assets/javascripts/charts.js +190 -0
  65. data/web/assets/stylesheets/application.css +527 -0
  66. data/web/views/_messages.erb +29 -0
  67. data/web/views/_navigation.erb +15 -0
  68. data/web/views/_paging.erb +17 -0
  69. data/web/views/_status_filter.erb +8 -0
  70. data/web/views/_summary.erb +39 -0
  71. data/web/views/broadcasts.erb +35 -0
  72. data/web/views/dashboard.erb +128 -0
  73. data/web/views/dead_letter.erb +40 -0
  74. data/web/views/dead_letters.erb +42 -0
  75. data/web/views/effects.erb +35 -0
  76. data/web/views/instance.erb +139 -0
  77. data/web/views/instances.erb +49 -0
  78. data/web/views/layout.erb +22 -0
  79. data/web/views/mailbox.erb +35 -0
  80. data/web/views/message.erb +34 -0
  81. data/web/views/processes.erb +37 -0
  82. data/web/views/reminders.erb +37 -0
  83. metadata +58 -2
data/docs/dashboard.md ADDED
@@ -0,0 +1,200 @@
1
+ # Operator dashboard
2
+
3
+ `SolidObjects::Web` is a Rack application that shows what the actor runtime is
4
+ doing: instances and their state, the mailbox, reminders, effects, broadcasts,
5
+ dead letters, and the registered processes. It reads the same tables the
6
+ runtime writes, so it needs no separate store and no agent.
7
+
8
+ It is deliberately not loaded by `require "solid_objects"`. A worker process
9
+ must not carry a web stack, and an application that never mounts the dashboard
10
+ must not pay for it.
11
+
12
+ ## Mounting
13
+
14
+ ```ruby
15
+ # config/routes.rb
16
+ require "solid_objects/web"
17
+
18
+ Rails.application.routes.draw do
19
+ mount SolidObjects::Web => "/solid_objects/dashboard"
20
+ end
21
+ ```
22
+
23
+ Mount it inside the application routes so the Rails session middleware runs
24
+ first. The dashboard needs a Rack session for CSRF protection and refuses a
25
+ state changing request without one.
26
+
27
+ The dashboard and the engine are separate mounts. Mount the engine as well if
28
+ the application uses reactive ERB, and give each one its own path:
29
+
30
+ ```ruby
31
+ mount SolidObjects::Engine => "/solid_objects"
32
+ mount SolidObjects::Web => "/solid_objects/dashboard"
33
+ ```
34
+
35
+ In a bare Rack application, supply the session middleware yourself:
36
+
37
+ ```ruby
38
+ use Rack::Session::Cookie, secret: ENV.fetch("SESSION_SECRET"), same_site: true
39
+ run SolidObjects::Web
40
+ ```
41
+
42
+ ## Authorization
43
+
44
+ Every page asks `configuration.authorize_administration` before its handler
45
+ runs. That policy denies by default, so a mount alone exposes nothing. A route
46
+ declared without a policy raises at load time, which is why a new page cannot
47
+ reach the actor tables before an application has said who may read it.
48
+
49
+ The block receives the route's own action and resource:
50
+
51
+ | Page | `action` | `resource` | `resource_id` |
52
+ | --- | --- | --- | --- |
53
+ | Dashboard, `GET /stats`, `HEAD /` | `index` | `dashboard` | none |
54
+ | Instance list | `index` | `instances` | none |
55
+ | Instance detail | `show` | `instances` | instance id |
56
+ | Pause an instance | `pause` | `instances` | instance id |
57
+ | Resume an instance | `resume` | `instances` | instance id |
58
+ | Mailbox | `index` | `messages` | none |
59
+ | Message detail | `show` | `messages` | message id |
60
+ | Reminders | `index` | `reminders` | none |
61
+ | Effects | `index` | `effects` | none |
62
+ | Broadcasts | `index` | `broadcasts` | none |
63
+ | Dead letter list | `index` | `dead_letters` | none |
64
+ | Dead letter detail | `show` | `dead_letters` | dead letter id |
65
+ | Retry a dead letter | `retry` | `dead_letters` | dead letter id |
66
+ | Processes | `index` | `processes` | none |
67
+
68
+ `authorization_context:` is the request object. It answers `request`,
69
+ `session`, and `env`, so a policy can read the signed-in operator the same way
70
+ a controller does:
71
+
72
+ ```ruby
73
+ SolidObjects.configure do |configuration|
74
+ configuration.authorize_administration = lambda do |action:, authorization_context:, **|
75
+ return false unless authorization_context.respond_to?(:session)
76
+
77
+ operator = Operator.find_by(id: authorization_context.session[:operator_id])
78
+ return false unless operator&.administrator?
79
+
80
+ action == "index" || action == "show" || operator.may_write_runtime?
81
+ end
82
+ end
83
+ ```
84
+
85
+ The command line reaches the same policy with `{ source: "cli" }` rather than
86
+ a request, which is why the example checks what the context answers before
87
+ reading a session from it.
88
+
89
+ ## Pages
90
+
91
+ **Dashboard.** Totals per subsystem, the registered processes, and the most
92
+ recent dead letters. The summary bar appears on every page and can poll
93
+ `GET /stats` for the same numbers; nothing else on the page refreshes, because
94
+ a table that reloads under an operator who is reading it is worse than a stale
95
+ one.
96
+
97
+ **Instances.** Filter by actor type and by an actor id substring. Each row
98
+ shows the lease state: `idle`, `activated`, `expired`, or `paused`. The detail
99
+ page shows committed state, the ready and claimed mailbox, message history,
100
+ reminders, effects, broadcasts, and dead letters for that identity.
101
+
102
+ **Mailbox.** The ready and claimed messages across every identity, oldest
103
+ first. Mailbox lag on the summary bar is the age of the oldest message that is
104
+ already due, which is how far behind the workers are.
105
+
106
+ **Reminders, effects, broadcasts, processes.** Status filtered lists.
107
+
108
+ ## Charts
109
+
110
+ The dashboard draws three charts: instances per actor type, mailbox depth, and
111
+ a stacked view of effects, broadcasts, and reminders by status. Each canvas
112
+ carries its own numbers in a `data-chart-values` attribute, so the page needs
113
+ no inline script and no request to draw. Mailbox depth and the status chart
114
+ redraw when the Live poller reports new totals, because `/stats` already
115
+ carries those numbers. The instance chart does not: `/stats` does not group by
116
+ actor type, and adding that would put a `GROUP BY` on every poll.
117
+
118
+ Chart.js comes from a CDN with a subresource integrity hash, so a compromised
119
+ CDN cannot substitute other code, and the CDN host is the only external origin
120
+ the content security policy names.
121
+
122
+ A deployment with no outbound network access should vendor the file:
123
+
124
+ ```ruby
125
+ SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
126
+ SolidObjects::Web.chart_library_integrity = nil
127
+ ```
128
+
129
+ A path below the mount is served from the dashboard's own asset directory and
130
+ needs no policy exception. Setting the URL to `nil` renders the dashboard
131
+ without charts and names no external origin at all.
132
+
133
+ Set these before the first request. The middleware stack and the compiled
134
+ templates are built once and cached.
135
+
136
+ **Dead letters.** The exception, its message, and its backtrace, with a retry
137
+ button.
138
+
139
+ ## Actions
140
+
141
+ The dashboard changes only two things.
142
+
143
+ **Retry a dead letter** goes through `SolidObjects.dead_letters.retry`, which
144
+ enqueues the original operation under an idempotency key. Pressing it twice
145
+ produces one message rather than two.
146
+
147
+ A retry re-enters the mailbox, which refuses work the runtime cannot accept: an
148
+ actor class that no longer exists, a full mailbox, a payload over the cap. The
149
+ dashboard renders the dead letter again with the reason and a 422 status,
150
+ rather than failing the request.
151
+
152
+ **Pause an instance** sets `paused_at`, and the activation manager stops
153
+ claiming that identity. Two consequences matter:
154
+
155
+ - A pass already in flight finishes its turn. Pause is not a stop.
156
+ - A synchronous caller waiting on a paused instance times out rather than
157
+ receiving a result, because nothing will execute its message.
158
+
159
+ Resume clears the column and the mailbox drains in sequence order.
160
+
161
+ ## Extensions
162
+
163
+ An extension adds pages by declaring routes on the application class. Its
164
+ routes carry an authorization policy like every other route:
165
+
166
+ ```ruby
167
+ module Tenants
168
+ def self.registered(application)
169
+ application.get "/tenants", policy: { action: "index", resource: "tenants" } do
170
+ @tenants = Tenant.order(:name)
171
+ erb(:tenants)
172
+ end
173
+ end
174
+ end
175
+
176
+ SolidObjects::Web.register(
177
+ Tenants,
178
+ tab: "Tenants",
179
+ path: "/tenants",
180
+ views: File.expand_path("../web/views", __dir__)
181
+ )
182
+ ```
183
+
184
+ A registered view directory is searched before the packaged one, so an
185
+ application can replace a single page without forking the gem. A template
186
+ reads its arguments from `locals`, and a replacement `layout.erb` renders the
187
+ page it wraps with `locals.fetch(:content)`.
188
+
189
+ Add Rack middleware in front of the dashboard with `SolidObjects::Web.use`,
190
+ for example to require HTTP basic authentication in an environment that has no
191
+ session-backed operator.
192
+
193
+ ## Cost
194
+
195
+ The summary bar issues one grouped count per subsystem on every page, and each
196
+ list page counts its own relation to page it. That is a fixed set of indexed
197
+ aggregate queries, not a scan proportional to actor traffic, but it is not
198
+ free: do not put the dashboard behind an uptime monitor that loads the whole
199
+ page on an interval. `HEAD /` exists for that. It touches one table and
200
+ returns no body.
@@ -92,10 +92,11 @@ Status/availability/ID drives delivery; completion/ID drives cleanup.
92
92
  ### `broadcasts`
93
93
 
94
94
  Durable observable-change outbox. The unique message/observable key prevents
95
- duplicate rows for one actor turn. Rows contain the observable JSON value, or
96
- `{}` for an invalidation-only observable, plus message/instance references used to derive invalidation metadata, never
97
- personalized rendered HTML. Claim and delivery indexes support retries and
98
- cleanup.
95
+ duplicate rows for one actor turn. Rows contain `{}` for the default
96
+ invalidation-only observable, or the observable JSON value after an explicit
97
+ `broadcast: :value` opt-in, plus message/instance references used to derive
98
+ invalidation metadata, never personalized rendered HTML. Claim and delivery
99
+ indexes support retries and cleanup.
99
100
 
100
101
  ### `dead_letters`
101
102
 
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/realtime.md CHANGED
@@ -13,12 +13,19 @@
13
13
  <% end %>
14
14
  ```
15
15
 
16
- Every observable gets a stable opaque DOM ID. Multiple values share the one
17
- actor subscription and Action Cable multiplexes actor subscriptions over the
18
- browser's physical WebSocket.
16
+ Every value-broadcast observable gets a stable opaque DOM ID. Multiple values
17
+ share the one actor subscription and Action Cable multiplexes actor
18
+ subscriptions over the browser's physical WebSocket.
19
19
 
20
20
  Scalar observable calls such as `cart.items_count` render stable `<span>`
21
- targets. Their broadcast remains a direct escaped text replacement.
21
+ targets. Their broadcast remains a direct escaped text replacement, and their
22
+ declaration must explicitly opt in with `broadcast: :value`:
23
+
24
+ ```ruby
25
+ observable :items_count, broadcast: :value do
26
+ items.sum { |item| item.fetch("quantity") }
27
+ end
28
+ ```
22
29
 
23
30
  A reactive component declares one or more explicit observable dependencies.
24
31
  `actor.component(:summary, observes: ...)` resolves the host partial by
@@ -65,16 +72,12 @@ listed in `observes:` raises `UnknownComponentDependency`. This keeps
65
72
  invalidation correct and prevents a partial from silently depending on state
66
73
  that cannot wake it.
67
74
 
68
- Observable values are shared projections. By default, each changed value is
69
- stored in the broadcast outbox and can be sent as a scalar Turbo replacement to
70
- every subscriber that passes `authorize_subscription`. Authorization to the
71
- actor stream is not a per-viewer projection.
72
-
73
- For a component dependency whose value must never enter the durable outbox or
74
- Action Cable frame, declare it invalidation-only:
75
+ Observables are invalidation-only by default. Their values never enter the
76
+ durable outbox or Action Cable frame, so the ordinary declaration is the safe
77
+ choice for component dependencies:
75
78
 
76
79
  ```ruby
77
- observable :player_one, broadcast: :invalidation do
80
+ observable :player_one do
78
81
  player_in_seat(1)
79
82
  end
80
83
  ```
@@ -86,6 +89,10 @@ scalar value such as `actor.player_one`. The component endpoint reads the
86
89
  latest committed value and authorizes it again; subscriber-specific state
87
90
  belongs in `broadcast_payload`.
88
91
 
92
+ Use `broadcast: :value` only for a shared projection that may be stored and
93
+ sent to every subscriber that passes `authorize_subscription`. Authorization
94
+ to the actor stream is not a per-viewer projection.
95
+
89
96
  ```erb
90
97
  <ul>
91
98
  <% actor.recent_messages.each do |message| %>
@@ -437,8 +444,9 @@ component key, locals, or DOM ID.
437
444
  ## Broadcast durability
438
445
 
439
446
  The actor's fenced commit compares observables before and after the turn and
440
- inserts one broadcast row per changed observable. Value-broadcast observables
441
- store the changed JSON value; invalidation-only observables store `{}`. The actor state, monotonic
447
+ inserts one broadcast row per changed observable. Invalidation-only
448
+ observables, the default, store `{}`. Observables declared with
449
+ `broadcast: :value` store the changed JSON value. The actor state, monotonic
442
450
  `state_revision`, message completion, and broadcast rows commit atomically. A
443
451
  rolled-back or fenced-out turn therefore cannot invalidate a component.
444
452
 
data/docs/roadmap.md CHANGED
@@ -24,10 +24,11 @@
24
24
  listed here while broken in that worker: the scheduler reached a constant the
25
25
  caller path happened to load, so reminders never fired in production and
26
26
  every in-process test still passed
27
- - Durable value or invalidation-only observable broadcasts, scalar Turbo
28
- replacement, keyed ERB components, signed component locals, and authorized
29
- replace or morph refresh. Invalidation-only observables retain component
30
- change detection while storing and broadcasting no projected value
27
+ - Durable invalidation-only observable broadcasts by default, explicit
28
+ `broadcast: :value` scalar Turbo replacement, keyed ERB components, signed
29
+ component locals, and authorized replace or morph refresh. Default
30
+ observables retain component change detection while storing and broadcasting
31
+ no projected value
31
32
  - Batched component refreshes: components sharing a signed `batch:` collapse to
32
33
  one browser request per revision, served as HTML frames in a JSON envelope
33
34
  - Personalized state payload broadcasts computed per subscriber under that
@@ -82,11 +83,14 @@
82
83
  What is not done is making any of them automatic. In-process signaling cannot
83
84
  cross process boundaries, so by default a commit in a web process does not
84
85
  wake a broadcast executor in a worker process and that delivery waits up to
85
- `polling_interval`, 100 ms. An adapter removes that floor, measured at 103.7 ms
86
- to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis, but each stays
87
- opt-in for a reason: the PostgreSQL adapter opens a connection per waiting
88
- thread outside the pool and `LISTEN` does not survive a transaction-pooling
89
- 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.
90
94
  `WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
91
95
  default elsewhere; it never selects Redis. An application that configures
92
96
  nothing keeps polling, and MySQL applications keep polling unless they
@@ -107,8 +111,25 @@
107
111
  handing the block a raw Cable connection.
108
112
  - Backpressure: mailbox/payload/state/result caps and fair yields exist;
109
113
  distributed per-actor rate limits and global admission control do not.
110
- - Administration: actor and dead-letter views plus policy hooks exist; richer
111
- filtering, audit records, and bulk-safe tools do not.
114
+ - Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
115
+ instances, mailbox, reminders, effects, broadcasts, dead letters, and
116
+ processes, with actor-type and actor-id filtering, status filters, paging, a
117
+ polled stats endpoint, Chart.js charts, and extension registration. The chart
118
+ library is fetched from a CDN with a subresource integrity hash, which a
119
+ deployment without outbound network access must replace with a vendored copy
120
+ or turn off. Every route declares its
121
+ own administration policy and a route declared without one raises at load
122
+ time, so the deny-by-default posture is enforced by construction rather than
123
+ by remembering to add a check. It changes only two things: an idempotent dead
124
+ letter retry and instance pause/resume. What does not exist is audit records
125
+ of who pressed what, and bulk-safe tools: retry is one dead letter at a time,
126
+ because `DeadLetterManager` exposes no bulk operation. Pause is an operator
127
+ brake and not a stop, since a pass already in flight finishes its turn and a
128
+ synchronous caller waiting on a paused instance times out. The page cost was
129
+ reasoned about rather than measured: the summary bar issues a fixed set of
130
+ indexed aggregate queries per page, which is why `HEAD /` exists for uptime
131
+ monitors, but no dashboard latency has been benchmarked against a large
132
+ table.
112
133
 
113
134
  ## Next milestones
114
135
 
data/docs/security.md CHANGED
@@ -36,13 +36,13 @@ Opaque stream and DOM names reduce accidental disclosure but do not replace
36
36
  authorization. Signed stream tokens are readable by their recipient and prove
37
37
  integrity only.
38
38
 
39
- Every normal observable value is stored in the broadcast outbox and can reach
40
- every subscriber that passes `authorize_subscription` for the actor. Never put
41
- credentials, session identifiers, private cards, hidden library order, or any
42
- other subscriber-specific state in a value-broadcast observable. Declare a
43
- component dependency with `broadcast: :invalidation` when only change metadata
44
- may cross the shared stream, or use `broadcast_payload` for a projection that
45
- must be computed separately for each authorized connection.
39
+ Observables are invalidation-only by default, so their durable rows and shared
40
+ actor stream carry no projected value. `broadcast: :value` deliberately stores
41
+ the value in the broadcast outbox and may send it to every subscriber that
42
+ passes `authorize_subscription` for the actor. Never opt credentials, session
43
+ identifiers, private cards, hidden library order, or any other
44
+ subscriber-specific state into value broadcasting. Use `broadcast_payload` for
45
+ a projection that must be computed separately for each authorized connection.
46
46
 
47
47
  ## Serialization
48
48
 
@@ -10,8 +10,8 @@ key. The chat actor also gives every submitted chat message a caller-generated
10
10
  message ID and checks that ID in durable actor state, because actor handlers may
11
11
  be redelivered.
12
12
 
13
- The views demonstrate scalar observable replacement and a live chat-message
14
- ERB component. The chat component receives `recent_messages` as an ordinary
15
- Ruby array, rerenders its `<ol>` after committed changes, and refreshes through
16
- the authenticated host request context. Turbo append intents remain roadmap
17
- work.
13
+ The views demonstrate explicit `broadcast: :value` scalar replacement and a
14
+ default invalidation-only chat-message ERB component. The chat component
15
+ receives `recent_messages` as an ordinary Ruby array, rerenders its `<ol>` after
16
+ committed changes, and refreshes through the authenticated host request
17
+ context. Turbo append intents remain roadmap work.
@@ -6,7 +6,7 @@ class ChatRoomActor < SolidObjects::Actor
6
6
  attribute :members, default: -> { [] }
7
7
  attribute :recent_messages, default: -> { [] }
8
8
 
9
- observable :presence do
9
+ observable :presence, broadcast: :value do
10
10
  members.length
11
11
  end
12
12
 
@@ -5,11 +5,11 @@ class ShoppingCartActor < SolidObjects::Actor
5
5
  attribute :checkout_status, default: "open"
6
6
  attribute :payment_id
7
7
 
8
- observable :items_count do
8
+ observable :items_count, broadcast: :value do
9
9
  items.sum { |item| item.fetch("quantity") }
10
10
  end
11
11
 
12
- observable :subtotal_cents do
12
+ observable :subtotal_cents, broadcast: :value do
13
13
  items.sum do |item|
14
14
  item.fetch("quantity") * item.fetch("unit_price_cents")
15
15
  end
@@ -53,7 +53,7 @@ module SolidObjects
53
53
  end
54
54
 
55
55
  # @rbs (Symbol | String, ?broadcast: Symbol) ?{ () -> untyped } -> ActorDefinition::Handler
56
- def observable(name, broadcast: :value, &block)
56
+ def observable(name, broadcast: :invalidation, &block)
57
57
  definition.add_observable(name, block, broadcast:)
58
58
  end
59
59
 
@@ -1,11 +1,14 @@
1
1
  # rbs_inline: enabled
2
2
 
3
+ require "solid_objects/polling_backoff"
4
+
3
5
  module SolidObjects
4
6
  class BroadcastExecutor
5
7
  # @rbs @process_registry: ProcessRegistry
6
8
  # @rbs @database_adapter: DatabaseAdapter
7
9
  # @rbs @stopped: bool
8
10
  # @rbs @shutdown_requested: bool
11
+ # @rbs @polling_backoff: PollingBackoff
9
12
 
10
13
  # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
11
14
  def initialize(
@@ -17,6 +20,17 @@ module SolidObjects
17
20
  process_registry.register(kind: "broadcast")
18
21
  @stopped = false
19
22
  @shutdown_requested = false
23
+ @polling_backoff = PollingBackoff.new(
24
+ minimum_interval: SolidObjects.configuration.polling_interval,
25
+ maximum_interval: SolidObjects.configuration.idle_polling_interval,
26
+ on_change: ->(transition) do
27
+ SolidObjects.instrument(
28
+ :"polling.interval_changed",
29
+ role: "broadcasts",
30
+ **transition
31
+ )
32
+ end
33
+ )
20
34
  end
21
35
 
22
36
  # @rbs () -> bool
@@ -46,11 +60,23 @@ module SolidObjects
46
60
 
47
61
  # @rbs () -> void
48
62
  def run
63
+ ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up
64
+
49
65
  until shutdown_requested?
66
+ wake_up = SolidObjects.wake_up
67
+ watch = wake_up.respond_to?(:watch) ? wake_up.watch : wake_up
50
68
  worked = run_once
51
- next if worked
52
-
53
- SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
69
+ if worked
70
+ polling_backoff.reset(:work)
71
+ next
72
+ end
73
+
74
+ notified = watch.wait(timeout: current_polling_interval)
75
+ if notified == false
76
+ polling_backoff.record_idle
77
+ else
78
+ polling_backoff.reset(:wake_up)
79
+ end
54
80
  end
55
81
  ensure
56
82
  stop
@@ -72,9 +98,14 @@ module SolidObjects
72
98
  @shutdown_requested
73
99
  end
74
100
 
101
+ # @rbs () -> Float
102
+ def current_polling_interval
103
+ polling_backoff.current_interval
104
+ end
105
+
75
106
  private
76
107
 
77
- attr_reader :process_registry, :database_adapter
108
+ attr_reader :process_registry, :database_adapter, :polling_backoff
78
109
 
79
110
  # @rbs () -> Broadcast?
80
111
  def claim_next
@@ -4,6 +4,7 @@ module SolidObjects
4
4
  class Configuration
5
5
  # @rbs @table_name_prefix: String
6
6
  # @rbs @polling_interval: Float
7
+ # @rbs @idle_polling_interval: Float
7
8
  # @rbs @sync_polling_interval: Float
8
9
  # @rbs @lease_duration: Float
9
10
  # @rbs @lease_renewal_interval: Float
@@ -49,6 +50,7 @@ module SolidObjects
49
50
 
50
51
  attr_accessor :table_name_prefix,
51
52
  :polling_interval,
53
+ :idle_polling_interval,
52
54
  :sync_polling_interval,
53
55
  :lease_duration,
54
56
  :lease_renewal_interval,
@@ -99,6 +101,7 @@ module SolidObjects
99
101
  def initialize
100
102
  @table_name_prefix = "solid_objects_"
101
103
  @polling_interval = 0.1
104
+ @idle_polling_interval = 1.0
102
105
  @sync_polling_interval = 0.05
103
106
  @lease_duration = 30.0
104
107
  @lease_renewal_interval = 10.0
@@ -241,6 +244,7 @@ module SolidObjects
241
244
  def positive_values
242
245
  {
243
246
  polling_interval:,
247
+ idle_polling_interval:,
244
248
  sync_polling_interval:,
245
249
  lease_duration:,
246
250
  lease_renewal_interval:,
@@ -1,5 +1,7 @@
1
1
  # rbs_inline: enabled
2
2
 
3
+ require "solid_objects/polling_backoff"
4
+
3
5
  module SolidObjects
4
6
  EffectContext = Data.define(:id, :attempt, :source_message_id, :actor_type, :actor_id)
5
7
 
@@ -10,6 +12,7 @@ module SolidObjects
10
12
  # @rbs @database_adapter: DatabaseAdapter
11
13
  # @rbs @stopped: bool
12
14
  # @rbs @shutdown_requested: bool
15
+ # @rbs @polling_backoff: PollingBackoff
13
16
 
14
17
  # @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
15
18
  def initialize(
@@ -21,6 +24,17 @@ module SolidObjects
21
24
  process_registry.register(kind: "effect")
22
25
  @stopped = false
23
26
  @shutdown_requested = false
27
+ @polling_backoff = PollingBackoff.new(
28
+ minimum_interval: SolidObjects.configuration.polling_interval,
29
+ maximum_interval: SolidObjects.configuration.idle_polling_interval,
30
+ on_change: ->(transition) do
31
+ SolidObjects.instrument(
32
+ :"polling.interval_changed",
33
+ role: "effects",
34
+ **transition
35
+ )
36
+ end
37
+ )
24
38
  end
25
39
 
26
40
  # @rbs () -> bool
@@ -50,11 +64,23 @@ module SolidObjects
50
64
 
51
65
  # @rbs () -> void
52
66
  def run
67
+ ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up
68
+
53
69
  until shutdown_requested?
70
+ wake_up = SolidObjects.wake_up
71
+ watch = wake_up.respond_to?(:watch) ? wake_up.watch : wake_up
54
72
  worked = run_once
55
- next if worked
73
+ if worked
74
+ polling_backoff.reset(:work)
75
+ next
76
+ end
56
77
 
57
- SolidObjects.wake_up.wait(timeout: SolidObjects.configuration.polling_interval)
78
+ notified = watch.wait(timeout: current_polling_interval)
79
+ if notified == false
80
+ polling_backoff.record_idle
81
+ else
82
+ polling_backoff.reset(:wake_up)
83
+ end
58
84
  end
59
85
  ensure
60
86
  stop
@@ -76,9 +102,14 @@ module SolidObjects
76
102
  @shutdown_requested
77
103
  end
78
104
 
105
+ # @rbs () -> Float
106
+ def current_polling_interval
107
+ polling_backoff.current_interval
108
+ end
109
+
79
110
  private
80
111
 
81
- attr_reader :process_registry, :database_adapter
112
+ attr_reader :process_registry, :database_adapter, :polling_backoff
82
113
 
83
114
  # @rbs () -> Effect?
84
115
  def claim_next