solid_objects 0.9.0 → 0.10.0
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/app/controllers/solid_objects/components_controller.rb +3 -18
- data/benchmark/support.rb +47 -5
- data/docs/benchmarks.md +19 -2
- data/docs/realtime.md +59 -4
- data/docs/roadmap.md +46 -27
- data/exe/solid_objects +12 -1
- data/lib/generators/solid_objects/templates/solid_objects.rb +8 -0
- data/lib/solid_objects/actor_channel.rb +50 -10
- data/lib/solid_objects/callable_keywords.rb +29 -0
- data/lib/solid_objects/component_subscriptions.rb +19 -11
- data/lib/solid_objects/configuration.rb +13 -0
- data/lib/solid_objects/payload_broadcast.rb +29 -1
- data/lib/solid_objects/supervisor.rb +76 -0
- data/lib/solid_objects/version.rb +1 -1
- data/lib/solid_objects.rb +1 -0
- data/sig/generated/controllers/solid_objects/components_controller.rbs +0 -8
- data/sig/generated/lib/solid_objects/actor_channel.rbs +19 -0
- data/sig/generated/lib/solid_objects/callable_keywords.rbs +16 -0
- data/sig/generated/lib/solid_objects/component_subscriptions.rbs +5 -0
- data/sig/generated/lib/solid_objects/configuration.rbs +10 -2
- data/sig/generated/lib/solid_objects/payload_broadcast.rbs +13 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +32 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 935a2d2bb12fbc5b39121fe44004d0cc0671c3bef82012cf9b024089c3546ac9
|
|
4
|
+
data.tar.gz: 15de97ea8547bfb552ae848b2ae523fdd6c48a557313ba3fd87d931ee770f9b1
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6633846db212684a9e6d687f76e559cd51448171bd9e067534024e9a067534f1ff7eab223e3c1ab80b7a02765b909a324f2dbb9b1f527e5846d933de5b3639c7
|
|
7
|
+
data.tar.gz: ce930198b48379439c4947dd41ea5d3a0577ad8ce49eb1513715075ccf23c03b23237bcee77f06a898ea5460b83e3bf6b0c8a54f7b9c16e03c159c2572a03306
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.0 - 2026-08-10
|
|
4
|
+
|
|
5
|
+
- Report a denied CLI command as a policy decision rather than a crash.
|
|
6
|
+
Administration denies by default, so an unconfigured host met a thirty-line
|
|
7
|
+
Ruby backtrace on its first `solid_objects` command. The executable now
|
|
8
|
+
prints the refusal and the setting that grants access, and exits 1.
|
|
9
|
+
- Measure the query count for a synchronous call. `benchmark/query_count.rb`
|
|
10
|
+
only measured a worker turn, so the documented synchronous number had no
|
|
11
|
+
script behind it. It reports three now: a message turn costs 26 queries
|
|
12
|
+
rather than the documented 29, the caller of a synchronous call costs 49, and
|
|
13
|
+
a synchronous call in total costs 75, being a caller plus the turn it waits
|
|
14
|
+
on. Counting is scoped to the measuring thread, since a worker loop polls
|
|
15
|
+
whether or not a call is in flight and a process-wide count folds those polls
|
|
16
|
+
into the result.
|
|
17
|
+
|
|
18
|
+
- Run payload broadcast blocks against the actor instance, like every other
|
|
19
|
+
block in the actor DSL. `self` was the actor class, so an actor instance
|
|
20
|
+
method called from a payload block raised
|
|
21
|
+
`NoMethodError: undefined method 'x' for class PlaymatRoom`. Blocks keep
|
|
22
|
+
receiving the actor and the authorization context as arguments, so the
|
|
23
|
+
documented signature is unaffected. A block that relied on the class receiver
|
|
24
|
+
now raises `InvalidPayloadBroadcast` naming the method and the change instead
|
|
25
|
+
of an unexplained `NameError`.
|
|
26
|
+
- Add `payload_authorization_context`, the payload counterpart to
|
|
27
|
+
`component_authorization_context`. Payloads are computed inside the channel,
|
|
28
|
+
so without a resolver the payload block and its `authorize_query` call
|
|
29
|
+
received the raw Action Cable connection while a controller render passed an
|
|
30
|
+
application object, and the authorization hook had to tell them apart. The
|
|
31
|
+
resolver may also accept `payload_name:`. It defaults to returning the
|
|
32
|
+
connection unchanged.
|
|
33
|
+
- Confine a failing payload to itself. A raising payload block propagated out of
|
|
34
|
+
the channel: on subscribe it rejected the subscription, and on a broadcast it
|
|
35
|
+
abandoned the remaining payload names, which showed up in the browser only as
|
|
36
|
+
reactive updates that stopped arriving. A failure is now reported as
|
|
37
|
+
`solid_objects.payload_broadcast_failed` with the actor type, actor id,
|
|
38
|
+
payload name, and exception class, and delivery continues. The exception
|
|
39
|
+
message is deliberately excluded so subscriber state cannot leak into logs. A
|
|
40
|
+
revision with a failed payload does not advance the delivery watermark, so a
|
|
41
|
+
transient failure is retried on the next broadcast instead of being recorded
|
|
42
|
+
as delivered and deduplicated away.
|
|
43
|
+
- Run retention on the supervisor rather than leaving it configured but
|
|
44
|
+
unscheduled. Every actor call writes a durable message row, so a policy that
|
|
45
|
+
nothing invokes let history grow without bound until an application scheduled
|
|
46
|
+
its own job. `retention_interval` defaults to one hour, and zero disables it.
|
|
47
|
+
Retention runs on its own thread, so a slow pass cannot delay replacing a
|
|
48
|
+
crashed role, and a failed pass retries at monitor cadence with a doubling
|
|
49
|
+
backoff rather than deferring for the whole interval.
|
|
50
|
+
- Batch component refreshes on reconnect. A reconnecting subscription refreshed
|
|
51
|
+
every stale component individually, ignoring the batches those components
|
|
52
|
+
declared, so a page with twenty batched components issued twenty requests
|
|
53
|
+
instead of one. That happens at the worst moment: a server restart reconnects
|
|
54
|
+
every client at once. Reconnect now shares the batching the live invalidation
|
|
55
|
+
path uses.
|
|
56
|
+
- Cover the reconnect burst in the browser suite: convergence of batched and
|
|
57
|
+
unbatched components, an inert replay of an already-applied revision,
|
|
58
|
+
cancellation of the request left in flight by the drop, incarnation ordering
|
|
59
|
+
after a destroy and recreate, and payload delivery exactly once per revision.
|
|
60
|
+
- Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and
|
|
61
|
+
4.0 against Rails 8.0 and 8.1.
|
|
62
|
+
|
|
3
63
|
## 0.9.0 - 2026-08-10
|
|
4
64
|
|
|
5
65
|
- Add a browser test suite running the refresh modules against real Chromium and
|
|
@@ -138,26 +138,11 @@ module SolidObjects
|
|
|
138
138
|
# @rbs (Array[ComponentRegistration]) -> untyped
|
|
139
139
|
def component_authorization_context(registrations)
|
|
140
140
|
callable = SolidObjects.configuration.component_authorization_context
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
callable.call(controller: self, registrations:)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
# A lambda answers `parameters` directly; a callable object answers it
|
|
147
|
-
# through its `call` method.
|
|
148
|
-
# @rbs (untyped) -> bool
|
|
149
|
-
def accepts_registrations?(callable)
|
|
150
|
-
callable_parameters(callable).any? do |type, name|
|
|
151
|
-
type == :keyrest || (%i[key keyreq].include?(type) && name == :registrations)
|
|
141
|
+
unless CallableKeywords.accepts?(callable, :registrations)
|
|
142
|
+
return callable.call(controller: self)
|
|
152
143
|
end
|
|
153
|
-
end
|
|
154
|
-
|
|
155
|
-
# @rbs (untyped) -> Array[[ Symbol, Symbol ]]
|
|
156
|
-
def callable_parameters(callable)
|
|
157
|
-
return callable.parameters if callable.respond_to?(:parameters)
|
|
158
|
-
return callable.method(:call).parameters if callable.respond_to?(:call)
|
|
159
144
|
|
|
160
|
-
|
|
145
|
+
callable.call(controller: self, registrations:)
|
|
161
146
|
end
|
|
162
147
|
|
|
163
148
|
# @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
|
data/benchmark/support.rb
CHANGED
|
@@ -291,24 +291,66 @@ module SolidObjectsBenchmark
|
|
|
291
291
|
|
|
292
292
|
# @rbs () -> void
|
|
293
293
|
def query_count
|
|
294
|
+
turn = message_turn_query_count
|
|
295
|
+
caller_queries = synchronous_caller_query_count
|
|
296
|
+
puts "database queries: #{turn} for 1 message turn"
|
|
297
|
+
puts "database queries: #{caller_queries} for the caller of 1 synchronous call"
|
|
298
|
+
puts "database queries: #{caller_queries + turn} for 1 synchronous call, caller plus the turn it waits on"
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
# Runs the turn on the measuring thread, so nothing else can contribute.
|
|
304
|
+
# @rbs () -> Integer
|
|
305
|
+
def message_turn_query_count
|
|
294
306
|
CounterActor.ref("queries").async(:increment)
|
|
295
307
|
worker = SolidObjects::Worker.new
|
|
308
|
+
count_queries { worker.run_once }
|
|
309
|
+
ensure
|
|
310
|
+
worker&.stop
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
# A synchronous call also registers or heartbeats the caller process,
|
|
314
|
+
# claims the activation, and observes the result, so the caller costs more
|
|
315
|
+
# than the turn it waits on. Only the caller thread is counted; the worker
|
|
316
|
+
# runs on its own thread and its turn is measured separately, because a
|
|
317
|
+
# worker loop also polls and those polls belong to no particular call. The
|
|
318
|
+
# first call is discarded because it pays for activation and caller
|
|
319
|
+
# registration that a steady-state call does not.
|
|
320
|
+
# @rbs () -> Integer
|
|
321
|
+
def synchronous_caller_query_count
|
|
322
|
+
reference = CounterActor.ref("sync-queries")
|
|
323
|
+
worker = SolidObjects::Worker.new
|
|
324
|
+
runner = Thread.new { worker.run }
|
|
325
|
+
reference.sync(:increment)
|
|
326
|
+
count_queries { reference.sync(:increment) }
|
|
327
|
+
ensure
|
|
328
|
+
worker&.request_shutdown
|
|
329
|
+
runner&.join(5)
|
|
330
|
+
worker&.stop
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Subscriptions are process-wide and notifications run on the thread that
|
|
334
|
+
# issued the query, so counting is scoped to the measuring thread. Without
|
|
335
|
+
# that, a worker polling in the background inflates the count by however
|
|
336
|
+
# many times it happened to poll during the window.
|
|
337
|
+
# @rbs () { () -> untyped } -> Integer
|
|
338
|
+
def count_queries
|
|
339
|
+
measuring = Thread.current
|
|
296
340
|
queries = 0
|
|
297
341
|
subscriber = ActiveSupport::Notifications.subscribe("sql.active_record") do |event|
|
|
342
|
+
next unless Thread.current.equal?(measuring)
|
|
298
343
|
next if %w[SCHEMA TRANSACTION].include?(event.payload[:name])
|
|
299
344
|
next if event.payload[:cached]
|
|
300
345
|
|
|
301
346
|
queries += 1
|
|
302
347
|
end
|
|
303
|
-
|
|
304
|
-
|
|
348
|
+
yield
|
|
349
|
+
queries
|
|
305
350
|
ensure
|
|
306
351
|
ActiveSupport::Notifications.unsubscribe(subscriber) if subscriber
|
|
307
|
-
worker&.stop
|
|
308
352
|
end
|
|
309
353
|
|
|
310
|
-
private
|
|
311
|
-
|
|
312
354
|
# @rbs (ComponentRegistration, untyped, ?snapshot: ActorSnapshot?) -> untyped
|
|
313
355
|
def render_component(registration, view_context, snapshot: nil)
|
|
314
356
|
SolidObjects::ComponentRenderer.new(
|
data/docs/benchmarks.md
CHANGED
|
@@ -40,8 +40,25 @@ scenario used four worker threads.
|
|
|
40
40
|
| Process, four workers | 556.5 messages/s |
|
|
41
41
|
| Synchronous latency | p50 1.8 ms, p95 25.6 ms, p99 156.2 ms |
|
|
42
42
|
| Activation reuse | 98.0%, four activations for 200 messages |
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
|
|
44
|
+
Query counts are a property of the code rather than the host, so they are
|
|
45
|
+
tracked separately. Re-measured 2026-08-10 against 0.10.0 on SQLite with
|
|
46
|
+
`bundle exec ruby benchmark/query_count.rb`, which is deterministic across runs:
|
|
47
|
+
|
|
48
|
+
| Scenario | Queries |
|
|
49
|
+
| --- | ---: |
|
|
50
|
+
| One message turn | 26 |
|
|
51
|
+
| The caller of one synchronous call | 49 |
|
|
52
|
+
| One synchronous call, caller plus the turn it waits on | 75 |
|
|
53
|
+
|
|
54
|
+
A worker turn fell from the 29 recorded earlier. The caller count is unchanged
|
|
55
|
+
at 49, and the combined figure is new: a synchronous call is a caller and a
|
|
56
|
+
worker turn, and only the sum says what the database actually serves.
|
|
57
|
+
|
|
58
|
+
Counting is scoped to the measuring thread. A worker loop polls whether or not
|
|
59
|
+
a call is in flight, so a process-wide count folds however many polls happened
|
|
60
|
+
to land inside the window into the result. That is also why the caller and the
|
|
61
|
+
turn are measured separately rather than by watching both threads at once.
|
|
45
62
|
|
|
46
63
|
A synchronous call costs far more queries than a worker turn because the caller
|
|
47
64
|
also registers or heartbeats its caller process, claims the activation, and
|
data/docs/realtime.md
CHANGED
|
@@ -237,9 +237,11 @@ single actor mutation changes several components, an application pays several
|
|
|
237
237
|
round trips for one logical update. A payload broadcast collapses that into one
|
|
238
238
|
message on the stream the page already has open.
|
|
239
239
|
|
|
240
|
-
Declare the payload on the actor. The block
|
|
241
|
-
|
|
242
|
-
|
|
240
|
+
Declare the payload on the actor. The block runs against the actor instance,
|
|
241
|
+
like every other block in the actor DSL, and receives the actor and the
|
|
242
|
+
subscriber's authorization context as arguments. It runs **once per
|
|
243
|
+
subscriber**, so two sessions watching the same actor never see each other's
|
|
244
|
+
private state:
|
|
243
245
|
|
|
244
246
|
```ruby
|
|
245
247
|
class PlaymatRoom < SolidObjects::Actor
|
|
@@ -259,6 +261,16 @@ class PlaymatRoom < SolidObjects::Actor
|
|
|
259
261
|
end
|
|
260
262
|
```
|
|
261
263
|
|
|
264
|
+
Because the block runs against the actor, an actor instance method is reachable
|
|
265
|
+
without a receiver, so shared logic does not have to be duplicated into the
|
|
266
|
+
block:
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
broadcast_payload :playmat_state do |_room, authorization|
|
|
270
|
+
{ "turn" => turn, "hand" => hand_for(authorization.session_id) }
|
|
271
|
+
end
|
|
272
|
+
```
|
|
273
|
+
|
|
262
274
|
Subscribe the scope to it:
|
|
263
275
|
|
|
264
276
|
```erb
|
|
@@ -298,6 +310,26 @@ Payload blocks read committed actor state through the same snapshot components
|
|
|
298
310
|
use. They cannot write application records, and the return value must be a JSON
|
|
299
311
|
object or array so the wire format stays inspectable.
|
|
300
312
|
|
|
313
|
+
A payload is one subscriber's view of one name, so a failure is confined to it.
|
|
314
|
+
A raising block does not reject the subscription, stop the other payload names,
|
|
315
|
+
or stop component refreshes on the same connection. The failure is reported as
|
|
316
|
+
`solid_objects.payload_broadcast_failed` carrying the actor type, actor id,
|
|
317
|
+
payload name, and exception class. The exception message is deliberately not
|
|
318
|
+
included: a payload block reads subscriber state, so its message is the one
|
|
319
|
+
place that state could leak into logs.
|
|
320
|
+
|
|
321
|
+
A revision with a failed payload does not advance the delivery watermark, so a
|
|
322
|
+
transient failure is retried on the next broadcast rather than being recorded as
|
|
323
|
+
delivered. Retries are driven by broadcasts rather than a timer, so a payload
|
|
324
|
+
that fails persistently retries once per actor mutation and reports each
|
|
325
|
+
attempt. A repeating stream of `payload_broadcast_failed` for one `payload_name`
|
|
326
|
+
therefore means a persistent fault in that block, not a one-off; a single event
|
|
327
|
+
that does not recur was transient and has already been recovered.
|
|
328
|
+
|
|
329
|
+
A payload the subscriber cannot query is skipped rather than served partially;
|
|
330
|
+
that decision is stable, so it settles the revision and the skip is silent by
|
|
331
|
+
design.
|
|
332
|
+
|
|
301
333
|
### mtg-playmat before and after
|
|
302
334
|
|
|
303
335
|
Before, one mutation that touched three observables produced three refresh
|
|
@@ -350,13 +382,31 @@ end
|
|
|
350
382
|
Callbacks that accept only `controller:` continue to work; the extra keyword is
|
|
351
383
|
passed only to callables that declare it.
|
|
352
384
|
|
|
353
|
-
|
|
385
|
+
Payloads have the same resolver, because they are computed inside the channel
|
|
386
|
+
rather than in a controller. Without one, a payload block and its
|
|
387
|
+
`authorize_query` call receive the Cable connection while a controller render
|
|
388
|
+
passes an application object, and the authorization hook has to tell them
|
|
389
|
+
apart. Resolve both to the same type and it does not:
|
|
390
|
+
|
|
391
|
+
```ruby
|
|
392
|
+
configuration.component_authorization_context = ->(controller:) { controller.current_account }
|
|
393
|
+
configuration.payload_authorization_context = ->(connection:) { connection.current_account }
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
The resolved value is what the payload block receives as its second argument and
|
|
397
|
+
what `authorize_query` receives as `authorization_context`. A resolver may also
|
|
398
|
+
accept `payload_name:` when the subject depends on which payload was requested.
|
|
399
|
+
The default returns the connection unchanged, so an application that has not
|
|
400
|
+
configured one is unaffected.
|
|
401
|
+
|
|
402
|
+
The contexts are intentionally different:
|
|
354
403
|
|
|
355
404
|
| Boundary | Authorization context |
|
|
356
405
|
| --- | --- |
|
|
357
406
|
| Initial Action View render | Explicit `authorization_context:` passed to `solid_object` |
|
|
358
407
|
| Action Cable subscription | The authenticated Cable connection |
|
|
359
408
|
| Component refresh | Value returned by `component_authorization_context` for the engine controller request |
|
|
409
|
+
| State payload | Value returned by `payload_authorization_context` for the Cable connection |
|
|
360
410
|
|
|
361
411
|
Do not substitute a signed token for any of them. Keys and locals are visible
|
|
362
412
|
to the browser and signed for integrity, not encrypted or authorized. Never
|
|
@@ -393,6 +443,11 @@ ordered commits within one incarnation. Out-of-order invalidations at or below
|
|
|
393
443
|
the last transmitted pair are ignored. The durable state row remains source of
|
|
394
444
|
truth.
|
|
395
445
|
|
|
446
|
+
Stale components that share a `batch:` are refreshed together, exactly as a
|
|
447
|
+
live invalidation refreshes them, so reconnecting costs one request per batch
|
|
448
|
+
rather than one per component. That matters most on a restart, when every
|
|
449
|
+
client reconnects at once.
|
|
450
|
+
|
|
396
451
|
The component endpoint rejects a requested revision newer than the committed
|
|
397
452
|
snapshot. This is a final server-side guard; browser safety primarily comes
|
|
398
453
|
from monotonic channel filtering plus replace-frame detachment or morph
|
data/docs/roadmap.md
CHANGED
|
@@ -13,14 +13,21 @@
|
|
|
13
13
|
- At-least-once retries, terminal domain rejection, strict poison ordering,
|
|
14
14
|
dead letters, and tail retry
|
|
15
15
|
- Transactional effects with success/failure actor messages
|
|
16
|
-
- Actor-to-actor asynchronous outbox delivery
|
|
16
|
+
- Actor-to-actor asynchronous outbox delivery. Effects and broadcasts use
|
|
17
|
+
portable status rows with polling indexes and database check constraints on
|
|
18
|
+
status, which works on all three adapters; a future version may add narrow
|
|
19
|
+
ready/claimed membership tables for very large outboxes, as messages already
|
|
20
|
+
have
|
|
17
21
|
- One-shot and recurring reminders with `:latest` or `:all` catch-up
|
|
18
22
|
- Durable observable invalidations, scalar Turbo replacement, keyed ERB
|
|
19
23
|
components, signed component locals, and authorized replace or morph refresh
|
|
20
24
|
- Batched component refreshes: components sharing a signed `batch:` collapse to
|
|
21
25
|
one browser request per revision, served as HTML frames in a JSON envelope
|
|
22
26
|
- Personalized state payload broadcasts computed per subscriber under that
|
|
23
|
-
subscriber's authorization context, fenced by actor revision
|
|
27
|
+
subscriber's authorization context, fenced by actor revision, resolved through
|
|
28
|
+
`payload_authorization_context` so the block and `authorize_query` see the
|
|
29
|
+
same subject a controller render passes, and confined so one failing payload
|
|
30
|
+
cannot reject the subscription or stop its siblings
|
|
24
31
|
- Reconciliation read APIs
|
|
25
32
|
- Installation doctor, authorization reference, fit guide, and legacy-state
|
|
26
33
|
migration cookbook
|
|
@@ -35,7 +42,9 @@
|
|
|
35
42
|
graceful caller shutdown, committed state snapshots, and an opt-in Minitest
|
|
36
43
|
helper
|
|
37
44
|
- Supervisor role replacement: a role whose thread dies is restarted until
|
|
38
|
-
shutdown is requested, and dead process records
|
|
45
|
+
shutdown is requested, and dead process records plus expired message and
|
|
46
|
+
process history are pruned on their own intervals without an application
|
|
47
|
+
scheduling its own job
|
|
39
48
|
- SQLite, PostgreSQL, and MySQL integration suites
|
|
40
49
|
- Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
|
|
41
50
|
a listening connection per waiting thread and release on supervisor shutdown
|
|
@@ -44,51 +53,61 @@
|
|
|
44
53
|
gem's dependencies
|
|
45
54
|
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
|
|
46
55
|
RuboCop policy, and a warning-free Brakeman scan
|
|
47
|
-
- Compatibility CI across the supported span: Ruby 3.3
|
|
48
|
-
and 8.1, pinned through `RAILS_VERSION` so the advertised range is
|
|
49
|
-
rather than assumed
|
|
56
|
+
- Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
|
|
57
|
+
Rails 8.0 and 8.1, pinned through `RAILS_VERSION` so the advertised range is
|
|
58
|
+
verified rather than assumed
|
|
50
59
|
- A JavaScript suite covering every browser module, run in CI with Node's test
|
|
51
60
|
runner and jsdom, plus a browser suite running the same modules against real
|
|
52
61
|
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
|
|
53
|
-
a commit SHA
|
|
62
|
+
a commit SHA. The browser suite covers the reconnect burst: convergence of
|
|
63
|
+
batched and unbatched components, an inert replay of an applied revision,
|
|
64
|
+
cancellation of the request left in flight by the drop, incarnation ordering
|
|
65
|
+
after a destroy and recreate, and payload delivery exactly once per revision
|
|
54
66
|
|
|
55
67
|
## Partially implemented
|
|
56
68
|
|
|
57
|
-
- Wake-up strategy: in-process signaling, durable polling, injection, and
|
|
58
|
-
|
|
59
|
-
not. In-process signaling cannot
|
|
60
|
-
|
|
61
|
-
worker process and that delivery waits up to
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
69
|
+
- Wake-up strategy: in-process signaling, durable polling, injection, and
|
|
70
|
+
cross-process adapters for PostgreSQL and Redis are implemented and tested.
|
|
71
|
+
What is not done is making any of them automatic. In-process signaling cannot
|
|
72
|
+
cross process boundaries, so by default a commit in a web process does not
|
|
73
|
+
wake a broadcast executor in a worker process and that delivery waits up to
|
|
74
|
+
`polling_interval`, 100 ms. An adapter removes that floor, measured at 103.7 ms
|
|
75
|
+
to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis, but each stays
|
|
76
|
+
opt-in for a reason: the PostgreSQL adapter opens a connection per waiting
|
|
77
|
+
thread outside the pool and `LISTEN` does not survive a transaction-pooling
|
|
78
|
+
proxy such as PgBouncer, and Redis is not a dependency of this gem.
|
|
79
|
+
`WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
|
|
80
|
+
default elsewhere; it never selects Redis. An application that configures
|
|
81
|
+
nothing keeps polling, and MySQL applications keep polling unless they
|
|
82
|
+
configure Redis explicitly.
|
|
68
83
|
- Realtime: scalar and dependency-driven keyed ERB component replacement or
|
|
69
84
|
morphing, personalized refresh authorization, revision fencing, coalescing,
|
|
70
85
|
reconnect convergence, batched refreshes, and personalized state payloads are
|
|
71
86
|
implemented; application-directed Turbo append intents are not. Batch
|
|
72
87
|
coalescing happens in the browser rather than the broadcast executor, so one
|
|
73
88
|
commit still sends one Action Cable message per changed observable even
|
|
74
|
-
though it costs one browser request.
|
|
89
|
+
though it costs one browser request. Reconnect convergence previously
|
|
90
|
+
bypassed batching entirely, issuing one request per stale component at the
|
|
91
|
+
moment a restart reconnects every client at once; it now shares the batching
|
|
92
|
+
the live invalidation path uses. Payload delivery over Action Cable was
|
|
93
|
+
untested end to end, which is how a raising payload block came to reject the
|
|
94
|
+
subscription; it is now covered and confined, and the payload authorization
|
|
95
|
+
context is resolved through `payload_authorization_context` rather than
|
|
96
|
+
handing the block a raw Cable connection.
|
|
75
97
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
76
98
|
distributed per-actor rate limits and global admission control do not.
|
|
77
99
|
- Administration: actor and dead-letter views plus policy hooks exist; richer
|
|
78
100
|
filtering, audit records, and bulk-safe tools do not.
|
|
79
|
-
- Outboxes use portable status rows with polling indexes; future versions may
|
|
80
|
-
introduce narrow ready/claimed membership tables for very large outboxes.
|
|
81
101
|
|
|
82
102
|
## Next milestones
|
|
83
103
|
|
|
84
104
|
1. Add result lookup by request ID and broader deadlock retry classification.
|
|
85
|
-
2. Add
|
|
86
|
-
3. Add
|
|
87
|
-
4. Add distributed rate limits, global admission hooks, and cache-capacity
|
|
105
|
+
2. Add Turbo append intents.
|
|
106
|
+
3. Add distributed rate limits, global admission hooks, and cache-capacity
|
|
88
107
|
eviction.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
4. Expand security scanning beyond the Brakeman scan, such as dependency
|
|
109
|
+
auditing and secret scanning.
|
|
110
|
+
5. Benchmark all workloads under documented hardware/database settings and
|
|
92
111
|
publish adapter-specific adoption measurements. Throughput, synchronous
|
|
93
112
|
latency, query counts, and the three reactive delivery paths are measured on
|
|
94
113
|
SQLite; adapter-specific and end-to-end browser measurements are not.
|
data/exe/solid_objects
CHANGED
|
@@ -6,4 +6,15 @@ $LOAD_PATH.unshift File.expand_path("../lib", __dir__)
|
|
|
6
6
|
require "solid_objects"
|
|
7
7
|
require "solid_objects/cli"
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
begin
|
|
10
|
+
SolidObjects::CLI.start(ARGV)
|
|
11
|
+
rescue SolidObjects::Unauthorized => error
|
|
12
|
+
# Authorization denies by default, so this is what an unconfigured host sees
|
|
13
|
+
# from its first command. A policy decision is not a crash, and printing a
|
|
14
|
+
# backtrace for one buries the single line that says how to grant access.
|
|
15
|
+
warn "solid_objects: #{error.message}"
|
|
16
|
+
warn "Set configuration.authorize_administration in your Solid Objects " \
|
|
17
|
+
"initializer to allow this command. See the commented example in " \
|
|
18
|
+
"config/initializers/solid_objects.rb."
|
|
19
|
+
exit 1
|
|
20
|
+
end
|
|
@@ -52,6 +52,14 @@ SolidObjects.configure do |configuration|
|
|
|
52
52
|
# Configure component_authorization_context to return the authenticated
|
|
53
53
|
# principal used for reactive component refreshes.
|
|
54
54
|
|
|
55
|
+
# Payloads are delivered over Action Cable, so without a resolver the payload
|
|
56
|
+
# block and authorize_query receive the Cable connection while a controller
|
|
57
|
+
# render passes an application object. Resolve both to the same type and the
|
|
58
|
+
# authorization hook stops having to tell them apart:
|
|
59
|
+
#
|
|
60
|
+
# configuration.component_authorization_context = ->(controller:) { controller.current_account }
|
|
61
|
+
# configuration.payload_authorization_context = ->(connection:) { connection.current_account }
|
|
62
|
+
|
|
55
63
|
# On hosts where shell access is already an authenticated administrative
|
|
56
64
|
# boundary, this enables only gem commands that pass the CLI context:
|
|
57
65
|
#
|
|
@@ -78,19 +78,59 @@ module SolidObjects
|
|
|
78
78
|
return if payload_names.nil? || payload_names.empty?
|
|
79
79
|
return unless newer_payload_revision?(snapshot)
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
rescue Unauthorized
|
|
89
|
-
next
|
|
90
|
-
end
|
|
81
|
+
# The watermark records what the subscriber has, so a revision with a
|
|
82
|
+
# failed payload must not advance it: dedup would skip every later
|
|
83
|
+
# attempt at that revision and the actor may not mutate again for a long
|
|
84
|
+
# time. Every name is still attempted before the decision is made.
|
|
85
|
+
attempts = payload_names.map { |name| transmit_state_payload(snapshot, name) }
|
|
86
|
+
return if attempts.any?(false)
|
|
87
|
+
|
|
91
88
|
@payload_revision = [ snapshot.instance_id, snapshot.revision ]
|
|
92
89
|
end
|
|
93
90
|
|
|
91
|
+
# A payload is one subscriber's view of one name. Letting it raise through
|
|
92
|
+
# here would reject the subscription or abandon the rest of a broadcast, so
|
|
93
|
+
# a failure is confined to the payload that caused it and reported. The
|
|
94
|
+
# exception message is deliberately not instrumented: a payload block reads
|
|
95
|
+
# actor state, so its message is the one place subscriber state could leak
|
|
96
|
+
# into logs.
|
|
97
|
+
#
|
|
98
|
+
# Returns whether this revision was settled for the name. An unauthorized
|
|
99
|
+
# payload is settled: the decision is stable, so retrying it would only
|
|
100
|
+
# re-deliver its authorized siblings.
|
|
101
|
+
# @rbs (ActorSnapshot, String) -> bool
|
|
102
|
+
def transmit_state_payload(snapshot, name)
|
|
103
|
+
payload = PayloadBroadcast.new(
|
|
104
|
+
snapshot:,
|
|
105
|
+
name:,
|
|
106
|
+
authorization_context: payload_authorization_context(name)
|
|
107
|
+
).call
|
|
108
|
+
transmit TurboStreamRenderer.state_payload(payload)
|
|
109
|
+
true
|
|
110
|
+
rescue Unauthorized
|
|
111
|
+
true
|
|
112
|
+
rescue => error
|
|
113
|
+
SolidObjects.instrument(
|
|
114
|
+
:payload_broadcast_failed,
|
|
115
|
+
actor_type: reference.actor_type,
|
|
116
|
+
actor_id: reference.actor_id,
|
|
117
|
+
payload_name: name,
|
|
118
|
+
error_class: error.class.name
|
|
119
|
+
)
|
|
120
|
+
false
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Resolves the Cable connection to whatever the application uses as an
|
|
124
|
+
# authorization subject, so a payload block and `authorize_query` see the
|
|
125
|
+
# same object a controller render would pass.
|
|
126
|
+
# @rbs (String) -> untyped
|
|
127
|
+
def payload_authorization_context(name)
|
|
128
|
+
callable = SolidObjects.configuration.payload_authorization_context
|
|
129
|
+
return callable.call(connection:) unless CallableKeywords.accepts?(callable, :payload_name)
|
|
130
|
+
|
|
131
|
+
callable.call(connection:, payload_name: name)
|
|
132
|
+
end
|
|
133
|
+
|
|
94
134
|
# @rbs (ActorSnapshot) -> bool
|
|
95
135
|
def newer_payload_revision?(snapshot)
|
|
96
136
|
current = @payload_revision
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# rbs_inline: enabled
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
# Authorization context resolvers gained keywords after applications had
|
|
5
|
+
# already written them, so a resolver is called with what it declared it
|
|
6
|
+
# accepts rather than with everything the caller could offer.
|
|
7
|
+
module CallableKeywords
|
|
8
|
+
class << self
|
|
9
|
+
# @rbs (untyped, Symbol) -> bool
|
|
10
|
+
def accepts?(callable, keyword)
|
|
11
|
+
parameters(callable).any? do |type, name|
|
|
12
|
+
type == :keyrest || (%i[key keyreq].include?(type) && name == keyword)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
# A lambda answers `parameters` directly; a callable object answers it
|
|
19
|
+
# through its `call` method.
|
|
20
|
+
# @rbs (untyped) -> Array[[ Symbol, Symbol ]]
|
|
21
|
+
def parameters(callable)
|
|
22
|
+
return callable.parameters if callable.respond_to?(:parameters)
|
|
23
|
+
return callable.method(:call).parameters if callable.respond_to?(:call)
|
|
24
|
+
|
|
25
|
+
[]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -52,26 +52,19 @@ module SolidObjects
|
|
|
52
52
|
registration.dependencies.include?(observable_name) &&
|
|
53
53
|
newer_revision?(registration.dom_id, instance_id, revision)
|
|
54
54
|
end
|
|
55
|
-
|
|
56
|
-
streams = individual.map { |registration| refresh(registration, instance_id, revision) }
|
|
57
|
-
batched.group_by(&:batch).each_value do |group|
|
|
58
|
-
group.each { |registration| record_revision(registration, instance_id, revision) }
|
|
59
|
-
streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
|
|
60
|
-
end
|
|
61
|
-
streams
|
|
55
|
+
refresh_streams(changed, instance_id, revision)
|
|
62
56
|
end
|
|
63
57
|
|
|
64
58
|
# @rbs (ActorSnapshot) -> Array[String]
|
|
65
59
|
def reconnect_refreshes(snapshot)
|
|
66
|
-
registrations.
|
|
67
|
-
|
|
60
|
+
stale = registrations.select do |registration|
|
|
61
|
+
newer_revision?(
|
|
68
62
|
registration.dom_id,
|
|
69
63
|
snapshot.instance_id,
|
|
70
64
|
snapshot.revision
|
|
71
65
|
)
|
|
72
|
-
|
|
73
|
-
refresh(registration, snapshot.instance_id, snapshot.revision)
|
|
74
66
|
end
|
|
67
|
+
refresh_streams(stale, snapshot.instance_id, snapshot.revision)
|
|
75
68
|
end
|
|
76
69
|
|
|
77
70
|
class << self
|
|
@@ -91,6 +84,21 @@ module SolidObjects
|
|
|
91
84
|
|
|
92
85
|
attr_reader :registrations, :revisions
|
|
93
86
|
|
|
87
|
+
# Live invalidations and reconnect replays share this, so a reconnecting
|
|
88
|
+
# client pays the same number of requests a connected one does.
|
|
89
|
+
# @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
|
|
90
|
+
def refresh_streams(changed, instance_id, revision)
|
|
91
|
+
batched, individual = changed.partition(&:batch)
|
|
92
|
+
streams = individual.map do |registration|
|
|
93
|
+
refresh(registration, instance_id, revision)
|
|
94
|
+
end
|
|
95
|
+
batched.group_by(&:batch).each_value do |group|
|
|
96
|
+
group.each { |registration| record_revision(registration, instance_id, revision) }
|
|
97
|
+
streams << TurboStreamRenderer.batch_refresh(group, instance_id, revision)
|
|
98
|
+
end
|
|
99
|
+
streams
|
|
100
|
+
end
|
|
101
|
+
|
|
94
102
|
# @rbs (ComponentRegistration, Integer, Integer) -> void
|
|
95
103
|
def record_revision(registration, instance_id, revision)
|
|
96
104
|
revisions[registration.dom_id] = [ instance_id, revision ]
|
|
@@ -22,6 +22,7 @@ module SolidObjects
|
|
|
22
22
|
# @rbs @process_alive_threshold: Float
|
|
23
23
|
# @rbs @shutdown_timeout: Float
|
|
24
24
|
# @rbs @supervisor_monitor_interval: Float
|
|
25
|
+
# @rbs @retention_interval: Float
|
|
25
26
|
# @rbs @dead_process_cleanup_interval: Float
|
|
26
27
|
# @rbs @message_retention: Numeric
|
|
27
28
|
# @rbs @message_retention_by_actor_type: Hash[String, Numeric]
|
|
@@ -39,6 +40,7 @@ module SolidObjects
|
|
|
39
40
|
# @rbs @wake_up_adapter: untyped
|
|
40
41
|
# @rbs @component_path_resolver: Proc?
|
|
41
42
|
# @rbs @component_authorization_context: Proc
|
|
43
|
+
# @rbs @payload_authorization_context: Proc
|
|
42
44
|
# @rbs @authorize_message: Proc
|
|
43
45
|
# @rbs @authorize_query: Proc
|
|
44
46
|
# @rbs @authorize_destroy: Proc
|
|
@@ -65,6 +67,7 @@ module SolidObjects
|
|
|
65
67
|
:process_alive_threshold,
|
|
66
68
|
:shutdown_timeout,
|
|
67
69
|
:supervisor_monitor_interval,
|
|
70
|
+
:retention_interval,
|
|
68
71
|
:dead_process_cleanup_interval,
|
|
69
72
|
:message_retention,
|
|
70
73
|
:message_retention_by_actor_type,
|
|
@@ -82,6 +85,7 @@ module SolidObjects
|
|
|
82
85
|
:wake_up_adapter,
|
|
83
86
|
:component_path_resolver,
|
|
84
87
|
:component_authorization_context,
|
|
88
|
+
:payload_authorization_context,
|
|
85
89
|
:authorize_message,
|
|
86
90
|
:authorize_query,
|
|
87
91
|
:authorize_destroy,
|
|
@@ -108,6 +112,7 @@ module SolidObjects
|
|
|
108
112
|
@lock_retry_attempts = 10
|
|
109
113
|
@supervisor_monitor_interval = 1.0
|
|
110
114
|
@dead_process_cleanup_interval = 60.0
|
|
115
|
+
@retention_interval = 3600.0
|
|
111
116
|
@process_heartbeat_interval = 15.0
|
|
112
117
|
@process_alive_threshold = 60.0
|
|
113
118
|
@shutdown_timeout = 15.0
|
|
@@ -126,6 +131,7 @@ module SolidObjects
|
|
|
126
131
|
@wake_up_adapter = nil
|
|
127
132
|
@component_path_resolver = nil
|
|
128
133
|
@component_authorization_context = ->(controller:) { controller }
|
|
134
|
+
@payload_authorization_context = ->(connection:) { connection }
|
|
129
135
|
@logger = if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
130
136
|
Rails.logger
|
|
131
137
|
else
|
|
@@ -144,6 +150,10 @@ module SolidObjects
|
|
|
144
150
|
raise ArgumentError, "table_name_prefix must contain lowercase letters, digits, and underscores"
|
|
145
151
|
end
|
|
146
152
|
|
|
153
|
+
if retention_interval.negative?
|
|
154
|
+
raise ArgumentError, "retention_interval must not be negative"
|
|
155
|
+
end
|
|
156
|
+
|
|
147
157
|
unless supervisor_monitor_interval.positive?
|
|
148
158
|
raise ArgumentError, "supervisor_monitor_interval must be positive"
|
|
149
159
|
end
|
|
@@ -176,6 +186,9 @@ module SolidObjects
|
|
|
176
186
|
unless component_authorization_context.respond_to?(:call)
|
|
177
187
|
raise ArgumentError, "component_authorization_context must respond to call"
|
|
178
188
|
end
|
|
189
|
+
unless payload_authorization_context.respond_to?(:call)
|
|
190
|
+
raise ArgumentError, "payload_authorization_context must respond to call"
|
|
191
|
+
end
|
|
179
192
|
|
|
180
193
|
self
|
|
181
194
|
end
|
|
@@ -41,7 +41,7 @@ module SolidObjects
|
|
|
41
41
|
# @rbs (ActorDefinition::Handler) -> untyped
|
|
42
42
|
def rendered_payload(handler)
|
|
43
43
|
payload = Serialization.dump(
|
|
44
|
-
handler
|
|
44
|
+
evaluated_payload(handler),
|
|
45
45
|
max_bytes: MAXIMUM_PAYLOAD_BYTES
|
|
46
46
|
)
|
|
47
47
|
return payload if payload.is_a?(Hash) || payload.is_a?(Array)
|
|
@@ -50,6 +50,34 @@ module SolidObjects
|
|
|
50
50
|
"payload broadcast #{name.inspect} must return a JSON object or array"
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# The block runs against the actor instance, like every other block in the
|
|
54
|
+
# actor DSL, and still receives the actor and the resolved authorization
|
|
55
|
+
# context as arguments, so blocks written to the documented signature are
|
|
56
|
+
# unaffected.
|
|
57
|
+
# @rbs (ActorDefinition::Handler) -> untyped
|
|
58
|
+
def evaluated_payload(handler)
|
|
59
|
+
actor = snapshot.actor
|
|
60
|
+
actor.instance_exec(actor, authorization_context, &handler.block)
|
|
61
|
+
rescue NameError => error
|
|
62
|
+
raise unless class_level_receiver?(error)
|
|
63
|
+
|
|
64
|
+
raise InvalidPayloadBroadcast,
|
|
65
|
+
"payload broadcast #{name.inspect} called #{error.name.inspect} on the " \
|
|
66
|
+
"actor class. Payload blocks now run against the actor instance, like " \
|
|
67
|
+
"every other actor block. Call it on the class explicitly."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# Distinguishes a block that relied on the old class-level receiver from an
|
|
71
|
+
# ordinary typo, so the one behaviour change reports itself instead of
|
|
72
|
+
# surfacing as an unexplained NameError.
|
|
73
|
+
# @rbs (NameError[untyped]) -> bool
|
|
74
|
+
def class_level_receiver?(error)
|
|
75
|
+
error.receiver.equal?(snapshot.actor) &&
|
|
76
|
+
snapshot.actor_class.respond_to?(error.name)
|
|
77
|
+
rescue ArgumentError, NameError
|
|
78
|
+
false
|
|
79
|
+
end
|
|
80
|
+
|
|
53
81
|
# @rbs () -> void
|
|
54
82
|
def authorize!
|
|
55
83
|
authorized = SolidObjects.configuration.authorize_query.call(
|
|
@@ -2,11 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Supervisor
|
|
5
|
+
MAXIMUM_RETENTION_BACKOFF_DOUBLINGS = 16
|
|
6
|
+
|
|
5
7
|
# @rbs @components: Array[Worker | EffectExecutor | ReminderScheduler | BroadcastExecutor]
|
|
6
8
|
# @rbs @threads: Array[Thread]
|
|
7
9
|
# @rbs @monitor: Thread?
|
|
8
10
|
# @rbs @started: bool
|
|
9
11
|
# @rbs @cleaned_up_at: Float
|
|
12
|
+
# @rbs @retention: Thread?
|
|
10
13
|
# @rbs @lifecycle: Thread::Mutex
|
|
11
14
|
|
|
12
15
|
# @rbs (?worker_count: Integer, ?effect_worker_count: Integer, ?broadcast_worker_count: Integer, ?reminder_scheduler_count: Integer) -> void
|
|
@@ -26,6 +29,7 @@ module SolidObjects
|
|
|
26
29
|
@monitor = nil
|
|
27
30
|
@started = false
|
|
28
31
|
@cleaned_up_at = nil
|
|
32
|
+
@retention = nil
|
|
29
33
|
@lifecycle = Thread::Mutex.new
|
|
30
34
|
end
|
|
31
35
|
|
|
@@ -44,6 +48,7 @@ module SolidObjects
|
|
|
44
48
|
@started = true
|
|
45
49
|
@threads = components.map { |component| supervise(component) }
|
|
46
50
|
@monitor = Thread.new { monitor_loop }
|
|
51
|
+
@retention = Thread.new { retention_loop }
|
|
47
52
|
SolidObjects.instrument(:"supervisor.started", component_count: components.length)
|
|
48
53
|
end
|
|
49
54
|
|
|
@@ -57,6 +62,7 @@ module SolidObjects
|
|
|
57
62
|
# list, or never starts.
|
|
58
63
|
@lifecycle.synchronize { @started = false }
|
|
59
64
|
stop_monitor
|
|
65
|
+
stop_retention
|
|
60
66
|
components.each(&:request_shutdown)
|
|
61
67
|
join_until_timeout
|
|
62
68
|
components.reject(&:stopped?).each(&:stop)
|
|
@@ -133,6 +139,76 @@ module SolidObjects
|
|
|
133
139
|
error.class.name
|
|
134
140
|
end
|
|
135
141
|
|
|
142
|
+
# Retention gets its own thread rather than sharing the monitor's. A large
|
|
143
|
+
# backlog or a lock wait can make a pass slow, and role replacement must not
|
|
144
|
+
# wait behind housekeeping.
|
|
145
|
+
# @rbs () -> void
|
|
146
|
+
def retention_loop
|
|
147
|
+
failures = 0
|
|
148
|
+
while @started
|
|
149
|
+
begin
|
|
150
|
+
prune_expired_records
|
|
151
|
+
failures = 0
|
|
152
|
+
rescue => error
|
|
153
|
+
failures += 1
|
|
154
|
+
SolidObjects.instrument(
|
|
155
|
+
:"supervisor.retention_failed",
|
|
156
|
+
error_class: error.class.name,
|
|
157
|
+
error_message: error.message
|
|
158
|
+
)
|
|
159
|
+
end
|
|
160
|
+
wait_for_next_retention(failures)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Sleeping the whole interval would make shutdown wait out an hour-long
|
|
165
|
+
# nap, so the pause is taken in short steps that notice a stop request.
|
|
166
|
+
# @rbs (Integer) -> void
|
|
167
|
+
def wait_for_next_retention(failures)
|
|
168
|
+
deadline = monotonic_now + retention_pause(failures)
|
|
169
|
+
step = SolidObjects.configuration.supervisor_monitor_interval
|
|
170
|
+
while @started && monotonic_now < deadline
|
|
171
|
+
sleep [ step, deadline - monotonic_now ].min
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Every actor call writes a durable message row, so retention that is only
|
|
176
|
+
# configured and never run leaves those rows to grow without bound. The
|
|
177
|
+
# supervisor runs it rather than requiring every application to schedule
|
|
178
|
+
# its own job.
|
|
179
|
+
# @rbs () -> void
|
|
180
|
+
def prune_expired_records
|
|
181
|
+
return unless SolidObjects.configuration.retention_interval.positive?
|
|
182
|
+
|
|
183
|
+
MessagePruner.new.prune
|
|
184
|
+
ProcessPruner.new.prune
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
# A transient lock or connection error must not defer retention for the
|
|
188
|
+
# whole interval, so a failed pass retries at monitor cadence. The pause
|
|
189
|
+
# then doubles per consecutive failure, capped by the interval, so a
|
|
190
|
+
# database that stays down is not polled once a second forever.
|
|
191
|
+
# @rbs (Integer) -> Float
|
|
192
|
+
def retention_pause(failures)
|
|
193
|
+
interval = SolidObjects.configuration.retention_interval
|
|
194
|
+
interval = SolidObjects.configuration.supervisor_monitor_interval unless interval.positive?
|
|
195
|
+
return interval if failures.zero?
|
|
196
|
+
|
|
197
|
+
backoff = SolidObjects.configuration.supervisor_monitor_interval *
|
|
198
|
+
(2**[ failures - 1, MAXIMUM_RETENTION_BACKOFF_DOUBLINGS ].min)
|
|
199
|
+
[ backoff, interval ].min
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# @rbs () -> void
|
|
203
|
+
def stop_retention
|
|
204
|
+
retention = @retention
|
|
205
|
+
@retention = nil
|
|
206
|
+
return unless retention
|
|
207
|
+
|
|
208
|
+
retention.join(SolidObjects.configuration.shutdown_timeout)
|
|
209
|
+
retention.kill if retention.alive?
|
|
210
|
+
end
|
|
211
|
+
|
|
136
212
|
# @rbs () -> void
|
|
137
213
|
def cleanup_dead_processes
|
|
138
214
|
interval = SolidObjects.configuration.dead_process_cleanup_interval
|
data/lib/solid_objects.rb
CHANGED
|
@@ -13,6 +13,7 @@ require "securerandom"
|
|
|
13
13
|
require "solid_objects/version"
|
|
14
14
|
require "solid_objects/errors"
|
|
15
15
|
require "solid_objects/sync_deadline"
|
|
16
|
+
require "solid_objects/callable_keywords"
|
|
16
17
|
require "solid_objects/configuration"
|
|
17
18
|
require "solid_objects/instrumentation"
|
|
18
19
|
require "solid_objects/log_subscriber"
|
|
@@ -27,14 +27,6 @@ module SolidObjects
|
|
|
27
27
|
# @rbs (Array[ComponentRegistration]) -> untyped
|
|
28
28
|
def component_authorization_context: (Array[ComponentRegistration]) -> untyped
|
|
29
29
|
|
|
30
|
-
# A lambda answers `parameters` directly; a callable object answers it
|
|
31
|
-
# through its `call` method.
|
|
32
|
-
# @rbs (untyped) -> bool
|
|
33
|
-
def accepts_registrations?: (untyped) -> bool
|
|
34
|
-
|
|
35
|
-
# @rbs (untyped) -> Array[[ Symbol, Symbol ]]
|
|
36
|
-
def callable_parameters: (untyped) -> Array[[ Symbol, Symbol ]]
|
|
37
|
-
|
|
38
30
|
# @rbs (ComponentRegistration) -> Hash[Symbol, untyped]
|
|
39
31
|
def registration_payload: (ComponentRegistration) -> Hash[Symbol, untyped]
|
|
40
32
|
|
|
@@ -21,6 +21,25 @@ module SolidObjects
|
|
|
21
21
|
# @rbs (ActorSnapshot) -> void
|
|
22
22
|
def transmit_state_payloads: (ActorSnapshot) -> void
|
|
23
23
|
|
|
24
|
+
# A payload is one subscriber's view of one name. Letting it raise through
|
|
25
|
+
# here would reject the subscription or abandon the rest of a broadcast, so
|
|
26
|
+
# a failure is confined to the payload that caused it and reported. The
|
|
27
|
+
# exception message is deliberately not instrumented: a payload block reads
|
|
28
|
+
# actor state, so its message is the one place subscriber state could leak
|
|
29
|
+
# into logs.
|
|
30
|
+
#
|
|
31
|
+
# Returns whether this revision was settled for the name. An unauthorized
|
|
32
|
+
# payload is settled: the decision is stable, so retrying it would only
|
|
33
|
+
# re-deliver its authorized siblings.
|
|
34
|
+
# @rbs (ActorSnapshot, String) -> bool
|
|
35
|
+
def transmit_state_payload: (ActorSnapshot, String) -> bool
|
|
36
|
+
|
|
37
|
+
# Resolves the Cable connection to whatever the application uses as an
|
|
38
|
+
# authorization subject, so a payload block and `authorize_query` see the
|
|
39
|
+
# same object a controller render would pass.
|
|
40
|
+
# @rbs (String) -> untyped
|
|
41
|
+
def payload_authorization_context: (String) -> untyped
|
|
42
|
+
|
|
24
43
|
# @rbs (ActorSnapshot) -> bool
|
|
25
44
|
def newer_payload_revision?: (ActorSnapshot) -> bool
|
|
26
45
|
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# Generated from lib/solid_objects/callable_keywords.rb with RBS::Inline
|
|
2
|
+
|
|
3
|
+
module SolidObjects
|
|
4
|
+
# Authorization context resolvers gained keywords after applications had
|
|
5
|
+
# already written them, so a resolver is called with what it declared it
|
|
6
|
+
# accepts rather than with everything the caller could offer.
|
|
7
|
+
module CallableKeywords
|
|
8
|
+
# @rbs (untyped, Symbol) -> bool
|
|
9
|
+
def self.accepts?: (untyped, Symbol) -> bool
|
|
10
|
+
|
|
11
|
+
# A lambda answers `parameters` directly; a callable object answers it
|
|
12
|
+
# through its `call` method.
|
|
13
|
+
# @rbs (untyped) -> Array[[ Symbol, Symbol ]]
|
|
14
|
+
private def self.parameters: (untyped) -> Array[[ Symbol, Symbol ]]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -31,6 +31,11 @@ module SolidObjects
|
|
|
31
31
|
|
|
32
32
|
attr_reader revisions: untyped
|
|
33
33
|
|
|
34
|
+
# Live invalidations and reconnect replays share this, so a reconnecting
|
|
35
|
+
# client pays the same number of requests a connected one does.
|
|
36
|
+
# @rbs (Array[ComponentRegistration], Integer, Integer) -> Array[String]
|
|
37
|
+
def refresh_streams: (Array[ComponentRegistration], Integer, Integer) -> Array[String]
|
|
38
|
+
|
|
34
39
|
# @rbs (ComponentRegistration, Integer, Integer) -> void
|
|
35
40
|
def record_revision: (ComponentRegistration, Integer, Integer) -> void
|
|
36
41
|
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Configuration
|
|
5
|
-
@shutdown_timeout: Float
|
|
6
|
-
|
|
7
5
|
@supervisor_monitor_interval: Float
|
|
8
6
|
|
|
7
|
+
@retention_interval: Float
|
|
8
|
+
|
|
9
9
|
@dead_process_cleanup_interval: Float
|
|
10
10
|
|
|
11
11
|
@message_retention: Numeric
|
|
@@ -40,6 +40,8 @@ module SolidObjects
|
|
|
40
40
|
|
|
41
41
|
@component_authorization_context: Proc
|
|
42
42
|
|
|
43
|
+
@payload_authorization_context: Proc
|
|
44
|
+
|
|
43
45
|
@authorize_message: Proc
|
|
44
46
|
|
|
45
47
|
@authorize_query: Proc
|
|
@@ -86,6 +88,8 @@ module SolidObjects
|
|
|
86
88
|
|
|
87
89
|
@process_alive_threshold: Float
|
|
88
90
|
|
|
91
|
+
@shutdown_timeout: Float
|
|
92
|
+
|
|
89
93
|
attr_accessor table_name_prefix: untyped
|
|
90
94
|
|
|
91
95
|
attr_accessor polling_interval: untyped
|
|
@@ -126,6 +130,8 @@ module SolidObjects
|
|
|
126
130
|
|
|
127
131
|
attr_accessor supervisor_monitor_interval: untyped
|
|
128
132
|
|
|
133
|
+
attr_accessor retention_interval: untyped
|
|
134
|
+
|
|
129
135
|
attr_accessor dead_process_cleanup_interval: untyped
|
|
130
136
|
|
|
131
137
|
attr_accessor message_retention: untyped
|
|
@@ -160,6 +166,8 @@ module SolidObjects
|
|
|
160
166
|
|
|
161
167
|
attr_accessor component_authorization_context: untyped
|
|
162
168
|
|
|
169
|
+
attr_accessor payload_authorization_context: untyped
|
|
170
|
+
|
|
163
171
|
attr_accessor authorize_message: untyped
|
|
164
172
|
|
|
165
173
|
attr_accessor authorize_query: untyped
|
|
@@ -29,6 +29,19 @@ module SolidObjects
|
|
|
29
29
|
# @rbs (ActorDefinition::Handler) -> untyped
|
|
30
30
|
def rendered_payload: (ActorDefinition::Handler) -> untyped
|
|
31
31
|
|
|
32
|
+
# The block runs against the actor instance, like every other block in the
|
|
33
|
+
# actor DSL, and still receives the actor and the resolved authorization
|
|
34
|
+
# context as arguments, so blocks written to the documented signature are
|
|
35
|
+
# unaffected.
|
|
36
|
+
# @rbs (ActorDefinition::Handler) -> untyped
|
|
37
|
+
def evaluated_payload: (ActorDefinition::Handler) -> untyped
|
|
38
|
+
|
|
39
|
+
# Distinguishes a block that relied on the old class-level receiver from an
|
|
40
|
+
# ordinary typo, so the one behaviour change reports itself instead of
|
|
41
|
+
# surfacing as an unexplained NameError.
|
|
42
|
+
# @rbs (NameError[untyped]) -> bool
|
|
43
|
+
def class_level_receiver?: (NameError[untyped]) -> bool
|
|
44
|
+
|
|
32
45
|
# @rbs () -> void
|
|
33
46
|
def authorize!: () -> void
|
|
34
47
|
end
|
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module SolidObjects
|
|
4
4
|
class Supervisor
|
|
5
|
+
MAXIMUM_RETENTION_BACKOFF_DOUBLINGS: ::Integer
|
|
6
|
+
|
|
5
7
|
@lifecycle: Thread::Mutex
|
|
6
8
|
|
|
9
|
+
@retention: Thread?
|
|
10
|
+
|
|
7
11
|
@cleaned_up_at: Float
|
|
8
12
|
|
|
9
13
|
@started: bool
|
|
@@ -52,6 +56,34 @@ module SolidObjects
|
|
|
52
56
|
# @rbs (Thread?) -> String?
|
|
53
57
|
def thread_error: (Thread?) -> String?
|
|
54
58
|
|
|
59
|
+
# Retention gets its own thread rather than sharing the monitor's. A large
|
|
60
|
+
# backlog or a lock wait can make a pass slow, and role replacement must not
|
|
61
|
+
# wait behind housekeeping.
|
|
62
|
+
# @rbs () -> void
|
|
63
|
+
def retention_loop: () -> void
|
|
64
|
+
|
|
65
|
+
# Sleeping the whole interval would make shutdown wait out an hour-long
|
|
66
|
+
# nap, so the pause is taken in short steps that notice a stop request.
|
|
67
|
+
# @rbs (Integer) -> void
|
|
68
|
+
def wait_for_next_retention: (Integer) -> void
|
|
69
|
+
|
|
70
|
+
# Every actor call writes a durable message row, so retention that is only
|
|
71
|
+
# configured and never run leaves those rows to grow without bound. The
|
|
72
|
+
# supervisor runs it rather than requiring every application to schedule
|
|
73
|
+
# its own job.
|
|
74
|
+
# @rbs () -> void
|
|
75
|
+
def prune_expired_records: () -> void
|
|
76
|
+
|
|
77
|
+
# A transient lock or connection error must not defer retention for the
|
|
78
|
+
# whole interval, so a failed pass retries at monitor cadence. The pause
|
|
79
|
+
# then doubles per consecutive failure, capped by the interval, so a
|
|
80
|
+
# database that stays down is not polled once a second forever.
|
|
81
|
+
# @rbs (Integer) -> Float
|
|
82
|
+
def retention_pause: (Integer) -> Float
|
|
83
|
+
|
|
84
|
+
# @rbs () -> void
|
|
85
|
+
def stop_retention: () -> void
|
|
86
|
+
|
|
55
87
|
# @rbs () -> void
|
|
56
88
|
def cleanup_dead_processes: () -> void
|
|
57
89
|
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: solid_objects
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.10.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Lucas Carlson
|
|
@@ -354,6 +354,7 @@ files:
|
|
|
354
354
|
- lib/solid_objects/application_actor_loader.rb
|
|
355
355
|
- lib/solid_objects/application_write_guard.rb
|
|
356
356
|
- lib/solid_objects/broadcast_executor.rb
|
|
357
|
+
- lib/solid_objects/callable_keywords.rb
|
|
357
358
|
- lib/solid_objects/caller_process.rb
|
|
358
359
|
- lib/solid_objects/cli.rb
|
|
359
360
|
- lib/solid_objects/client.rb
|
|
@@ -428,6 +429,7 @@ files:
|
|
|
428
429
|
- sig/generated/lib/solid_objects/application_actor_loader.rbs
|
|
429
430
|
- sig/generated/lib/solid_objects/application_write_guard.rbs
|
|
430
431
|
- sig/generated/lib/solid_objects/broadcast_executor.rbs
|
|
432
|
+
- sig/generated/lib/solid_objects/callable_keywords.rbs
|
|
431
433
|
- sig/generated/lib/solid_objects/caller_process.rbs
|
|
432
434
|
- sig/generated/lib/solid_objects/cli.rbs
|
|
433
435
|
- sig/generated/lib/solid_objects/client.rbs
|