Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,39 @@
# Changelog

## Unreleased
## 0.14.3 - 2026-08-29

- Cut one of the three full state copies a committed turn made. The executor
built the after image twice, once to answer whether the state changed and
once for the committed row, and a synchronous query built a third for the
mutation guard. One image now answers all three. Measured on SQLite,
committed throughput rises 4.8% at 13 KB of state, 3.1% at 116 KB, and 10.2%
at 1 MB. `benchmark/state_size.rb` is the scenario and `docs/benchmarks.md`
holds the numbers. The guard now compares the image taken after the
observables are read, so a query whose observable mutates state also fails
with `InvalidActor`.
- Stop building an encoded string that `Serialization.dump` discarded. The
method encoded every value to measure it, while most call sites pass no
`max_bytes`. It now encodes only when a limit applies. That encoding was also
the only check that a string held valid bytes, so `normalize` now checks the
encoding of every string and key it visits. A value it rejects raises
`InvalidPayload` at the call that staged it, as before, rather than a
`JSON::GeneratorError` from inside the commit transaction.
- Add `warn_state_bytes`, a soft threshold that defaults to 64 KB and must not
exceed `max_state_bytes`. A commit above it reports
`solid_objects.state.large` with the actor identity, the `byte_count`, and
the threshold. The event carries no application state, and it reports after
the commit. `max_state_bytes` keeps its 5 MB default, which measurement shows
is a limit rather than an operating point. The setting, the event, and its
payload match `warnStateBytes` in solid-objects-js, which defaults to 128 KB,
because the Node curve falls later than this one.
- Report a committed turn without letting a subscriber fail it. Every event
the executor emitted after its commit ran outside a rescue, so a subscriber
that raised turned a committed turn into a failed one: the runtime skipped
`message.completed`, tried to fail a message whose claim it had already
destroyed, and lost the worker pass. Those reports now go through
`instrument_after_commit`, which reports a raising subscriber as
`solid_objects.instrumentation.failed` and continues. This matches the
isolation `solid-objects-js` already applied to every event it emits.

- Align the use-case claims with solid-objects-js. "Is it worth installing
here?" listed long-lived workflows without a limit, while `docs/fit.md`
Expand Down
4 changes: 2 additions & 2 deletions Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
solid_objects (0.14.2)
solid_objects (0.14.3)
actioncable (>= 7.1)
actionpack (>= 7.1)
actionview (>= 7.1)
Expand Down Expand Up @@ -384,7 +384,7 @@ CHECKSUMS
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
solid_objects (0.14.2)
solid_objects (0.14.3)
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b
Expand Down
5 changes: 5 additions & 0 deletions benchmark/state_size.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# rbs_inline: enabled

require_relative "support"

SolidObjectsBenchmark.state_size
63 changes: 63 additions & 0 deletions benchmark/support.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@

module SolidObjectsBenchmark
DATABASE_PATH = File.expand_path("../tmp/solid_objects_benchmark.sqlite3", __dir__)
STATE_SIZES = [ 0, 16 * 1_024, 128 * 1_024, 1_024 * 1_024 ].freeze
STATE_ENTRY_BYTES = 26

class StateSizeActor < SolidObjects::Actor
actor_type "benchmark-state-size"

attribute :count, default: 0
attribute :filler, default: -> { {} }

def fill(size:)
entries = size / STATE_ENTRY_BYTES
self.filler = Array.new(entries) { |index| [ "key-#{index}", "value-#{index}" ] }.to_h
filler.length
end

def increment
self.count = count + 1
end
end

class CounterActor < SolidObjects::Actor
actor_type "benchmark-counter"
Expand Down Expand Up @@ -289,6 +308,12 @@ def component_delivery
)
end

# @rbs () -> void
def state_size
warm_up_state_size
STATE_SIZES.each { |size| measure_state_size(size) }
end

# @rbs () -> void
def query_count
turn = message_turn_query_count
Expand Down Expand Up @@ -433,6 +458,44 @@ def enqueue_round_robin
count.times { |index| references[index % actor_count].async.increment }
end

# @rbs () -> void
def warm_up_state_size
reference = StateSizeActor.ref("state-size-warm-up")
reference.fill(size: 0)
count.times { reference.async.increment }
worker = SolidObjects::Worker.new
drain(worker)
ensure
worker&.stop
end

# @rbs (Integer) -> void
def measure_state_size(size)
silence_large_state_warning
actor_id = "state-size-#{size}"
reference = StateSizeActor.ref(actor_id)
reference.fill(size:)
state_bytes = committed_state_bytes(actor_id)
count.times { reference.async.increment }
worker = SolidObjects::Worker.new
measure("process #{count} messages with #{state_bytes} bytes of state") { drain(worker) }
ensure
worker&.stop
end

# @rbs () -> void
def silence_large_state_warning
return unless SolidObjects.configuration.respond_to?(:warn_state_bytes=)

SolidObjects.configuration.warn_state_bytes = SolidObjects.configuration.max_state_bytes
end

# @rbs (String) -> Integer
def committed_state_bytes(actor_id)
instance = SolidObjects::Instance.find_by!(actor_type: "benchmark-state-size", actor_id:)
JSON.generate(instance.state).bytesize
end

# @rbs (SolidObjects::Worker) -> Integer
def drain(worker)
processed = 0
Expand Down
45 changes: 45 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,51 @@ result is why Solid Objects does not publish one latency promise. Network
topology, adapter behavior, host schema, logging, callbacks, and contention all
matter.

## State size and committed throughput

A turn commits the whole state image. It copies the state, encodes it, and
writes the row, so every message pays for the size of the state its actor
keeps. Run the scenario with:

```bash
COUNT=300 bundle exec ruby -Ilib benchmark/state_size.rb
```

Measured 2026-08-29 on an Apple M5 with 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1,
and SQLite 3.53.2. One hot actor received 300 messages at each state size. Each
figure is the median of five runs, and the two trees ran one after the other in
each round. The state holds many small entries, because a copy visits every
node, and one long string of the same length costs much less. The harness sets
`warn_state_bytes` to the hard limit, so neither tree pays for an event that
only one of them can emit.

| Committed state | Before | After | Change |
| ---: | ---: | ---: | ---: |
| 23 bytes | 1,268.0 messages/s | 1,252.5 messages/s | -1.2% |
| 13,662 bytes | 624.1 messages/s | 654.1 messages/s | +4.8% |
| 118,786 bytes | 169.3 messages/s | 174.6 messages/s | +3.1% |
| 1,026,356 bytes | 21.6 messages/s | 23.8 messages/s | +10.2% |

The "before" tree copied the whole state three times per committed turn and
encoded a string that it discarded whenever the caller gave no byte limit. The
"after" tree copies it twice and encodes only where a limit applies. It also
checks the encoding of every string it normalizes, which the discarded encoding
used to do, so part of the saving pays for that check. The gain grows with the
state, because the database write dominates a small turn. The empty-state row
sits inside run-to-run variance.

The curve matters more than the change. Throughput falls about 28 times between
13 KB and 1 MB of state, and about 53 times between an empty state and 1 MB.
The `max_state_bytes` default of 5 MB is therefore a limit rather than an
operating point. `warn_state_bytes` defaults to 64 KB, and each commit above it
reports `solid_objects.state.large`. The Node package carries the same setting
as `warnStateBytes` and defaults it to 128 KB, because its measured curve falls
later: it keeps 98% of its empty-state throughput at 16 KB, where this gem
keeps 52% at 13 KB.

These are developer-laptop numbers on one adapter. They show shape and ratio,
not a capacity guarantee.

## Reactive delivery paths

Measured 2026-08-09 on an Apple M5 with 200 iterations, for one actor mutation
Expand Down
28 changes: 28 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ end
| `max_messages_per_activation_pass` | 50 |
| `max_activation_duration` | 5 seconds |
| `max_mailbox_length` | 10,000 |
| `warn_state_bytes` | 64 KB |
| `max_attempts` | 5 |
| `process_heartbeat_interval` | 15 seconds |
| `process_alive_threshold` | 60 seconds |
Expand All @@ -217,6 +218,15 @@ Payload, state, and result byte limits; retry delay; table prefix; logging;
wake-up; broadcast; database; and authorization adapters are also configurable.
Invalid lease intervals, component counts, and size limits fail fast at boot.

`max_state_bytes` defaults to 5 MB, which is a limit rather than an operating
point. A turn copies the whole state, encodes it, and writes the row, so
committed throughput falls long before that limit: measured on SQLite, about 28
times between 13 KB and 1 MB of state. See `docs/benchmarks.md` for the curve.
`warn_state_bytes` is the soft threshold, and it must not exceed
`max_state_bytes`. Each commit above it reports `solid_objects.state.large` and
nothing else changes, so an application that already keeps a large state keeps
working while its operator learns the cost.

Keep lease duration comfortably above renewal interval and expected database
pause time. A handler can exceed the pass-duration budget because Ruby code is
not safely preempted; alert on message duration and isolate untrusted work.
Expand Down Expand Up @@ -308,6 +318,7 @@ Alert on:
- ready and claimed membership counts;
- mailbox-full rejections;
- actor turn duration and failures;
- committed state above `warn_state_bytes`;
- lost-activation rate;
- dead-letter creation;
- actor destruction rate;
Expand Down Expand Up @@ -344,6 +355,23 @@ per queued item keeps only the last, and the earlier wake-up never happens.
Watch this event if your actors schedule from a loop or from a handler that can
run more than once. Rescheduling to the same time reports nothing.

`solid_objects.state.large` reports a committed turn whose state exceeded
`warn_state_bytes`. The payload carries the actor identity, the `byte_count`
the commit wrote, and the `threshold_bytes` it passed. It never carries the
state. The event reports after the commit, so a turn that rolled back reports
nothing. Every commit above the threshold reports, including a synchronous
query and a turn that changed nothing, so a hot actor reports once per message.
Aggregate by actor rather than alert on each event, and watch the reported size
rather than the event rate: a state that grows without a bound is what this
event exists to find. `solid-objects-js` emits the same event under the same
name and payload.

`solid_objects.instrumentation.failed` reports a subscriber that raised while
the runtime reported a committed turn. The payload carries the
`instrumentation_event` that failed and the `error_class`. The turn itself is
unaffected, because it already committed. Watch this event to find a broken
subscriber, which would otherwise be silent.

`solid_objects.component.refreshed` covers every authorized component refresh
request. Its payload carries the actor identity, `component_name`,
`component_key`, declared `dependencies`, `refresh_method`, the rendered
Expand Down
10 changes: 9 additions & 1 deletion docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,15 @@
loads them in every process, and a rejected subscription reports which
condition caused it instead of closing the socket silently.
- Backpressure: mailbox/payload/state/result caps and fair yields exist;
distributed per-actor rate limits and global admission control do not.
distributed per-actor rate limits and global admission control do not. The
state cap is a limit rather than an operating point. `max_state_bytes`
defaults to 5 MB, and committed throughput measured on SQLite falls about 53
times between an empty state and 1 MB of state, which `docs/benchmarks.md`
records. A soft `warn_state_bytes` threshold, 64 KB by default, now
reports each commit above it as `solid_objects.state.large`. The hard default
stays at 5 MB, because lowering it would break an application whose actors
already exceed a lower value; a major release can lower it from the measured
curve.
- Administration: `SolidObjects::Web` is a mountable Rack dashboard covering
instances, mailbox, reminders, effects, broadcasts, dead letters, and
processes, with actor-type and actor-id filtering, status filters, paging, a
Expand Down
7 changes: 7 additions & 0 deletions lib/solid_objects/configuration.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ class Configuration
# @rbs @claim_scan_limit: Integer
# @rbs @max_payload_bytes: Integer
# @rbs @max_state_bytes: Integer
# @rbs @warn_state_bytes: Integer
# @rbs @max_result_bytes: Integer
# @rbs @max_attempts: Integer
# @rbs @retry_delay: Proc
Expand Down Expand Up @@ -63,6 +64,7 @@ class Configuration
:claim_scan_limit,
:max_payload_bytes,
:max_state_bytes,
:warn_state_bytes,
:max_result_bytes,
:max_attempts,
:retry_delay,
Expand Down Expand Up @@ -116,6 +118,7 @@ def initialize
@claim_scan_limit = 100
@max_payload_bytes = 1.megabyte
@max_state_bytes = 5.megabytes
@warn_state_bytes = 64.kilobytes
@max_result_bytes = 1.megabyte
@max_attempts = 5
@retry_delay = ->(attempt) { [ 2**(attempt - 1), 60 ].min.to_f }
Expand Down Expand Up @@ -223,6 +226,9 @@ def validate!
positive_values.each do |name, value|
raise ArgumentError, "#{name} must be positive" unless value.positive?
end
if warn_state_bytes > max_state_bytes
raise ArgumentError, "warn_state_bytes must not exceed max_state_bytes"
end
message_retention_by_actor_type.each do |actor_type, retention|
raise ArgumentError, "actor type cannot be empty" if actor_type.to_s.empty?
raise ArgumentError, "message retention must be positive" unless retention.positive?
Expand Down Expand Up @@ -260,6 +266,7 @@ def positive_values
claim_scan_limit:,
max_payload_bytes:,
max_state_bytes:,
warn_state_bytes:,
max_result_bytes:,
max_attempts:,
process_heartbeat_interval:,
Expand Down
Loading
Loading