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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +60 -0
- data/README.md +69 -13
- data/benchmark/idle_polling.rb +98 -0
- data/benchmark/support.rb +2 -0
- data/docs/adr/0009-realtime-updates.md +5 -1
- data/docs/adr/0011-wake-up-strategy.md +6 -0
- data/docs/architecture.md +5 -4
- data/docs/authorization.md +11 -4
- data/docs/benchmarks.md +38 -0
- data/docs/correctness.md +3 -3
- data/docs/dashboard.md +200 -0
- data/docs/database-schema.md +5 -4
- data/docs/development.md +1 -0
- data/docs/operations.md +24 -0
- data/docs/realtime.md +22 -14
- data/docs/roadmap.md +32 -11
- data/docs/security.md +7 -7
- data/examples/application/README.md +5 -5
- data/examples/application/app/actors/chat_room_actor.rb +1 -1
- data/examples/application/app/actors/shopping_cart_actor.rb +2 -2
- data/lib/solid_objects/actor.rb +1 -1
- data/lib/solid_objects/broadcast_executor.rb +35 -4
- data/lib/solid_objects/configuration.rb +4 -0
- data/lib/solid_objects/effect_executor.rb +34 -3
- data/lib/solid_objects/polling_backoff.rb +45 -0
- data/lib/solid_objects/process_registry.rb +51 -0
- data/lib/solid_objects/reminder_scheduler.rb +35 -4
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects/wake_up.rb +36 -4
- data/lib/solid_objects/wake_up_adapters/postgresql.rb +6 -0
- data/lib/solid_objects/wake_up_adapters/redis.rb +25 -3
- data/lib/solid_objects/web/action.rb +109 -0
- data/lib/solid_objects/web/application.rb +239 -0
- data/lib/solid_objects/web/csrf_protection.rb +130 -0
- data/lib/solid_objects/web/helpers.rb +227 -0
- data/lib/solid_objects/web/paginator.rb +64 -0
- data/lib/solid_objects/web/route.rb +55 -0
- data/lib/solid_objects/web/router.rb +46 -0
- data/lib/solid_objects/web/statistics.rb +78 -0
- data/lib/solid_objects/web.rb +222 -0
- data/lib/solid_objects/worker.rb +39 -3
- data/lib/solid_objects.rb +2 -0
- data/sig/generated/lib/solid_objects/broadcast_executor.rbs +7 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +6 -2
- data/sig/generated/lib/solid_objects/effect_executor.rbs +7 -0
- data/sig/generated/lib/solid_objects/polling_backoff.rbs +29 -0
- data/sig/generated/lib/solid_objects/process_registry.rbs +15 -0
- data/sig/generated/lib/solid_objects/reminder_scheduler.rbs +7 -0
- data/sig/generated/lib/solid_objects/wake_up.rbs +19 -2
- data/sig/generated/lib/solid_objects/wake_up_adapters/postgresql.rbs +3 -0
- data/sig/generated/lib/solid_objects/wake_up_adapters/redis.rbs +17 -2
- data/sig/generated/lib/solid_objects/web/action.rbs +78 -0
- data/sig/generated/lib/solid_objects/web/application.rbs +50 -0
- data/sig/generated/lib/solid_objects/web/csrf_protection.rbs +55 -0
- data/sig/generated/lib/solid_objects/web/helpers.rbs +118 -0
- data/sig/generated/lib/solid_objects/web/paginator.rbs +54 -0
- data/sig/generated/lib/solid_objects/web/route.rbs +45 -0
- data/sig/generated/lib/solid_objects/web/router.rbs +29 -0
- data/sig/generated/lib/solid_objects/web/statistics.rbs +46 -0
- data/sig/generated/lib/solid_objects/web.rbs +129 -0
- data/sig/generated/lib/solid_objects/worker.rbs +7 -0
- data/web/assets/javascripts/application.js +118 -0
- data/web/assets/javascripts/charts.js +190 -0
- data/web/assets/stylesheets/application.css +527 -0
- data/web/views/_messages.erb +29 -0
- data/web/views/_navigation.erb +15 -0
- data/web/views/_paging.erb +17 -0
- data/web/views/_status_filter.erb +8 -0
- data/web/views/_summary.erb +39 -0
- data/web/views/broadcasts.erb +35 -0
- data/web/views/dashboard.erb +128 -0
- data/web/views/dead_letter.erb +40 -0
- data/web/views/dead_letters.erb +42 -0
- data/web/views/effects.erb +35 -0
- data/web/views/instance.erb +139 -0
- data/web/views/instances.erb +49 -0
- data/web/views/layout.erb +22 -0
- data/web/views/mailbox.erb +35 -0
- data/web/views/message.erb +34 -0
- data/web/views/processes.erb +37 -0
- data/web/views/reminders.erb +37 -0
- 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.
|
data/docs/database-schema.md
CHANGED
|
@@ -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
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
|
17
|
-
actor subscription and Action Cable multiplexes actor
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
|
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.
|
|
441
|
-
|
|
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
|
|
28
|
-
replacement, keyed ERB components, signed
|
|
29
|
-
replace or morph refresh.
|
|
30
|
-
change detection while storing and broadcasting
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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:
|
|
111
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
14
|
-
ERB component. The chat component
|
|
15
|
-
Ruby array, rerenders its `<ol>` after
|
|
16
|
-
|
|
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.
|
|
@@ -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
|
data/lib/solid_objects/actor.rb
CHANGED
|
@@ -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: :
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
69
|
+
if worked
|
|
70
|
+
polling_backoff.reset(:work)
|
|
71
|
+
next
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
notified = watch.wait(timeout: current_polling_interval)
|
|
75
|
+
if notified == false
|
|
76
|
+
polling_backoff.record_idle
|
|
77
|
+
else
|
|
78
|
+
polling_backoff.reset(:wake_up)
|
|
79
|
+
end
|
|
54
80
|
end
|
|
55
81
|
ensure
|
|
56
82
|
stop
|
|
@@ -72,9 +98,14 @@ module SolidObjects
|
|
|
72
98
|
@shutdown_requested
|
|
73
99
|
end
|
|
74
100
|
|
|
101
|
+
# @rbs () -> Float
|
|
102
|
+
def current_polling_interval
|
|
103
|
+
polling_backoff.current_interval
|
|
104
|
+
end
|
|
105
|
+
|
|
75
106
|
private
|
|
76
107
|
|
|
77
|
-
attr_reader :process_registry, :database_adapter
|
|
108
|
+
attr_reader :process_registry, :database_adapter, :polling_backoff
|
|
78
109
|
|
|
79
110
|
# @rbs () -> Broadcast?
|
|
80
111
|
def claim_next
|
|
@@ -4,6 +4,7 @@ module SolidObjects
|
|
|
4
4
|
class Configuration
|
|
5
5
|
# @rbs @table_name_prefix: String
|
|
6
6
|
# @rbs @polling_interval: Float
|
|
7
|
+
# @rbs @idle_polling_interval: Float
|
|
7
8
|
# @rbs @sync_polling_interval: Float
|
|
8
9
|
# @rbs @lease_duration: Float
|
|
9
10
|
# @rbs @lease_renewal_interval: Float
|
|
@@ -49,6 +50,7 @@ module SolidObjects
|
|
|
49
50
|
|
|
50
51
|
attr_accessor :table_name_prefix,
|
|
51
52
|
:polling_interval,
|
|
53
|
+
:idle_polling_interval,
|
|
52
54
|
:sync_polling_interval,
|
|
53
55
|
:lease_duration,
|
|
54
56
|
:lease_renewal_interval,
|
|
@@ -99,6 +101,7 @@ module SolidObjects
|
|
|
99
101
|
def initialize
|
|
100
102
|
@table_name_prefix = "solid_objects_"
|
|
101
103
|
@polling_interval = 0.1
|
|
104
|
+
@idle_polling_interval = 1.0
|
|
102
105
|
@sync_polling_interval = 0.05
|
|
103
106
|
@lease_duration = 30.0
|
|
104
107
|
@lease_renewal_interval = 10.0
|
|
@@ -241,6 +244,7 @@ module SolidObjects
|
|
|
241
244
|
def positive_values
|
|
242
245
|
{
|
|
243
246
|
polling_interval:,
|
|
247
|
+
idle_polling_interval:,
|
|
244
248
|
sync_polling_interval:,
|
|
245
249
|
lease_duration:,
|
|
246
250
|
lease_renewal_interval:,
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# rbs_inline: enabled
|
|
2
2
|
|
|
3
|
+
require "solid_objects/polling_backoff"
|
|
4
|
+
|
|
3
5
|
module SolidObjects
|
|
4
6
|
EffectContext = Data.define(:id, :attempt, :source_message_id, :actor_type, :actor_id)
|
|
5
7
|
|
|
@@ -10,6 +12,7 @@ module SolidObjects
|
|
|
10
12
|
# @rbs @database_adapter: DatabaseAdapter
|
|
11
13
|
# @rbs @stopped: bool
|
|
12
14
|
# @rbs @shutdown_requested: bool
|
|
15
|
+
# @rbs @polling_backoff: PollingBackoff
|
|
13
16
|
|
|
14
17
|
# @rbs (?process_registry: ProcessRegistry, ?database_adapter: DatabaseAdapter) -> void
|
|
15
18
|
def initialize(
|
|
@@ -21,6 +24,17 @@ module SolidObjects
|
|
|
21
24
|
process_registry.register(kind: "effect")
|
|
22
25
|
@stopped = false
|
|
23
26
|
@shutdown_requested = false
|
|
27
|
+
@polling_backoff = PollingBackoff.new(
|
|
28
|
+
minimum_interval: SolidObjects.configuration.polling_interval,
|
|
29
|
+
maximum_interval: SolidObjects.configuration.idle_polling_interval,
|
|
30
|
+
on_change: ->(transition) do
|
|
31
|
+
SolidObjects.instrument(
|
|
32
|
+
:"polling.interval_changed",
|
|
33
|
+
role: "effects",
|
|
34
|
+
**transition
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
)
|
|
24
38
|
end
|
|
25
39
|
|
|
26
40
|
# @rbs () -> bool
|
|
@@ -50,11 +64,23 @@ module SolidObjects
|
|
|
50
64
|
|
|
51
65
|
# @rbs () -> void
|
|
52
66
|
def run
|
|
67
|
+
ProcessRegistry.warn_if_polling_is_only_cross_process_wake_up
|
|
68
|
+
|
|
53
69
|
until shutdown_requested?
|
|
70
|
+
wake_up = SolidObjects.wake_up
|
|
71
|
+
watch = wake_up.respond_to?(:watch) ? wake_up.watch : wake_up
|
|
54
72
|
worked = run_once
|
|
55
|
-
|
|
73
|
+
if worked
|
|
74
|
+
polling_backoff.reset(:work)
|
|
75
|
+
next
|
|
76
|
+
end
|
|
56
77
|
|
|
57
|
-
|
|
78
|
+
notified = watch.wait(timeout: current_polling_interval)
|
|
79
|
+
if notified == false
|
|
80
|
+
polling_backoff.record_idle
|
|
81
|
+
else
|
|
82
|
+
polling_backoff.reset(:wake_up)
|
|
83
|
+
end
|
|
58
84
|
end
|
|
59
85
|
ensure
|
|
60
86
|
stop
|
|
@@ -76,9 +102,14 @@ module SolidObjects
|
|
|
76
102
|
@shutdown_requested
|
|
77
103
|
end
|
|
78
104
|
|
|
105
|
+
# @rbs () -> Float
|
|
106
|
+
def current_polling_interval
|
|
107
|
+
polling_backoff.current_interval
|
|
108
|
+
end
|
|
109
|
+
|
|
79
110
|
private
|
|
80
111
|
|
|
81
|
-
attr_reader :process_registry, :database_adapter
|
|
112
|
+
attr_reader :process_registry, :database_adapter, :polling_backoff
|
|
82
113
|
|
|
83
114
|
# @rbs () -> Effect?
|
|
84
115
|
def claim_next
|