solid_objects 0.9.0 → 0.10.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 +87 -0
- data/README.md +2 -1
- data/app/controllers/solid_objects/components_controller.rb +3 -18
- data/app/models/solid_objects/instance.rb +31 -4
- data/benchmark/support.rb +47 -5
- data/docs/architecture.md +4 -0
- data/docs/benchmarks.md +19 -2
- data/docs/local-testing.md +7 -0
- data/docs/realtime.md +59 -4
- data/docs/roadmap.md +49 -28
- 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/database_adapter.rb +24 -4
- data/lib/solid_objects/database_adapters/mysql.rb +17 -1
- 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/wake_up_adapters.rb +1 -1
- data/lib/solid_objects.rb +2 -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/database_adapter.rbs +10 -0
- data/sig/generated/lib/solid_objects/database_adapters/mysql.rbs +10 -0
- data/sig/generated/lib/solid_objects/payload_broadcast.rbs +13 -0
- data/sig/generated/lib/solid_objects/supervisor.rbs +32 -0
- data/sig/generated/models/solid_objects/instance.rbs +11 -0
- metadata +17 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 28a727648d2afc1ed0cd5be03fd624617db444bf173b289327d8c69e2a4abf80
|
|
4
|
+
data.tar.gz: 0f3bb956b7dcda6f993c92d3ce7f24a3ecf93caf0435030fe2e44955eb581c22
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6d5ffbb3074eff11b32966560a2ca42dca37dc7c8c9cf1725a9965a40815752379be88f18b6b0b184592931d180f7248d6c01e5344be2b79a29cdde5377ab23f
|
|
7
|
+
data.tar.gz: ca700bcc8d86a6939c462be3003b0e649c6a2611289b12064dda9d0f6e3f844cc332e479b355b248e0a97c9073e537b1b99a8ae0cfd7ac454b170bd03bbd9a71
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,92 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.10.1 - 2026-08-10
|
|
4
|
+
|
|
5
|
+
- Support Trilogy. Adapter selection matched the client name rather than the
|
|
6
|
+
protocol, and Trilogy reports `"Trilogy"`, so every Solid Objects call raised
|
|
7
|
+
`UnsupportedDatabase: unsupported database adapter "Trilogy"` on a database
|
|
8
|
+
the gem fully supports. Adapter names now resolve through one table of
|
|
9
|
+
families, `DatabaseAdapter.family`, used by adapter selection, owner-id
|
|
10
|
+
casting, and wake-up adapter selection alike, so a client cannot be accepted
|
|
11
|
+
in one place and rejected in another.
|
|
12
|
+
- Compare reconciliation owner ids in the column's own collation.
|
|
13
|
+
`Instance.orphaned` cast owner primary keys to `CHAR`, and a cast result
|
|
14
|
+
carries the connection collation rather than the column's. MySQL refuses to
|
|
15
|
+
compare two collations, so the query raised `Illegal mix of collations`
|
|
16
|
+
whenever the two differed. That is a property of the client rather than the
|
|
17
|
+
schema: mysql2 negotiates the database default while Trilogy negotiates
|
|
18
|
+
`utf8mb4_general_ci`. A mysql2 application that set `collation:` in
|
|
19
|
+
`database.yml` could already hit this.
|
|
20
|
+
- Recognise a statement interruption from any MySQL client. A synchronous
|
|
21
|
+
deadline is enforced by asking the server to interrupt the statement, and the
|
|
22
|
+
interruption was matched only through mysql2's `error_number`. Trilogy names
|
|
23
|
+
it `error_code`, so a deadline surfaced as a raw
|
|
24
|
+
`ActiveRecord::StatementTimeout` instead of `SyncEnqueueTimeout`. Both names
|
|
25
|
+
are read, and Active Record's own classification is trusted first.
|
|
26
|
+
- Run the MySQL suite against both mysql2 and Trilogy in CI, and key
|
|
27
|
+
adapter-specific test skips to the database family rather than the client
|
|
28
|
+
name, so a Trilogy run no longer silently skips every MySQL test.
|
|
29
|
+
|
|
30
|
+
## 0.10.0 - 2026-08-10
|
|
31
|
+
|
|
32
|
+
- Report a denied CLI command as a policy decision rather than a crash.
|
|
33
|
+
Administration denies by default, so an unconfigured host met a thirty-line
|
|
34
|
+
Ruby backtrace on its first `solid_objects` command. The executable now
|
|
35
|
+
prints the refusal and the setting that grants access, and exits 1.
|
|
36
|
+
- Measure the query count for a synchronous call. `benchmark/query_count.rb`
|
|
37
|
+
only measured a worker turn, so the documented synchronous number had no
|
|
38
|
+
script behind it. It reports three now: a message turn costs 26 queries
|
|
39
|
+
rather than the documented 29, the caller of a synchronous call costs 49, and
|
|
40
|
+
a synchronous call in total costs 75, being a caller plus the turn it waits
|
|
41
|
+
on. Counting is scoped to the measuring thread, since a worker loop polls
|
|
42
|
+
whether or not a call is in flight and a process-wide count folds those polls
|
|
43
|
+
into the result.
|
|
44
|
+
|
|
45
|
+
- Run payload broadcast blocks against the actor instance, like every other
|
|
46
|
+
block in the actor DSL. `self` was the actor class, so an actor instance
|
|
47
|
+
method called from a payload block raised
|
|
48
|
+
`NoMethodError: undefined method 'x' for class PlaymatRoom`. Blocks keep
|
|
49
|
+
receiving the actor and the authorization context as arguments, so the
|
|
50
|
+
documented signature is unaffected. A block that relied on the class receiver
|
|
51
|
+
now raises `InvalidPayloadBroadcast` naming the method and the change instead
|
|
52
|
+
of an unexplained `NameError`.
|
|
53
|
+
- Add `payload_authorization_context`, the payload counterpart to
|
|
54
|
+
`component_authorization_context`. Payloads are computed inside the channel,
|
|
55
|
+
so without a resolver the payload block and its `authorize_query` call
|
|
56
|
+
received the raw Action Cable connection while a controller render passed an
|
|
57
|
+
application object, and the authorization hook had to tell them apart. The
|
|
58
|
+
resolver may also accept `payload_name:`. It defaults to returning the
|
|
59
|
+
connection unchanged.
|
|
60
|
+
- Confine a failing payload to itself. A raising payload block propagated out of
|
|
61
|
+
the channel: on subscribe it rejected the subscription, and on a broadcast it
|
|
62
|
+
abandoned the remaining payload names, which showed up in the browser only as
|
|
63
|
+
reactive updates that stopped arriving. A failure is now reported as
|
|
64
|
+
`solid_objects.payload_broadcast_failed` with the actor type, actor id,
|
|
65
|
+
payload name, and exception class, and delivery continues. The exception
|
|
66
|
+
message is deliberately excluded so subscriber state cannot leak into logs. A
|
|
67
|
+
revision with a failed payload does not advance the delivery watermark, so a
|
|
68
|
+
transient failure is retried on the next broadcast instead of being recorded
|
|
69
|
+
as delivered and deduplicated away.
|
|
70
|
+
- Run retention on the supervisor rather than leaving it configured but
|
|
71
|
+
unscheduled. Every actor call writes a durable message row, so a policy that
|
|
72
|
+
nothing invokes let history grow without bound until an application scheduled
|
|
73
|
+
its own job. `retention_interval` defaults to one hour, and zero disables it.
|
|
74
|
+
Retention runs on its own thread, so a slow pass cannot delay replacing a
|
|
75
|
+
crashed role, and a failed pass retries at monitor cadence with a doubling
|
|
76
|
+
backoff rather than deferring for the whole interval.
|
|
77
|
+
- Batch component refreshes on reconnect. A reconnecting subscription refreshed
|
|
78
|
+
every stale component individually, ignoring the batches those components
|
|
79
|
+
declared, so a page with twenty batched components issued twenty requests
|
|
80
|
+
instead of one. That happens at the worst moment: a server restart reconnects
|
|
81
|
+
every client at once. Reconnect now shares the batching the live invalidation
|
|
82
|
+
path uses.
|
|
83
|
+
- Cover the reconnect burst in the browser suite: convergence of batched and
|
|
84
|
+
unbatched components, an inert replay of an already-applied revision,
|
|
85
|
+
cancellation of the request left in flight by the drop, incarnation ordering
|
|
86
|
+
after a destroy and recreate, and payload delivery exactly once per revision.
|
|
87
|
+
- Add Ruby 4.0 to the compatibility matrix, which now covers Ruby 3.3, 3.4, and
|
|
88
|
+
4.0 against Rails 8.0 and 8.1.
|
|
89
|
+
|
|
3
90
|
## 0.9.0 - 2026-08-10
|
|
4
91
|
|
|
5
92
|
- Add a browser test suite running the refresh modules against real Chromium and
|
data/README.md
CHANGED
|
@@ -956,7 +956,8 @@ shutdown, retention, and backup guidance.
|
|
|
956
956
|
Solid Objects supports:
|
|
957
957
|
|
|
958
958
|
- PostgreSQL 14 or newer
|
|
959
|
-
- MySQL 8.0 or newer using InnoDB
|
|
959
|
+
- MySQL 8.0 or newer using InnoDB, through either the `mysql2` or `trilogy`
|
|
960
|
+
client
|
|
960
961
|
- SQLite 3.35 or newer
|
|
961
962
|
|
|
962
963
|
PostgreSQL and MySQL use `FOR UPDATE SKIP LOCKED` when claiming hot-table rows.
|
|
@@ -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]
|
|
@@ -56,7 +56,7 @@ module SolidObjects
|
|
|
56
56
|
)
|
|
57
57
|
owner_ids = owner_relation
|
|
58
58
|
.except(:select)
|
|
59
|
-
.select(cast_id)
|
|
59
|
+
.select(collated(cast_id))
|
|
60
60
|
|
|
61
61
|
where(actor_type:).where.not(actor_id: owner_ids)
|
|
62
62
|
end
|
|
@@ -70,12 +70,39 @@ module SolidObjects
|
|
|
70
70
|
|
|
71
71
|
private
|
|
72
72
|
|
|
73
|
+
# A cast result carries the connection collation, not the column's, and
|
|
74
|
+
# MySQL refuses to compare two collations. Which collation a connection
|
|
75
|
+
# uses is a property of the client rather than the schema: mysql2
|
|
76
|
+
# negotiates the database default while Trilogy negotiates
|
|
77
|
+
# utf8mb4_general_ci, so the comparison is pinned to the column's own.
|
|
78
|
+
# @rbs (untyped) -> untyped
|
|
79
|
+
def collated(node)
|
|
80
|
+
collation = owner_id_collation
|
|
81
|
+
return node unless collation
|
|
82
|
+
|
|
83
|
+
Arel::Nodes::InfixOperation.new(
|
|
84
|
+
"COLLATE",
|
|
85
|
+
node,
|
|
86
|
+
Arel::Nodes::SqlLiteral.new(collation)
|
|
87
|
+
)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# @rbs () -> String?
|
|
91
|
+
def owner_id_collation
|
|
92
|
+
return nil unless DatabaseAdapter.family(connection) == :mysql
|
|
93
|
+
|
|
94
|
+
collation = columns_hash["actor_id"]&.collation
|
|
95
|
+
return nil unless collation&.match?(/\A[a-zA-Z0-9_]+\z/)
|
|
96
|
+
|
|
97
|
+
collation
|
|
98
|
+
end
|
|
99
|
+
|
|
73
100
|
# @rbs () -> String
|
|
74
101
|
def owner_id_cast_type
|
|
75
|
-
case connection
|
|
76
|
-
when
|
|
102
|
+
case DatabaseAdapter.family(connection)
|
|
103
|
+
when :mysql
|
|
77
104
|
"CHAR"
|
|
78
|
-
when
|
|
105
|
+
when :postgresql
|
|
79
106
|
"VARCHAR"
|
|
80
107
|
else
|
|
81
108
|
"TEXT"
|
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/architecture.md
CHANGED
|
@@ -41,6 +41,10 @@ The host application owns:
|
|
|
41
41
|
## Database coordination adapters
|
|
42
42
|
|
|
43
43
|
Solid Objects supports PostgreSQL 14+, MySQL 8.0+ with InnoDB, and SQLite 3.35+.
|
|
44
|
+
MySQL is reached through either the `mysql2` or `trilogy` client. Adapter names
|
|
45
|
+
are client names rather than protocol names, so every decision that depends on
|
|
46
|
+
the database resolves through `DatabaseAdapter.family` rather than matching an
|
|
47
|
+
adapter name in place.
|
|
44
48
|
|
|
45
49
|
One adapter capability object is selected from the Active Record connection. It supplies claim locking and database-time expressions. Unsupported adapter families fail when first used. Minimum server-version and storage-engine checks are documented operating requirements; automatic boot-time enforcement and classified contention retries remain hardening work.
|
|
46
50
|
|
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/local-testing.md
CHANGED
|
@@ -48,10 +48,17 @@ docker run -d --name so-redis -p 6380:6379 redis:7-alpine
|
|
|
48
48
|
SOLID_OBJECTS_DATABASE_URL=mysql2://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
|
|
49
49
|
bundle exec rake test
|
|
50
50
|
|
|
51
|
+
SOLID_OBJECTS_DATABASE_URL=trilogy://solid_objects:solid_objects@127.0.0.1:3307/solid_objects_test \
|
|
52
|
+
bundle exec rake test
|
|
53
|
+
|
|
51
54
|
SOLID_OBJECTS_REDIS_URL=redis://127.0.0.1:6380/15 \
|
|
52
55
|
bundle exec rake test TEST=test/integration/redis_wake_up_test.rb
|
|
53
56
|
```
|
|
54
57
|
|
|
58
|
+
Run both MySQL clients. They report different adapter names, negotiate
|
|
59
|
+
different connection collations, and name the same error code differently, so a
|
|
60
|
+
pass on one says nothing about the other. Recreate the database between them.
|
|
61
|
+
|
|
55
62
|
Stop them with `docker rm -f so-mysql so-redis`.
|
|
56
63
|
|
|
57
64
|
## Recreating a database between runs
|
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,8 +42,12 @@
|
|
|
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
|
|
39
|
-
|
|
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
|
|
48
|
+
- SQLite, PostgreSQL, and MySQL integration suites, with MySQL run against both
|
|
49
|
+
the `mysql2` and `trilogy` clients because an adapter name, a connection
|
|
50
|
+
collation, and an error code name all differ between them
|
|
40
51
|
- Opt-in cross-process wake-up on PostgreSQL through `WakeUpAdapters.for`, with
|
|
41
52
|
a listening connection per waiting thread and release on supervisor shutdown
|
|
42
53
|
- Opt-in cross-process wake-up on Redis, the option for MySQL applications,
|
|
@@ -44,51 +55,61 @@
|
|
|
44
55
|
gem's dependencies
|
|
45
56
|
- Inline RBS generation/validation, Steep, Standard Ruby, Solid Queue's exact
|
|
46
57
|
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
|
|
58
|
+
- Compatibility CI across the supported span: Ruby 3.3, 3.4, and 4.0 against
|
|
59
|
+
Rails 8.0 and 8.1, pinned through `RAILS_VERSION` so the advertised range is
|
|
60
|
+
verified rather than assumed
|
|
50
61
|
- A JavaScript suite covering every browser module, run in CI with Node's test
|
|
51
62
|
runner and jsdom, plus a browser suite running the same modules against real
|
|
52
63
|
Chromium and a real Turbo build, with every GitHub Actions reference pinned to
|
|
53
|
-
a commit SHA
|
|
64
|
+
a commit SHA. The browser suite covers the reconnect burst: convergence of
|
|
65
|
+
batched and unbatched components, an inert replay of an applied revision,
|
|
66
|
+
cancellation of the request left in flight by the drop, incarnation ordering
|
|
67
|
+
after a destroy and recreate, and payload delivery exactly once per revision
|
|
54
68
|
|
|
55
69
|
## Partially implemented
|
|
56
70
|
|
|
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
|
-
|
|
71
|
+
- Wake-up strategy: in-process signaling, durable polling, injection, and
|
|
72
|
+
cross-process adapters for PostgreSQL and Redis are implemented and tested.
|
|
73
|
+
What is not done is making any of them automatic. In-process signaling cannot
|
|
74
|
+
cross process boundaries, so by default a commit in a web process does not
|
|
75
|
+
wake a broadcast executor in a worker process and that delivery waits up to
|
|
76
|
+
`polling_interval`, 100 ms. An adapter removes that floor, measured at 103.7 ms
|
|
77
|
+
to 2.9 ms at p50 on PostgreSQL and 103.8 ms to 5.7 ms on Redis, but each stays
|
|
78
|
+
opt-in for a reason: the PostgreSQL adapter opens a connection per waiting
|
|
79
|
+
thread outside the pool and `LISTEN` does not survive a transaction-pooling
|
|
80
|
+
proxy such as PgBouncer, and Redis is not a dependency of this gem.
|
|
81
|
+
`WakeUpAdapters.for` selects notifications on PostgreSQL and the in-process
|
|
82
|
+
default elsewhere; it never selects Redis. An application that configures
|
|
83
|
+
nothing keeps polling, and MySQL applications keep polling unless they
|
|
84
|
+
configure Redis explicitly.
|
|
68
85
|
- Realtime: scalar and dependency-driven keyed ERB component replacement or
|
|
69
86
|
morphing, personalized refresh authorization, revision fencing, coalescing,
|
|
70
87
|
reconnect convergence, batched refreshes, and personalized state payloads are
|
|
71
88
|
implemented; application-directed Turbo append intents are not. Batch
|
|
72
89
|
coalescing happens in the browser rather than the broadcast executor, so one
|
|
73
90
|
commit still sends one Action Cable message per changed observable even
|
|
74
|
-
though it costs one browser request.
|
|
91
|
+
though it costs one browser request. Reconnect convergence previously
|
|
92
|
+
bypassed batching entirely, issuing one request per stale component at the
|
|
93
|
+
moment a restart reconnects every client at once; it now shares the batching
|
|
94
|
+
the live invalidation path uses. Payload delivery over Action Cable was
|
|
95
|
+
untested end to end, which is how a raising payload block came to reject the
|
|
96
|
+
subscription; it is now covered and confined, and the payload authorization
|
|
97
|
+
context is resolved through `payload_authorization_context` rather than
|
|
98
|
+
handing the block a raw Cable connection.
|
|
75
99
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
76
100
|
distributed per-actor rate limits and global admission control do not.
|
|
77
101
|
- Administration: actor and dead-letter views plus policy hooks exist; richer
|
|
78
102
|
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
103
|
|
|
82
104
|
## Next milestones
|
|
83
105
|
|
|
84
106
|
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
|
|
107
|
+
2. Add Turbo append intents.
|
|
108
|
+
3. Add distributed rate limits, global admission hooks, and cache-capacity
|
|
88
109
|
eviction.
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
110
|
+
4. Expand security scanning beyond the Brakeman scan, such as dependency
|
|
111
|
+
auditing and secret scanning.
|
|
112
|
+
5. Benchmark all workloads under documented hardware/database settings and
|
|
92
113
|
publish adapter-specific adoption measurements. Throughput, synchronous
|
|
93
114
|
latency, query counts, and the three reactive delivery paths are measured on
|
|
94
115
|
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
|
#
|