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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: e5bd1724baaa40d79ec3693558ef7aa8e37830e4c5afd1da1d5774356d1e584e
|
|
4
|
+
data.tar.gz: 1e7a2307cae51316b9b4c4f56ea6fc986a622e3665541514137d87bbf72b2e9a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b1b5284ef616f306bea2136d7becf4a3c8e4aa2adbcc24eb5da08c3f33e85f34551f687ff7133cd3781abba79d4f0dac919657d9150bfe3ebd021417542d505b
|
|
7
|
+
data.tar.gz: 6971c0ee78d9eab66776a5ba6d60dcc403d3346c57cffca795797f50048fc419b6473d3d620c4d025238916757f52ed79ed1ec153feaa3dda84744d740b08045
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,65 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.13.1 - 2026-08-16
|
|
4
|
+
|
|
5
|
+
- Back idle actor, effect, reminder, and broadcast polling off exponentially
|
|
6
|
+
from the configured fast interval to a new one-second idle ceiling. Any
|
|
7
|
+
processed work or wake-up resets the role immediately, and actor polling
|
|
8
|
+
remains capped by the lease-renewal interval.
|
|
9
|
+
- Expose each role's `current_polling_interval` and emit
|
|
10
|
+
`solid_objects.polling.interval_changed` instrumentation for every idle,
|
|
11
|
+
work, and wake-up transition.
|
|
12
|
+
- Warn once when live processes share the database without a configured
|
|
13
|
+
cross-process wake-up adapter.
|
|
14
|
+
- Make the in-process wake-up generation-aware so a signal committed between
|
|
15
|
+
an empty claim and the wait is not missed. PostgreSQL and Redis adapters now
|
|
16
|
+
expose the same watch contract.
|
|
17
|
+
- Add a reproducible four-role SQLite idle benchmark and repair the benchmark
|
|
18
|
+
schema setup for the current operation columns.
|
|
19
|
+
- **Behavior change:** `polling_interval` is now the fast interval after
|
|
20
|
+
activity, not a constant idle cadence. Existing explicit values back off to
|
|
21
|
+
`idle_polling_interval`, which defaults to one second. Set both options to
|
|
22
|
+
the same value to preserve a fixed cadence. Existing custom wake-up adapters
|
|
23
|
+
that return `nil` remain at the fast cadence until they return `false` for a
|
|
24
|
+
timeout and `true` for a notification.
|
|
25
|
+
|
|
26
|
+
## 0.13.0 - 2026-08-15
|
|
27
|
+
|
|
28
|
+
- **Breaking:** make observables invalidation-only by default. An ordinary
|
|
29
|
+
`observable :status` continues to detect changes and refresh reactive
|
|
30
|
+
components, but persists `{}` and sends no scalar value over Action Cable.
|
|
31
|
+
Declare `observable :status, broadcast: :value` to deliberately store and
|
|
32
|
+
share the projection with every authorized actor subscriber. Applications
|
|
33
|
+
upgrading from 0.12.x must add that opt-in to observables rendered as scalar
|
|
34
|
+
targets.
|
|
35
|
+
- Add `SolidObjects::Web`, a mountable Rack dashboard for the actor runtime.
|
|
36
|
+
It covers instances and their committed state, the ready and claimed
|
|
37
|
+
mailbox, reminders, effects, broadcasts, dead letters, and processes, with
|
|
38
|
+
actor-type and actor-id filtering, status filters, paging, and a polled
|
|
39
|
+
`GET /stats` endpoint. Mount it with
|
|
40
|
+
`mount SolidObjects::Web => "/solid_objects/dashboard"` after
|
|
41
|
+
`require "solid_objects/web"`; requiring the gem does not load it, so a
|
|
42
|
+
worker process carries no web stack.
|
|
43
|
+
- Authorize every dashboard route through `authorize_administration`. Each
|
|
44
|
+
route declares its own `action` and `resource`, and a route declared without
|
|
45
|
+
a policy raises at load time. The policy receives a context that answers
|
|
46
|
+
`request`, `session`, and `env`.
|
|
47
|
+
- Add two dashboard actions: an idempotent dead letter retry through
|
|
48
|
+
`SolidObjects.dead_letters.retry`, and instance pause/resume, which sets and
|
|
49
|
+
clears `paused_at` so the activation manager stops claiming that identity. A
|
|
50
|
+
retry the mailbox refuses, such as an actor class that no longer exists,
|
|
51
|
+
renders the reason with a 422 rather than failing the request.
|
|
52
|
+
- Draw instances per actor type, mailbox depth, and outbox and reminder status
|
|
53
|
+
with Chart.js, loaded from a CDN with a subresource integrity hash. The CDN
|
|
54
|
+
host is the only external origin the content security policy names. Point
|
|
55
|
+
`SolidObjects::Web.chart_library_url` at a vendored copy for a deployment
|
|
56
|
+
with no outbound network access, or set it to nil to render without charts.
|
|
57
|
+
- Add `SolidObjects::Web.register` for extension tabs, routes, and view
|
|
58
|
+
directories, and `SolidObjects::Web.use` for Rack middleware in front of the
|
|
59
|
+
dashboard.
|
|
60
|
+
- Add `rack` as an explicit dependency at `>= 3.1`, and package the `web/`
|
|
61
|
+
directory in the gem.
|
|
62
|
+
|
|
3
63
|
## 0.12.1 - 2026-08-13
|
|
4
64
|
|
|
5
65
|
- Add invalidation-only observables with `broadcast: :invalidation`. They still
|
data/README.md
CHANGED
|
@@ -86,6 +86,7 @@ tested, but the project does not yet claim production readiness. See
|
|
|
86
86
|
- [State migrations](#state-migrations)
|
|
87
87
|
- [Configuration](#configuration)
|
|
88
88
|
- [Workers and operations](#workers-and-operations)
|
|
89
|
+
- [Dashboard](#dashboard)
|
|
89
90
|
- [Database support](#database-support)
|
|
90
91
|
- [Guarantees](#guarantees)
|
|
91
92
|
- [When to use it](#when-to-use-it)
|
|
@@ -178,7 +179,7 @@ class ChatRoom < SolidObjects::Actor
|
|
|
178
179
|
attribute :recent_messages, default: -> { [] }
|
|
179
180
|
attribute :status, default: "open"
|
|
180
181
|
|
|
181
|
-
observable :message_count do
|
|
182
|
+
observable :message_count, broadcast: :value do
|
|
182
183
|
recent_messages.length
|
|
183
184
|
end
|
|
184
185
|
|
|
@@ -205,20 +206,22 @@ dependencies changes:
|
|
|
205
206
|
<% end %>
|
|
206
207
|
```
|
|
207
208
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
209
|
+
Observables are invalidation-only by default. Their values remain available to
|
|
210
|
+
authorized component rendering, while durable rows and Action Cable frames
|
|
211
|
+
carry only change metadata. Explicitly opt a scalar observable into sharing its
|
|
212
|
+
value with every authorized actor subscriber:
|
|
211
213
|
|
|
212
214
|
```ruby
|
|
213
|
-
observable :
|
|
214
|
-
|
|
215
|
+
observable :message_count, broadcast: :value do
|
|
216
|
+
recent_messages.length
|
|
215
217
|
end
|
|
216
218
|
```
|
|
217
219
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
which computes a fresh projection for
|
|
220
|
+
Only `broadcast: :value` observables can render as scalar `<span>` targets.
|
|
221
|
+
Their changed values are stored in `solid_objects_broadcasts` and can reach
|
|
222
|
+
every subscriber that passes `authorize_subscription` for the actor. Put
|
|
223
|
+
per-viewer state in `broadcast_payload`, which computes a fresh projection for
|
|
224
|
+
each connection.
|
|
222
225
|
|
|
223
226
|
Component names can repeat when each instance has a stable key. Signed
|
|
224
227
|
JSON-compatible locals let one conventional partial render the matching
|
|
@@ -530,7 +533,7 @@ class ShoppingCart < SolidObjects::Actor
|
|
|
530
533
|
end
|
|
531
534
|
end
|
|
532
535
|
|
|
533
|
-
observable :items_count do
|
|
536
|
+
observable :items_count, broadcast: :value do
|
|
534
537
|
items.sum { |item| item.fetch("quantity") }
|
|
535
538
|
end
|
|
536
539
|
end
|
|
@@ -1011,6 +1014,7 @@ Important defaults:
|
|
|
1011
1014
|
| Setting | Default |
|
|
1012
1015
|
| --- | ---: |
|
|
1013
1016
|
| `polling_interval` | 0.1 seconds |
|
|
1017
|
+
| `idle_polling_interval` | 1 second |
|
|
1014
1018
|
| `sync_polling_interval` | 0.05 seconds |
|
|
1015
1019
|
| `lease_duration` | 30 seconds |
|
|
1016
1020
|
| `lease_renewal_interval` | 10 seconds |
|
|
@@ -1035,6 +1039,14 @@ Payload, state, and result limits; retry delay; table prefix; logging; wake-up;
|
|
|
1035
1039
|
broadcast; database; and authorization adapters are also configurable. Invalid
|
|
1036
1040
|
lease intervals, component counts, and size limits fail fast at boot.
|
|
1037
1041
|
|
|
1042
|
+
`polling_interval` is the fast interval after work or a wake-up. Consecutive
|
|
1043
|
+
empty passes double it up to `idle_polling_interval`. Actor workers never wait
|
|
1044
|
+
longer than `lease_renewal_interval`. Set the fast and idle values equal for a
|
|
1045
|
+
fixed cadence. The default wake-up reaches only the current Ruby process;
|
|
1046
|
+
configure PostgreSQL notifications or optional Redis Pub/Sub when separate
|
|
1047
|
+
processes need low-latency delivery. The runtime warns once when it sees that
|
|
1048
|
+
topology without an adapter.
|
|
1049
|
+
|
|
1038
1050
|
## Workers and operations
|
|
1039
1051
|
|
|
1040
1052
|
`solid_objects start` runs actor, effect, reminder, and broadcast roles under
|
|
@@ -1082,6 +1094,50 @@ an initializer.
|
|
|
1082
1094
|
See the [operations guide](docs/operations.md) for monitoring, reconciliation,
|
|
1083
1095
|
shutdown, retention, and backup guidance.
|
|
1084
1096
|
|
|
1097
|
+
## Dashboard
|
|
1098
|
+
|
|
1099
|
+
`SolidObjects::Web` is a Rack application that shows instances and their state,
|
|
1100
|
+
the mailbox, reminders, effects, broadcasts, dead letters, and the registered
|
|
1101
|
+
processes. Mount it inside the application routes, so the Rails session
|
|
1102
|
+
middleware runs first:
|
|
1103
|
+
|
|
1104
|
+
```ruby
|
|
1105
|
+
# config/routes.rb
|
|
1106
|
+
require "solid_objects/web"
|
|
1107
|
+
|
|
1108
|
+
Rails.application.routes.draw do
|
|
1109
|
+
mount SolidObjects::Web => "/solid_objects/dashboard"
|
|
1110
|
+
end
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
It is not loaded by `require "solid_objects"`: a worker process must not carry
|
|
1114
|
+
a web stack. The dashboard and the engine are separate mounts, so an
|
|
1115
|
+
application that uses reactive ERB mounts both on different paths.
|
|
1116
|
+
|
|
1117
|
+
Every page asks `authorize_administration` before its handler runs, and that
|
|
1118
|
+
policy denies by default, so a mount alone exposes nothing. The block receives
|
|
1119
|
+
the route's own `action:` and `resource:`, and an `authorization_context:` that
|
|
1120
|
+
answers `request`, `session`, and `env`.
|
|
1121
|
+
|
|
1122
|
+
The dashboard changes only two things. Retrying a dead letter goes through
|
|
1123
|
+
`SolidObjects.dead_letters.retry`, which is idempotent. Pausing an instance
|
|
1124
|
+
sets `paused_at` so the activation manager stops claiming that identity; a pass
|
|
1125
|
+
already in flight finishes its turn, and a synchronous caller waiting on a
|
|
1126
|
+
paused instance times out rather than receiving a result.
|
|
1127
|
+
|
|
1128
|
+
The dashboard draws instances per actor type, mailbox depth, and outbox status
|
|
1129
|
+
with Chart.js, loaded from a CDN with a subresource integrity hash. A
|
|
1130
|
+
deployment with no outbound network access can vendor the file, or turn the
|
|
1131
|
+
charts off:
|
|
1132
|
+
|
|
1133
|
+
```ruby
|
|
1134
|
+
SolidObjects::Web.chart_library_url = "/javascripts/chart.umd.min.js"
|
|
1135
|
+
SolidObjects::Web.chart_library_integrity = nil
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
Read the [dashboard guide](docs/dashboard.md) for the full policy table,
|
|
1139
|
+
extension registration, and query cost.
|
|
1140
|
+
|
|
1085
1141
|
## Database support
|
|
1086
1142
|
|
|
1087
1143
|
Solid Objects supports:
|
|
@@ -1252,8 +1308,8 @@ Partially implemented:
|
|
|
1252
1308
|
|
|
1253
1309
|
- the supervisor starts and drains roles but does not replace a crashed role or
|
|
1254
1310
|
run periodic maintenance automatically;
|
|
1255
|
-
-
|
|
1256
|
-
|
|
1311
|
+
- PostgreSQL notifications and optional Redis acceleration are implemented,
|
|
1312
|
+
but adapter selection remains explicit and polling is the durable fallback;
|
|
1257
1313
|
- live observable and component replacement work, while Turbo append actions
|
|
1258
1314
|
remain future work;
|
|
1259
1315
|
- local admission limits exist, but distributed rate limits and global
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "rbconfig"
|
|
5
|
+
require_relative "support"
|
|
6
|
+
|
|
7
|
+
class CountingWakeUp < SolidObjects::WakeUp
|
|
8
|
+
def initialize
|
|
9
|
+
super
|
|
10
|
+
@count_mutex = Mutex.new
|
|
11
|
+
@poll_count = 0
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def wait(timeout:, generation: nil)
|
|
15
|
+
@count_mutex.synchronize { @poll_count += 1 }
|
|
16
|
+
super
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def reset_count
|
|
20
|
+
@count_mutex.synchronize { @poll_count = 0 }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def poll_count
|
|
24
|
+
@count_mutex.synchronize { @poll_count }
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def measure(interval:, warmup:, duration:, wake_up:)
|
|
29
|
+
SolidObjects.configuration.polling_interval = interval
|
|
30
|
+
SolidObjects.configuration.idle_polling_interval = 1.0
|
|
31
|
+
components = [
|
|
32
|
+
SolidObjects::Worker.new,
|
|
33
|
+
SolidObjects::EffectExecutor.new,
|
|
34
|
+
SolidObjects::ReminderScheduler.new,
|
|
35
|
+
SolidObjects::BroadcastExecutor.new
|
|
36
|
+
]
|
|
37
|
+
threads = components.map { |component| Thread.new { component.run } }
|
|
38
|
+
|
|
39
|
+
sleep warmup
|
|
40
|
+
wake_up.reset_count
|
|
41
|
+
cpu_started_at = Process.times
|
|
42
|
+
wall_started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
43
|
+
sleep duration
|
|
44
|
+
wall_elapsed = Process.clock_gettime(Process::CLOCK_MONOTONIC) - wall_started_at
|
|
45
|
+
cpu_finished_at = Process.times
|
|
46
|
+
cpu_elapsed = cpu_finished_at.utime + cpu_finished_at.stime -
|
|
47
|
+
cpu_started_at.utime - cpu_started_at.stime
|
|
48
|
+
poll_count = wake_up.poll_count
|
|
49
|
+
|
|
50
|
+
{
|
|
51
|
+
polling_interval: interval,
|
|
52
|
+
idle_polling_interval: SolidObjects.configuration.idle_polling_interval,
|
|
53
|
+
current_intervals: components.map(&:current_polling_interval),
|
|
54
|
+
polls: poll_count,
|
|
55
|
+
polls_per_second: (poll_count / wall_elapsed).round(3),
|
|
56
|
+
idle_cpu_percent: ((cpu_elapsed / wall_elapsed) * 100).round(3)
|
|
57
|
+
}
|
|
58
|
+
ensure
|
|
59
|
+
components&.each(&:request_shutdown)
|
|
60
|
+
threads&.each { |thread| thread.join(2) }
|
|
61
|
+
components&.each(&:stop)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
intervals = ENV.fetch("INTERVALS", "0.02,0.1,0.5").split(",").map do |value|
|
|
65
|
+
Float(value).tap { |interval| raise ArgumentError, "intervals must be positive" unless interval.positive? }
|
|
66
|
+
end
|
|
67
|
+
warmup = Float(ENV.fetch("WARMUP", "3"))
|
|
68
|
+
duration = Float(ENV.fetch("DURATION", "10"))
|
|
69
|
+
raise ArgumentError, "warmup must be positive" unless warmup.positive?
|
|
70
|
+
raise ArgumentError, "duration must be positive" unless duration.positive?
|
|
71
|
+
|
|
72
|
+
SolidObjectsBenchmark.setup
|
|
73
|
+
wake_up = CountingWakeUp.new
|
|
74
|
+
SolidObjects.configuration.wake_up_adapter = wake_up
|
|
75
|
+
database_version = ActiveRecord::Base.connection.select_value("SELECT sqlite_version()")
|
|
76
|
+
results = intervals.map { |interval| measure(interval:, warmup:, duration:, wake_up:) }
|
|
77
|
+
puts JSON.pretty_generate(
|
|
78
|
+
measured_at: Time.now.utc.iso8601,
|
|
79
|
+
package_version: SolidObjects::VERSION,
|
|
80
|
+
runtime: {
|
|
81
|
+
ruby: RUBY_DESCRIPTION,
|
|
82
|
+
platform: RUBY_PLATFORM,
|
|
83
|
+
cpu: RbConfig::CONFIG.fetch("host_cpu")
|
|
84
|
+
},
|
|
85
|
+
database: {
|
|
86
|
+
adapter: "sqlite",
|
|
87
|
+
version: database_version,
|
|
88
|
+
path: SolidObjectsBenchmark::DATABASE_PATH
|
|
89
|
+
},
|
|
90
|
+
methodology: {
|
|
91
|
+
roles: %w[actors effects reminders broadcasts],
|
|
92
|
+
warmup_seconds: warmup,
|
|
93
|
+
duration_seconds: duration,
|
|
94
|
+
cpu_percent: "process user plus system CPU time divided by wall time"
|
|
95
|
+
},
|
|
96
|
+
results:
|
|
97
|
+
)
|
|
98
|
+
SolidObjectsBenchmark.teardown
|
data/benchmark/support.rb
CHANGED
|
@@ -402,8 +402,10 @@ module SolidObjectsBenchmark
|
|
|
402
402
|
def migrate
|
|
403
403
|
require_relative "../db/migrate/20260805000000_create_solid_objects_tables"
|
|
404
404
|
require_relative "../db/migrate/20260806000000_add_state_revision_to_solid_objects_instances"
|
|
405
|
+
require_relative "../db/migrate/20260813000000_rename_message_dispatch_columns"
|
|
405
406
|
CreateSolidObjectsTables.new.migrate(:up)
|
|
406
407
|
AddStateRevisionToSolidObjectsInstances.new.migrate(:up)
|
|
408
|
+
RenameMessageDispatchColumns.new.migrate(:up)
|
|
407
409
|
end
|
|
408
410
|
|
|
409
411
|
# @rbs () -> void
|
|
@@ -9,7 +9,11 @@ Action Cable broadcasts are online-only. A transaction can roll back, a broadcas
|
|
|
9
9
|
|
|
10
10
|
## Decision
|
|
11
11
|
|
|
12
|
-
The executor evaluates declared observables before and after a successful
|
|
12
|
+
The executor evaluates declared observables before and after a successful
|
|
13
|
+
message. Changes create invalidation-only broadcast outbox records inside the
|
|
14
|
+
message commit by default. An explicit `broadcast: :value` declaration stores
|
|
15
|
+
the projection and lets a broadcast worker deliver scalar Turbo Stream
|
|
16
|
+
replacements after commit.
|
|
13
17
|
|
|
14
18
|
One `solid_object` block creates one signed Action Cable subscription and
|
|
15
19
|
contains stable targets for multiple observables and components. Component
|
|
@@ -11,6 +11,10 @@ Polling adds latency and database queries. PostgreSQL notifications are transact
|
|
|
11
11
|
|
|
12
12
|
Database rows remain the only durable source of work and results. Wake-up
|
|
13
13
|
adapters only prompt workers and synchronous waiters to re-query those rows.
|
|
14
|
+
Actor, effect, reminder, and broadcast roles double consecutive empty waits
|
|
15
|
+
from `polling_interval` to `idle_polling_interval`. Work and notifications reset
|
|
16
|
+
the wait immediately. Actor workers clamp the ceiling to
|
|
17
|
+
`lease_renewal_interval`.
|
|
14
18
|
|
|
15
19
|
The interface supports:
|
|
16
20
|
|
|
@@ -40,4 +44,6 @@ Timeout does not cancel durable work.
|
|
|
40
44
|
- A reconnecting PostgreSQL listener must commit `LISTEN`, inspect current state, and then wait.
|
|
41
45
|
- Redis loss only increases latency and never loses durable work.
|
|
42
46
|
- Every adapter retains periodic polling to close startup, reconnect, and missed-message races.
|
|
47
|
+
- A process that returns `false` from a timed wait participates in backoff; an older custom adapter that returns `nil` keeps the fast cadence.
|
|
48
|
+
- A multi-process deployment without an adapter trades idle database load for up to the current idle polling interval of notification latency and logs that topology once.
|
|
43
49
|
- Notification payloads never contain actor arguments or results.
|
data/docs/architecture.md
CHANGED
|
@@ -169,8 +169,9 @@ turn. `SolidObjects.mutable_copy` creates an independent mutable JSON value.
|
|
|
169
169
|
`message` and `query` both execute as durable mailbox turns. A query may not
|
|
170
170
|
mutate state. The executor detects query mutation and fails the message. An
|
|
171
171
|
observable is a named projection of state used by server rendering and realtime
|
|
172
|
-
updates. Its durable broadcast row stores
|
|
173
|
-
`broadcast: :
|
|
172
|
+
updates. Its durable broadcast row stores only an empty invalidation marker by
|
|
173
|
+
default. `broadcast: :value` explicitly opts into storing and sharing the
|
|
174
|
+
projected value.
|
|
174
175
|
|
|
175
176
|
Lifecycle hooks are deterministic local hooks:
|
|
176
177
|
|
|
@@ -364,8 +365,8 @@ it drains earlier messages and the target through the same activation and
|
|
|
364
365
|
executor used by workers. If another process owns the actor, the caller waits
|
|
365
366
|
for the row to become completed, rejected, dead-lettered, destroyed, or timed
|
|
366
367
|
out. Every wait re-queries durable rows. The implemented wake-up interface
|
|
367
|
-
provides same-process signaling, bounded polling,
|
|
368
|
-
PostgreSQL `LISTEN/NOTIFY
|
|
368
|
+
provides generation-aware same-process signaling, adaptive bounded polling,
|
|
369
|
+
PostgreSQL `LISTEN/NOTIFY`, and optional Redis Pub/Sub.
|
|
369
370
|
|
|
370
371
|
The normal path does not wait for a worker polling interval because the caller
|
|
371
372
|
assists execution immediately. End-to-end latency still includes earlier
|
data/docs/authorization.md
CHANGED
|
@@ -13,7 +13,7 @@ intentionally inert until the host application defines its trust boundary.
|
|
|
13
13
|
| `authorize_query` | Attribute reads, declared queries, committed snapshots, scalar observable reads, initial component rendering, and every component refresh dependency | Explicit call context, the context passed to `solid_object`, or the request context resolved for a component refresh | Actor state or personalized projections can leak across users or tenants |
|
|
14
14
|
| `authorize_destroy` | `reference.destroy` | Value passed as `authorization_context:` | Complete actor state, mailbox, reminders, and pending outboxes can be deleted |
|
|
15
15
|
| `authorize_subscription` | Action Cable subscription to one actor stream | The `ActionCable::Connection` object | Clients can receive future observable updates for other actors |
|
|
16
|
-
| `authorize_administration` | Engine administration controllers, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
|
|
16
|
+
| `authorize_administration` | Engine administration controllers, every `SolidObjects::Web` page, process inspection/cleanup/pruning, message pruning, and dead-letter inspection/retry | Rails controller, a `SolidObjects::Web` request that answers `request`/`session`/`env`, or `{ source: "cli" }` | Operational metadata, arguments, errors, deletion, and retries become exposed or mutable |
|
|
17
17
|
|
|
18
18
|
Waiting again through `MessageReference#wait` reauthorizes the stored
|
|
19
19
|
invocation as a message or query. Internal reminder, effect-callback, and
|
|
@@ -53,9 +53,9 @@ Component partials receive the resolved value as the
|
|
|
53
53
|
`authorization_context` local, allowing two authorized viewers to render
|
|
54
54
|
different projections. Responses use `Cache-Control: private, no-store`.
|
|
55
55
|
Durable outbox rows and shared Cable messages never contain component HTML.
|
|
56
|
-
The stream token also signs
|
|
57
|
-
specific scope.
|
|
58
|
-
their state value to the browser.
|
|
56
|
+
The stream token also signs explicitly value-broadcast scalar observable
|
|
57
|
+
targets rendered into that specific scope. Default component dependencies send
|
|
58
|
+
invalidation metadata but not their state value to the browser.
|
|
59
59
|
|
|
60
60
|
Keyed components sign their `component_key` and declared JSON-compatible
|
|
61
61
|
locals into the component token. Initial rendering and every refresh pass those
|
|
@@ -154,6 +154,13 @@ configuration.authorize_administration = lambda do |authorization_context:, **|
|
|
|
154
154
|
end
|
|
155
155
|
```
|
|
156
156
|
|
|
157
|
+
That policy also denies every `SolidObjects::Web` page, which is the correct
|
|
158
|
+
result for a host whose only administration boundary is shell access. A policy
|
|
159
|
+
that opens the dashboard should separate reading from writing, because
|
|
160
|
+
`action` distinguishes them: `index` and `show` read, while `pause`, `resume`,
|
|
161
|
+
and `retry` change the runtime. The [dashboard guide](dashboard.md) lists the
|
|
162
|
+
action and resource of every page.
|
|
163
|
+
|
|
157
164
|
Run `bin/rails solid_objects:doctor` after configuration. Its neutral policy
|
|
158
165
|
probe is deliberately conservative: a context-aware policy may correctly warn
|
|
159
166
|
because it denies a `nil` context.
|
data/docs/benchmarks.md
CHANGED
|
@@ -5,6 +5,44 @@ They include the runtime's Active Record and database query overhead and will
|
|
|
5
5
|
vary with hardware, schema size, connection pools, durability settings, and
|
|
6
6
|
contention.
|
|
7
7
|
|
|
8
|
+
## Idle SQLite polling
|
|
9
|
+
|
|
10
|
+
Run the four-role idle harness with:
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
bundle exec ruby -Ilib benchmark/idle_polling.rb
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
It warms each interval for three seconds, measures for ten seconds, and reports
|
|
17
|
+
process user plus system CPU time divided by wall time. Measured August 16,
|
|
18
|
+
2026 on an Apple M5 with Ruby 4.0.6 and SQLite 3.53.2. The before run used
|
|
19
|
+
0.13.0; the after run used the prepared 0.13.1 tree.
|
|
20
|
+
|
|
21
|
+
| Fast interval | Before polls/s | Before CPU | After polls/s | After CPU |
|
|
22
|
+
| ---: | ---: | ---: | ---: | ---: |
|
|
23
|
+
| 20 ms | 165.340 | 8.401% | 3.998 | 0.947% |
|
|
24
|
+
| 100 ms | 38.396 | 2.925% | 3.999 | 0.482% |
|
|
25
|
+
| 500 ms | 7.998 | 2.061% | 3.996 | 0.283% |
|
|
26
|
+
|
|
27
|
+
The after run reached the one-second ceiling for the actor, effect, reminder,
|
|
28
|
+
and broadcast roles. These are developer-laptop measurements, not a CPU
|
|
29
|
+
guarantee; timer scheduling, YJIT, the SQLite file, and unrelated host activity
|
|
30
|
+
affect short samples.
|
|
31
|
+
|
|
32
|
+
Five SQLite samples measured durable enqueue through committed completion after
|
|
33
|
+
2.5 seconds of idleness. The polling-only multi-process harness submits just
|
|
34
|
+
after an empty pass, so it measures approximately the full polling wait rather
|
|
35
|
+
than average arrival latency.
|
|
36
|
+
|
|
37
|
+
| Topology | 0.13.0 p50 | Prepared 0.13.1 p50 |
|
|
38
|
+
| --- | ---: | ---: |
|
|
39
|
+
| One process, in-process wake-up | 43.360 ms | 50.339 ms |
|
|
40
|
+
| Two processes, polling only | 117.787 ms | 1,028.006 ms |
|
|
41
|
+
|
|
42
|
+
The local wake-up keeps the one-process path prompt after backoff. The
|
|
43
|
+
polling-only row is the explicit tradeoff: use PostgreSQL notifications or
|
|
44
|
+
optional Redis Pub/Sub when separate processes need low-latency delivery.
|
|
45
|
+
|
|
8
46
|
## Production-shaped adoption measurement
|
|
9
47
|
|
|
10
48
|
An adoption evaluation measured Solid Objects 0.2.0 from a macOS Rails process
|
data/docs/correctness.md
CHANGED
|
@@ -131,9 +131,9 @@ rendering invokes query authorization, Cable separately invokes subscription
|
|
|
131
131
|
authorization, and the cookie-bearing refresh request invokes query
|
|
132
132
|
authorization again for the component name and every dependency.
|
|
133
133
|
|
|
134
|
-
The actor stream token separately signs the
|
|
135
|
-
into its scope. A component dependency
|
|
136
|
-
its name and revision over Cable, not its serialized value.
|
|
134
|
+
The actor stream token separately signs the explicitly value-broadcast scalar
|
|
135
|
+
observable targets rendered into its scope. A default component dependency
|
|
136
|
+
carries only its name and revision over Cable, not its serialized value.
|
|
137
137
|
|
|
138
138
|
Cable compares `(instance_id, state_revision)` pairs, coalesces dependencies
|
|
139
139
|
changed by the same turn, and ignores an older pair after a newer one. A new
|