solid_objects 0.14.2 → 0.14.3
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 +53 -0
- data/README.md +121 -522
- data/benchmark/state_size.rb +5 -0
- data/benchmark/support.rb +63 -0
- data/docs/adr/0006-at-least-once-delivery.md +1 -1
- data/docs/architecture.md +7 -6
- data/docs/authorization.md +4 -4
- data/docs/benchmarks.md +48 -3
- data/docs/correctness.md +2 -2
- data/docs/fit.md +24 -7
- data/docs/local-testing.md +3 -3
- data/docs/migrating-existing-state.md +1 -1
- data/docs/operations.md +32 -4
- data/docs/realtime.md +2 -2
- data/docs/reminders.md +6 -6
- data/docs/research/solid_queue.md +1 -1
- data/docs/roadmap.md +9 -1
- data/lib/solid_objects/configuration.rb +7 -0
- data/lib/solid_objects/executor.rb +37 -16
- data/lib/solid_objects/instrumentation.rb +33 -0
- data/lib/solid_objects/serialization.rb +18 -5
- data/lib/solid_objects/version.rb +1 -1
- data/sig/generated/lib/solid_objects/configuration.rbs +7 -3
- data/sig/generated/lib/solid_objects/executor.rbs +7 -4
- data/sig/generated/lib/solid_objects/instrumentation.rbs +11 -0
- data/sig/generated/lib/solid_objects/serialization.rbs +16 -0
- metadata +3 -2
data/benchmark/support.rb
CHANGED
|
@@ -7,6 +7,25 @@ require "solid_objects"
|
|
|
7
7
|
|
|
8
8
|
module SolidObjectsBenchmark
|
|
9
9
|
DATABASE_PATH = File.expand_path("../tmp/solid_objects_benchmark.sqlite3", __dir__)
|
|
10
|
+
STATE_SIZES = [ 0, 16 * 1_024, 128 * 1_024, 1_024 * 1_024 ].freeze
|
|
11
|
+
STATE_ENTRY_BYTES = 26
|
|
12
|
+
|
|
13
|
+
class StateSizeActor < SolidObjects::Actor
|
|
14
|
+
actor_type "benchmark-state-size"
|
|
15
|
+
|
|
16
|
+
attribute :count, default: 0
|
|
17
|
+
attribute :filler, default: -> { {} }
|
|
18
|
+
|
|
19
|
+
def fill(size:)
|
|
20
|
+
entries = size / STATE_ENTRY_BYTES
|
|
21
|
+
self.filler = Array.new(entries) { |index| [ "key-#{index}", "value-#{index}" ] }.to_h
|
|
22
|
+
filler.length
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def increment
|
|
26
|
+
self.count = count + 1
|
|
27
|
+
end
|
|
28
|
+
end
|
|
10
29
|
|
|
11
30
|
class CounterActor < SolidObjects::Actor
|
|
12
31
|
actor_type "benchmark-counter"
|
|
@@ -289,6 +308,12 @@ module SolidObjectsBenchmark
|
|
|
289
308
|
)
|
|
290
309
|
end
|
|
291
310
|
|
|
311
|
+
# @rbs () -> void
|
|
312
|
+
def state_size
|
|
313
|
+
warm_up_state_size
|
|
314
|
+
STATE_SIZES.each { |size| measure_state_size(size) }
|
|
315
|
+
end
|
|
316
|
+
|
|
292
317
|
# @rbs () -> void
|
|
293
318
|
def query_count
|
|
294
319
|
turn = message_turn_query_count
|
|
@@ -433,6 +458,44 @@ module SolidObjectsBenchmark
|
|
|
433
458
|
count.times { |index| references[index % actor_count].async.increment }
|
|
434
459
|
end
|
|
435
460
|
|
|
461
|
+
# @rbs () -> void
|
|
462
|
+
def warm_up_state_size
|
|
463
|
+
reference = StateSizeActor.ref("state-size-warm-up")
|
|
464
|
+
reference.fill(size: 0)
|
|
465
|
+
count.times { reference.async.increment }
|
|
466
|
+
worker = SolidObjects::Worker.new
|
|
467
|
+
drain(worker)
|
|
468
|
+
ensure
|
|
469
|
+
worker&.stop
|
|
470
|
+
end
|
|
471
|
+
|
|
472
|
+
# @rbs (Integer) -> void
|
|
473
|
+
def measure_state_size(size)
|
|
474
|
+
silence_large_state_warning
|
|
475
|
+
actor_id = "state-size-#{size}"
|
|
476
|
+
reference = StateSizeActor.ref(actor_id)
|
|
477
|
+
reference.fill(size:)
|
|
478
|
+
state_bytes = committed_state_bytes(actor_id)
|
|
479
|
+
count.times { reference.async.increment }
|
|
480
|
+
worker = SolidObjects::Worker.new
|
|
481
|
+
measure("process #{count} messages with #{state_bytes} bytes of state") { drain(worker) }
|
|
482
|
+
ensure
|
|
483
|
+
worker&.stop
|
|
484
|
+
end
|
|
485
|
+
|
|
486
|
+
# @rbs () -> void
|
|
487
|
+
def silence_large_state_warning
|
|
488
|
+
return unless SolidObjects.configuration.respond_to?(:warn_state_bytes=)
|
|
489
|
+
|
|
490
|
+
SolidObjects.configuration.warn_state_bytes = SolidObjects.configuration.max_state_bytes
|
|
491
|
+
end
|
|
492
|
+
|
|
493
|
+
# @rbs (String) -> Integer
|
|
494
|
+
def committed_state_bytes(actor_id)
|
|
495
|
+
instance = SolidObjects::Instance.find_by!(actor_type: "benchmark-state-size", actor_id:)
|
|
496
|
+
JSON.generate(instance.state).bytesize
|
|
497
|
+
end
|
|
498
|
+
|
|
436
499
|
# @rbs (SolidObjects::Worker) -> Integer
|
|
437
500
|
def drain(worker)
|
|
438
501
|
processed = 0
|
|
@@ -13,7 +13,7 @@ Mailbox delivery is at least once. State mutation, message completion, result pe
|
|
|
13
13
|
|
|
14
14
|
Actor code receives message ID, request ID, attempt, enqueue time, and idempotency key. Documentation requires idempotency for effects outside the actor commit.
|
|
15
15
|
|
|
16
|
-
Message handlers themselves can run more than once. Sequential execution means one valid activation runs one turn at a time
|
|
16
|
+
Message handlers themselves can run more than once. Sequential execution means that one valid activation runs one turn at a time. It does not guarantee that a handler runs only once. Handlers for transitions such as `launch`, `checkout`, or `submit` must inspect durable actor state and return safely when the transition already happened. External calls belong in an outbox and still require downstream idempotency.
|
|
17
17
|
|
|
18
18
|
## Consequences
|
|
19
19
|
|
data/docs/architecture.md
CHANGED
|
@@ -7,8 +7,9 @@ It is a database-backed virtual actor runtime for MySQL, PostgreSQL, and
|
|
|
7
7
|
SQLite. A virtual actor is a logical object addressed by type and ID whose
|
|
8
8
|
in-memory activation is created on demand, processes one mailbox turn at a
|
|
9
9
|
time, persists JSON state, and can disappear when idle without losing its
|
|
10
|
-
identity or state. This ports the programming model
|
|
11
|
-
serverless runtime, global placement, storage API, or platform
|
|
10
|
+
identity or state. This gem ports the programming model. It does not port
|
|
11
|
+
Cloudflare's serverless runtime, global placement, storage API, or platform
|
|
12
|
+
guarantees.
|
|
12
13
|
|
|
13
14
|
The runtime contract is:
|
|
14
15
|
|
|
@@ -128,8 +129,8 @@ instance first prevents a claimed reminder from recreating a destroyed actor.
|
|
|
128
129
|
The broadcast worker claims committed observable-change rows, renders
|
|
129
130
|
idempotent scalar Turbo replacements with component invalidation metadata,
|
|
130
131
|
broadcasts to a signed actor stream, and records delivery. It never renders
|
|
131
|
-
personalized component HTML.
|
|
132
|
-
|
|
132
|
+
personalized component HTML. On reconnect, and on a request-time component
|
|
133
|
+
render, Solid Objects reads the current actor state.
|
|
133
134
|
|
|
134
135
|
### Process registry
|
|
135
136
|
|
|
@@ -482,7 +483,7 @@ A reminder record contains actor identity, a reminder name, target message, JSON
|
|
|
482
483
|
schedule(at: 30.minutes.from_now).expire
|
|
483
484
|
```
|
|
484
485
|
|
|
485
|
-
Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another,
|
|
486
|
+
Reminders are keyed by `(actor, reminder name)`, enforced by a unique index on `(instance_id, name)`. `schedule` is therefore an upsert: scheduling a name that is already armed moves that alarm instead of adding another, so re-arming is safe from a handler that may run more than once. An actor needing several pending items should arm one alarm for the earliest and drain everything due when it fires, rather than one alarm per item; the [reminders guide](reminders.md#one-alarm-for-a-whole-queue) shows that pattern. A move that changes `next_run_at` emits `solid_objects.reminder.replaced`, because the replacement is otherwise indistinguishable from a first schedule.
|
|
486
487
|
|
|
487
488
|
When due, the scheduler locks the source instance and creates a normal mailbox
|
|
488
489
|
row with an idempotency key derived from reminder ID and occurrence. The
|
|
@@ -698,7 +699,7 @@ Backoff and a retry limit prevent tight loops. The poison message blocks its act
|
|
|
698
699
|
|
|
699
700
|
### Handler redelivery
|
|
700
701
|
|
|
701
|
-
|
|
702
|
+
Ordered processing does not prevent repeated execution. A handler can run, lose its lease before commit, and run again. Logical transitions must guard on durable state:
|
|
702
703
|
|
|
703
704
|
```ruby
|
|
704
705
|
def launch
|
data/docs/authorization.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# Authorization policies
|
|
2
2
|
|
|
3
|
-
Solid Objects treats actor identities as identifiers
|
|
4
|
-
|
|
5
|
-
All five policies deny by default, so a generated installation
|
|
6
|
-
|
|
3
|
+
Solid Objects treats actor identities as identifiers. They are not
|
|
4
|
+
capabilities. An actor ID, a message ID, or a signed stream token grants no
|
|
5
|
+
permission. All five policies deny by default, so a generated installation
|
|
6
|
+
answers nothing until the host application defines its trust boundary.
|
|
7
7
|
|
|
8
8
|
## Policy reference
|
|
9
9
|
|
data/docs/benchmarks.md
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# Performance and storage costs
|
|
2
2
|
|
|
3
|
-
These numbers are development measurements
|
|
4
|
-
They include the runtime's Active Record and database query overhead and
|
|
5
|
-
|
|
3
|
+
These numbers are development measurements. They do not guarantee capacity.
|
|
4
|
+
They include the runtime's Active Record and database query overhead, and they
|
|
5
|
+
change with hardware, schema size, connection pools, durability settings, and
|
|
6
6
|
contention.
|
|
7
7
|
|
|
8
8
|
## Idle SQLite polling
|
|
@@ -109,6 +109,51 @@ result is why Solid Objects does not publish one latency promise. Network
|
|
|
109
109
|
topology, adapter behavior, host schema, logging, callbacks, and contention all
|
|
110
110
|
matter.
|
|
111
111
|
|
|
112
|
+
## State size and committed throughput
|
|
113
|
+
|
|
114
|
+
A turn commits the whole state image. It copies the state, encodes it, and
|
|
115
|
+
writes the row, so every message pays for the size of the state its actor
|
|
116
|
+
keeps. Run the scenario with:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
COUNT=300 bundle exec ruby -Ilib benchmark/state_size.rb
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
Measured 2026-08-29 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
|
|
123
|
+
and SQLite 3.53.2. One hot actor received 300 messages at each state size. Each
|
|
124
|
+
figure is the median of five runs, and the two trees ran one after the other in
|
|
125
|
+
each round. The state holds many small entries, because a copy visits every
|
|
126
|
+
node, and one long string of the same length costs much less. The harness sets
|
|
127
|
+
`warn_state_bytes` to the hard limit, so neither tree pays for an event that
|
|
128
|
+
only one of them can emit.
|
|
129
|
+
|
|
130
|
+
| Committed state | Before | After | Change |
|
|
131
|
+
| ---: | ---: | ---: | ---: |
|
|
132
|
+
| 23 bytes | 1,268.0 messages/s | 1,252.5 messages/s | -1.2% |
|
|
133
|
+
| 13,662 bytes | 624.1 messages/s | 654.1 messages/s | +4.8% |
|
|
134
|
+
| 118,786 bytes | 169.3 messages/s | 174.6 messages/s | +3.1% |
|
|
135
|
+
| 1,026,356 bytes | 21.6 messages/s | 23.8 messages/s | +10.2% |
|
|
136
|
+
|
|
137
|
+
The "before" tree copied the whole state three times per committed turn and
|
|
138
|
+
encoded a string that it discarded whenever the caller gave no byte limit. The
|
|
139
|
+
"after" tree copies it twice and encodes only where a limit applies. It also
|
|
140
|
+
checks the encoding of every string it normalizes, which the discarded encoding
|
|
141
|
+
used to do, so part of the saving pays for that check. The gain grows with the
|
|
142
|
+
state, because the database write dominates a small turn. The empty-state row
|
|
143
|
+
sits inside run-to-run variance.
|
|
144
|
+
|
|
145
|
+
The curve matters more than the change. Throughput falls about 28 times between
|
|
146
|
+
13 KB and 1 MB of state, and about 53 times between an empty state and 1 MB.
|
|
147
|
+
The `max_state_bytes` default of 5 MB is therefore a limit rather than an
|
|
148
|
+
operating point. `warn_state_bytes` defaults to 64 KB, and each commit above it
|
|
149
|
+
reports `solid_objects.state.large`. The Node package carries the same setting
|
|
150
|
+
as `warnStateBytes` and defaults it to 128 KB, because its measured curve falls
|
|
151
|
+
later: it keeps 98% of its empty-state throughput at 16 KB, where this gem
|
|
152
|
+
keeps 52% at 13 KB.
|
|
153
|
+
|
|
154
|
+
These are developer-laptop numbers on one adapter. They show shape and ratio,
|
|
155
|
+
not a capacity guarantee.
|
|
156
|
+
|
|
112
157
|
## Reactive delivery paths
|
|
113
158
|
|
|
114
159
|
Measured 2026-08-09 on an Apple M5 with 200 iterations, for one actor mutation
|
data/docs/correctness.md
CHANGED
|
@@ -78,8 +78,8 @@ Destruction is synchronous, forbidden from actor context, authorized by
|
|
|
78
78
|
|
|
79
79
|
## Handler idempotency
|
|
80
80
|
|
|
81
|
-
|
|
82
|
-
guard:
|
|
81
|
+
Ordered execution does not prevent repeated execution. A message such as
|
|
82
|
+
`launch` still needs a durable guard:
|
|
83
83
|
|
|
84
84
|
```ruby
|
|
85
85
|
def launch
|
data/docs/fit.md
CHANGED
|
@@ -24,7 +24,13 @@ Solid Objects is a good candidate when most of these are true:
|
|
|
24
24
|
asynchronous features, a Solid Objects runtime process.
|
|
25
25
|
|
|
26
26
|
Typical fits include checkout state machines, collaborative rooms, device
|
|
27
|
-
twins, durable assessments, approval workflows,
|
|
27
|
+
twins, durable assessments, approval workflows, user-specific scheduling, and
|
|
28
|
+
low-rate quotas that a reminder refills.
|
|
29
|
+
|
|
30
|
+
A workflow fits when one entity owns the mutable state and its mailbox holds
|
|
31
|
+
the step order. A durable execution engine that replays named steps from a step
|
|
32
|
+
log is a different tool: Solid Objects redelivers an ordered message and
|
|
33
|
+
retries it, and does not replay a handler from a step log.
|
|
28
34
|
|
|
29
35
|
## Poor fit and anti-patterns
|
|
30
36
|
|
|
@@ -46,10 +52,21 @@ when any of these dominate:
|
|
|
46
52
|
- State that is clearer as a normal record with database constraints and direct
|
|
47
53
|
service methods.
|
|
48
54
|
|
|
49
|
-
A rate limiter is usually a poor actor: it is hot,
|
|
50
|
-
expires rather than requiring permanent message
|
|
51
|
-
|
|
52
|
-
|
|
55
|
+
A request-path rate limiter is usually a poor actor: it is hot,
|
|
56
|
+
request-critical, and often expires rather than requiring permanent message
|
|
57
|
+
history. A low-rate quota is the case that does fit, such as five password
|
|
58
|
+
resets an hour for one account, where a reminder refills the bucket and each
|
|
59
|
+
check is one durable ordered message. An impressions pipeline is also a poor
|
|
60
|
+
actor, because its value comes from high-throughput append and aggregation. It
|
|
61
|
+
does not need serialized mutable state.
|
|
62
|
+
|
|
63
|
+
[Solid Objects Pro](https://solidobjects.pro/) is the commercial scaling layer
|
|
64
|
+
for the high-QPS cases in this section. Grouped operations coalesce concurrent
|
|
65
|
+
calls into one bulk insert. Ephemeral operations hold a loss-tolerant call in
|
|
66
|
+
process memory and write no journal row, which is the mode for an abuse limiter,
|
|
67
|
+
a presence signal, or a view count. Reactive projections materialize a read
|
|
68
|
+
model from the durable broadcast outbox, so request-path reads stop competing
|
|
69
|
+
with mailbox work.
|
|
53
70
|
|
|
54
71
|
## Cost model
|
|
55
72
|
|
|
@@ -94,5 +111,5 @@ Before adopting an actor, answer:
|
|
|
94
111
|
10. How will existing state be cut over and rolled back?
|
|
95
112
|
|
|
96
113
|
Benchmark the actual host database and deployment topology before committing a
|
|
97
|
-
latency-sensitive surface. Local benchmark results
|
|
98
|
-
|
|
114
|
+
latency-sensitive surface. Local benchmark results show query shape. They do
|
|
115
|
+
not guarantee capacity.
|
data/docs/local-testing.md
CHANGED
|
@@ -24,9 +24,9 @@ SOLID_OBJECTS_DATABASE_URL=postgresql://solid_objects:solid_objects@127.0.0.1:54
|
|
|
24
24
|
bundle exec rake test
|
|
25
25
|
```
|
|
26
26
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
27
|
+
The setup is worth the effort. This local suite caught a PostgreSQL version
|
|
28
|
+
comparison that read a packed integer, where `170010` compared greater than any
|
|
29
|
+
minimum and made the check useless on the adapter that needed it most.
|
|
30
30
|
|
|
31
31
|
## MySQL and Redis in Docker
|
|
32
32
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Moving an existing Redis, cache, or key-value state machine into Solid Objects
|
|
4
4
|
is a data migration and a coordination cutover. Treat it as a staged production
|
|
5
|
-
change
|
|
5
|
+
change. Do not switch the storage in one deploy.
|
|
6
6
|
|
|
7
7
|
## 1. Write down the existing contract
|
|
8
8
|
|
data/docs/operations.md
CHANGED
|
@@ -143,9 +143,9 @@ loader participates in Rails preparation callbacks so a development reload can
|
|
|
143
143
|
replace a registered actor class without loading unrelated application code.
|
|
144
144
|
|
|
145
145
|
An actor registers itself as its class loads, and a web process resolves
|
|
146
|
-
actors by name for Cable subscriptions and component renders.
|
|
147
|
-
every process
|
|
148
|
-
an actor no request in that process has rendered yet.
|
|
146
|
+
actors by name for Cable subscriptions and component renders. Solid Objects
|
|
147
|
+
loads the actors in every process, so a freshly booted web process can serve a
|
|
148
|
+
live card for an actor that no request in that process has rendered yet.
|
|
149
149
|
|
|
150
150
|
Worker and outbox counts can be overridden on the command line:
|
|
151
151
|
|
|
@@ -200,6 +200,7 @@ end
|
|
|
200
200
|
| `max_messages_per_activation_pass` | 50 |
|
|
201
201
|
| `max_activation_duration` | 5 seconds |
|
|
202
202
|
| `max_mailbox_length` | 10,000 |
|
|
203
|
+
| `warn_state_bytes` | 64 KB |
|
|
203
204
|
| `max_attempts` | 5 |
|
|
204
205
|
| `process_heartbeat_interval` | 15 seconds |
|
|
205
206
|
| `process_alive_threshold` | 60 seconds |
|
|
@@ -217,6 +218,15 @@ Payload, state, and result byte limits; retry delay; table prefix; logging;
|
|
|
217
218
|
wake-up; broadcast; database; and authorization adapters are also configurable.
|
|
218
219
|
Invalid lease intervals, component counts, and size limits fail fast at boot.
|
|
219
220
|
|
|
221
|
+
`max_state_bytes` defaults to 5 MB, which is a limit rather than an operating
|
|
222
|
+
point. A turn copies the whole state, encodes it, and writes the row, so
|
|
223
|
+
committed throughput falls long before that limit: measured on SQLite, about 28
|
|
224
|
+
times between 13 KB and 1 MB of state. See `docs/benchmarks.md` for the curve.
|
|
225
|
+
`warn_state_bytes` is the soft threshold, and it must not exceed
|
|
226
|
+
`max_state_bytes`. Each commit above it reports `solid_objects.state.large` and
|
|
227
|
+
nothing else changes, so an application that already keeps a large state keeps
|
|
228
|
+
working while its operator learns the cost.
|
|
229
|
+
|
|
220
230
|
Keep lease duration comfortably above renewal interval and expected database
|
|
221
231
|
pause time. A handler can exceed the pass-duration budget because Ruby code is
|
|
222
232
|
not safely preempted; alert on message duration and isolate untrusted work.
|
|
@@ -278,7 +288,7 @@ Use:
|
|
|
278
288
|
|
|
279
289
|
Spread large repairs with `available_at:`. Report at least bootstrapped,
|
|
280
290
|
reconfigured, revived, suspended, and orphaned counts. A nonzero revived count
|
|
281
|
-
|
|
291
|
+
shows that alarms are lost.
|
|
282
292
|
|
|
283
293
|
Never bulk-update actor state. That bypasses lease ownership and fencing.
|
|
284
294
|
|
|
@@ -308,6 +318,7 @@ Alert on:
|
|
|
308
318
|
- ready and claimed membership counts;
|
|
309
319
|
- mailbox-full rejections;
|
|
310
320
|
- actor turn duration and failures;
|
|
321
|
+
- committed state above `warn_state_bytes`;
|
|
311
322
|
- lost-activation rate;
|
|
312
323
|
- dead-letter creation;
|
|
313
324
|
- actor destruction rate;
|
|
@@ -344,6 +355,23 @@ per queued item keeps only the last, and the earlier wake-up never happens.
|
|
|
344
355
|
Watch this event if your actors schedule from a loop or from a handler that can
|
|
345
356
|
run more than once. Rescheduling to the same time reports nothing.
|
|
346
357
|
|
|
358
|
+
`solid_objects.state.large` reports a committed turn whose state exceeded
|
|
359
|
+
`warn_state_bytes`. The payload carries the actor identity, the `byte_count`
|
|
360
|
+
the commit wrote, and the `threshold_bytes` it passed. It never carries the
|
|
361
|
+
state. The event reports after the commit, so a turn that rolled back reports
|
|
362
|
+
nothing. Every commit above the threshold reports, including a synchronous
|
|
363
|
+
query and a turn that changed nothing, so a hot actor reports once per message.
|
|
364
|
+
Aggregate by actor rather than alert on each event, and watch the reported size
|
|
365
|
+
rather than the event rate: a state that grows without a bound is what this
|
|
366
|
+
event exists to find. `solid-objects-js` emits the same event under the same
|
|
367
|
+
name and payload.
|
|
368
|
+
|
|
369
|
+
`solid_objects.instrumentation.failed` reports a subscriber that raised while
|
|
370
|
+
the runtime reported a committed turn. The payload carries the
|
|
371
|
+
`instrumentation_event` that failed and the `error_class`. The turn itself is
|
|
372
|
+
unaffected, because it already committed. Watch this event to find a broken
|
|
373
|
+
subscriber, which would otherwise be silent.
|
|
374
|
+
|
|
347
375
|
`solid_objects.component.refreshed` covers every authorized component refresh
|
|
348
376
|
request. Its payload carries the actor identity, `component_name`,
|
|
349
377
|
`component_key`, declared `dependencies`, `refresh_method`, the rendered
|
data/docs/realtime.md
CHANGED
|
@@ -421,8 +421,8 @@ configuration.component_authorization_context = ->(controller:) { controller.cur
|
|
|
421
421
|
configuration.payload_authorization_context = ->(connection:) { connection.current_account }
|
|
422
422
|
```
|
|
423
423
|
|
|
424
|
-
The
|
|
425
|
-
|
|
424
|
+
The payload block receives the resolved value as its second argument, and
|
|
425
|
+
`authorize_query` receives it as `authorization_context`. A resolver may also
|
|
426
426
|
accept `payload_name:` when the subject depends on which payload was requested.
|
|
427
427
|
The default returns the connection unchanged, so an application that has not
|
|
428
428
|
configured one is unaffected.
|
data/docs/reminders.md
CHANGED
|
@@ -22,9 +22,9 @@ The uniqueness key is `(actor, reminder name)`. Scheduling a name that is
|
|
|
22
22
|
already armed **moves the existing alarm** rather than adding a second one. The
|
|
23
23
|
database enforces this with a unique index on `(instance_id, name)`.
|
|
24
24
|
|
|
25
|
-
This is the same model as Orleans reminders and Durable Objects alarms
|
|
26
|
-
|
|
27
|
-
|
|
25
|
+
This is the same model as Orleans reminders and Durable Objects alarms. It
|
|
26
|
+
makes a reminder safe to re-arm from a handler that may run more than once.
|
|
27
|
+
Without a key the name is the operation, so this is a data-loss bug:
|
|
28
28
|
|
|
29
29
|
```ruby
|
|
30
30
|
# Wrong. Every entry overwrites the previous entry's alarm.
|
|
@@ -51,9 +51,9 @@ end
|
|
|
51
51
|
```
|
|
52
52
|
|
|
53
53
|
Two entries now leave two reminders. Scheduling the same key again moves that
|
|
54
|
-
item's alarm and leaves the others alone,
|
|
55
|
-
|
|
56
|
-
|
|
54
|
+
item's alarm and leaves the others alone, so a keyed reminder is as safe to
|
|
55
|
+
re-arm as an unkeyed one. The operation still decides which handler runs; the
|
|
56
|
+
key only decides which alarm is which.
|
|
57
57
|
|
|
58
58
|
A key must be non-empty, and the name it becomes must fit the 191-character
|
|
59
59
|
column, which is checked on the composed name rather than the key alone so a
|
|
@@ -472,7 +472,7 @@ The current `cardmagic/classifier` source uses RBS::Inline directly in Ruby file
|
|
|
472
472
|
- [`.github/workflows/ruby.yml`](https://github.com/cardmagic/classifier/blob/48cdfa63f3efdba8149c8f47dd053ceebce5dfc1/.github/workflows/ruby.yml) generates signatures, validates them with RBS, and runs Steep.
|
|
473
473
|
- [`Steepfile`](https://github.com/cardmagic/classifier/blob/48cdfa63f3efdba8149c8f47dd053ceebce5dfc1/Steepfile) enables strict diagnostics for the typed library while explicitly isolating incompatible extension files.
|
|
474
474
|
|
|
475
|
-
Solid Objects will use the same source-adjacent convention. Every owned Ruby source file starts with `# rbs_inline: enabled`, declares its instance variables, and annotates public and private methods.
|
|
475
|
+
Solid Objects will use the same source-adjacent convention. Every owned Ruby source file starts with `# rbs_inline: enabled`, declares its instance variables, and annotates public and private methods. The build checks the generated signatures. Nobody maintains them by hand as a second authority.
|
|
476
476
|
|
|
477
477
|
## Related primary-source findings
|
|
478
478
|
|
data/docs/roadmap.md
CHANGED
|
@@ -134,7 +134,15 @@
|
|
|
134
134
|
loads them in every process, and a rejected subscription reports which
|
|
135
135
|
condition caused it instead of closing the socket silently.
|
|
136
136
|
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
|
|
137
|
-
distributed per-actor rate limits and global admission control do not.
|
|
137
|
+
distributed per-actor rate limits and global admission control do not. The
|
|
138
|
+
state cap is a limit rather than an operating point. `max_state_bytes`
|
|
139
|
+
defaults to 5 MB, and committed throughput measured on SQLite falls about 53
|
|
140
|
+
times between an empty state and 1 MB of state, which `docs/benchmarks.md`
|
|
141
|
+
records. A soft `warn_state_bytes` threshold, 64 KB by default, now
|
|
142
|
+
reports each commit above it as `solid_objects.state.large`. The hard default
|
|
143
|
+
stays at 5 MB, because lowering it would break an application whose actors
|
|
144
|
+
already exceed a lower value; a major release can lower it from the measured
|
|
145
|
+
curve.
|
|
138
146
|
- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
|
|
139
147
|
instances, mailbox, reminders, effects, broadcasts, dead letters, and
|
|
140
148
|
processes, with actor-type and actor-id filtering, status filters, paging, a
|
|
@@ -15,6 +15,7 @@ module SolidObjects
|
|
|
15
15
|
# @rbs @claim_scan_limit: Integer
|
|
16
16
|
# @rbs @max_payload_bytes: Integer
|
|
17
17
|
# @rbs @max_state_bytes: Integer
|
|
18
|
+
# @rbs @warn_state_bytes: Integer
|
|
18
19
|
# @rbs @max_result_bytes: Integer
|
|
19
20
|
# @rbs @max_attempts: Integer
|
|
20
21
|
# @rbs @retry_delay: Proc
|
|
@@ -63,6 +64,7 @@ module SolidObjects
|
|
|
63
64
|
:claim_scan_limit,
|
|
64
65
|
:max_payload_bytes,
|
|
65
66
|
:max_state_bytes,
|
|
67
|
+
:warn_state_bytes,
|
|
66
68
|
:max_result_bytes,
|
|
67
69
|
:max_attempts,
|
|
68
70
|
:retry_delay,
|
|
@@ -116,6 +118,7 @@ module SolidObjects
|
|
|
116
118
|
@claim_scan_limit = 100
|
|
117
119
|
@max_payload_bytes = 1.megabyte
|
|
118
120
|
@max_state_bytes = 5.megabytes
|
|
121
|
+
@warn_state_bytes = 64.kilobytes
|
|
119
122
|
@max_result_bytes = 1.megabyte
|
|
120
123
|
@max_attempts = 5
|
|
121
124
|
@retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
|
|
@@ -223,6 +226,9 @@ module SolidObjects
|
|
|
223
226
|
positive_values.each do |name, value|
|
|
224
227
|
raise ArgumentError, "#{name} must be positive" unless value.positive?
|
|
225
228
|
end
|
|
229
|
+
if warn_state_bytes > max_state_bytes
|
|
230
|
+
raise ArgumentError, "warn_state_bytes must not exceed max_state_bytes"
|
|
231
|
+
end
|
|
226
232
|
message_retention_by_actor_type.each do |actor_type, retention|
|
|
227
233
|
raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
|
|
228
234
|
raise ArgumentError, "message retention must be positive" unless retention.positive?
|
|
@@ -260,6 +266,7 @@ module SolidObjects
|
|
|
260
266
|
claim_scan_limit:,
|
|
261
267
|
max_payload_bytes:,
|
|
262
268
|
max_state_bytes:,
|
|
269
|
+
warn_state_bytes:,
|
|
263
270
|
max_result_bytes:,
|
|
264
271
|
max_attempts:,
|
|
265
272
|
process_heartbeat_interval:,
|
|
@@ -25,9 +25,15 @@ module SolidObjects
|
|
|
25
25
|
|
|
26
26
|
SolidObjects.instrument(:"message.started", **instrumentation_payload)
|
|
27
27
|
result = invoke_actor(message_context)
|
|
28
|
-
ensure_query_did_not_mutate_state!(state_before)
|
|
29
28
|
observable_changes = changed_observables(observables_before, actor.observable_values)
|
|
30
|
-
|
|
29
|
+
state_after = actor.state.to_h
|
|
30
|
+
ensure_query_did_not_mutate_state!(state_before, state_after)
|
|
31
|
+
complete(
|
|
32
|
+
result,
|
|
33
|
+
observable_changes,
|
|
34
|
+
state_after:,
|
|
35
|
+
state_changed: state_after != state_before
|
|
36
|
+
)
|
|
31
37
|
true
|
|
32
38
|
rescue LostActivation
|
|
33
39
|
raise
|
|
@@ -59,11 +65,11 @@ module SolidObjects
|
|
|
59
65
|
end
|
|
60
66
|
end
|
|
61
67
|
|
|
62
|
-
# @rbs (Hash[String, untyped]) -> void
|
|
63
|
-
def ensure_query_did_not_mutate_state!(state_before)
|
|
68
|
+
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> void
|
|
69
|
+
def ensure_query_did_not_mutate_state!(state_before, state_after)
|
|
64
70
|
return unless message.delivery_mode == "sync"
|
|
65
71
|
return unless actor.class.definition.queries.key?(message.operation.to_sym)
|
|
66
|
-
return if
|
|
72
|
+
return if state_after == state_before
|
|
67
73
|
|
|
68
74
|
raise InvalidActor, "query #{message.operation.inspect} mutated actor state"
|
|
69
75
|
end
|
|
@@ -75,10 +81,10 @@ module SolidObjects
|
|
|
75
81
|
end
|
|
76
82
|
end
|
|
77
83
|
|
|
78
|
-
# @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
|
|
79
|
-
def complete(result, observable_changes, state_changed:)
|
|
80
|
-
|
|
81
|
-
|
|
84
|
+
# @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
|
|
85
|
+
def complete(result, observable_changes, state_after:, state_changed:)
|
|
86
|
+
dumped_state = Serialization.dump_with_byte_size(
|
|
87
|
+
state_after,
|
|
82
88
|
max_bytes: SolidObjects.configuration.max_state_bytes
|
|
83
89
|
)
|
|
84
90
|
serialized_result = Serialization.dump(
|
|
@@ -102,7 +108,7 @@ module SolidObjects
|
|
|
102
108
|
locked_message = Message.lock.find(message.id)
|
|
103
109
|
execute_commit_actions(commit_action_intents)
|
|
104
110
|
instance.update!(
|
|
105
|
-
state:
|
|
111
|
+
state: dumped_state.value,
|
|
106
112
|
state_version: actor.class.state_version,
|
|
107
113
|
state_revision: locked_message.sequence,
|
|
108
114
|
last_used_at: SolidObjects.database_adapter.database_now
|
|
@@ -129,17 +135,17 @@ module SolidObjects
|
|
|
129
135
|
end
|
|
130
136
|
|
|
131
137
|
observable_changes.each_key do |observable_name|
|
|
132
|
-
SolidObjects.
|
|
138
|
+
SolidObjects.instrument_after_commit(
|
|
133
139
|
:"broadcast.enqueued",
|
|
134
140
|
**instrumentation_payload,
|
|
135
141
|
observable_name:
|
|
136
142
|
)
|
|
137
143
|
end
|
|
138
144
|
moved_reminders.each do |moved|
|
|
139
|
-
SolidObjects.
|
|
145
|
+
SolidObjects.instrument_after_commit(:"reminder.replaced", **moved)
|
|
140
146
|
end
|
|
141
147
|
enqueued_effects.each do |effect|
|
|
142
|
-
SolidObjects.
|
|
148
|
+
SolidObjects.instrument_after_commit(
|
|
143
149
|
:"effect.enqueued",
|
|
144
150
|
effect_id: effect.effect_id,
|
|
145
151
|
effect_name: effect.name,
|
|
@@ -148,10 +154,25 @@ module SolidObjects
|
|
|
148
154
|
actor_id: message.actor_id
|
|
149
155
|
)
|
|
150
156
|
end
|
|
151
|
-
|
|
157
|
+
report_large_state(dumped_state.byte_size)
|
|
158
|
+
SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
|
|
152
159
|
SolidObjects.wake_up.signal
|
|
153
160
|
end
|
|
154
161
|
|
|
162
|
+
# @rbs (Integer) -> void
|
|
163
|
+
def report_large_state(byte_count)
|
|
164
|
+
threshold = SolidObjects.configuration.warn_state_bytes
|
|
165
|
+
return if byte_count <= threshold
|
|
166
|
+
|
|
167
|
+
SolidObjects.instrument_after_commit(
|
|
168
|
+
:"state.large",
|
|
169
|
+
actor_type: message.actor_type,
|
|
170
|
+
actor_id: message.actor_id,
|
|
171
|
+
byte_count:,
|
|
172
|
+
threshold_bytes: threshold
|
|
173
|
+
)
|
|
174
|
+
end
|
|
175
|
+
|
|
155
176
|
# @rbs (Array[Actor::CommitActionIntent]) -> void
|
|
156
177
|
def execute_commit_actions(intents)
|
|
157
178
|
ensure_application_database_is_shared! if intents.any?
|
|
@@ -352,7 +373,7 @@ module SolidObjects
|
|
|
352
373
|
end
|
|
353
374
|
end
|
|
354
375
|
|
|
355
|
-
SolidObjects.
|
|
376
|
+
SolidObjects.instrument_after_commit(
|
|
356
377
|
:"message.failed",
|
|
357
378
|
**instrumentation_payload,
|
|
358
379
|
error_class: error.class.name,
|
|
@@ -387,7 +408,7 @@ module SolidObjects
|
|
|
387
408
|
claimed_message.destroy!
|
|
388
409
|
end
|
|
389
410
|
|
|
390
|
-
SolidObjects.
|
|
411
|
+
SolidObjects.instrument_after_commit(
|
|
391
412
|
:"message.rejected",
|
|
392
413
|
**instrumentation_payload,
|
|
393
414
|
code: rejection.code
|
|
@@ -6,5 +6,38 @@ module SolidObjects
|
|
|
6
6
|
def instrument(event, **payload, &block)
|
|
7
7
|
ActiveSupport::Notifications.instrument("solid_objects.#{event}", payload, &block)
|
|
8
8
|
end
|
|
9
|
+
|
|
10
|
+
# @rbs (Symbol, **untyped) -> void
|
|
11
|
+
def instrument_after_commit(event, **payload)
|
|
12
|
+
instrument(event, **payload)
|
|
13
|
+
rescue => error
|
|
14
|
+
report_instrumentation_failure(event, error)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# @rbs (Symbol, Exception) -> void
|
|
20
|
+
def report_instrumentation_failure(event, error)
|
|
21
|
+
instrument(
|
|
22
|
+
:"instrumentation.failed",
|
|
23
|
+
instrumentation_event: "solid_objects.#{event}",
|
|
24
|
+
error_class: error.class.name
|
|
25
|
+
)
|
|
26
|
+
rescue => failure
|
|
27
|
+
log_instrumentation_failure(event, failure)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# @rbs (Symbol, Exception) -> void
|
|
31
|
+
def log_instrumentation_failure(event, error)
|
|
32
|
+
SolidObjects.configuration.logger.error(
|
|
33
|
+
{
|
|
34
|
+
event: "solid_objects.instrumentation.failed",
|
|
35
|
+
instrumentation_event: "solid_objects.#{event}",
|
|
36
|
+
error_class: error.class.name
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
rescue
|
|
40
|
+
nil
|
|
41
|
+
end
|
|
9
42
|
end
|
|
10
43
|
end
|