Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
26 changes: 25 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,30 @@
# 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 9.5% at 116 KB of state and 18.9% 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. A value that
`normalize` rejects still raises `InvalidPayload`, and a value that JSON
cannot encode still raises `InvalidPayload` where a limit applies. Without a
limit, that value now passes through, which affects a string that carries
invalid encoding.
- Add `warn_state_bytes`, a soft threshold that defaults to 64 KB. 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 and
the event match `warnStateBytes` in solid-objects-js, which defaults to
128 KB, because the Node curve falls later than this one.

- 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
55 changes: 55 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,36 @@ 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)
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 (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
40 changes: 40 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,46 @@ 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.

| Committed state | Before | After | Change |
| ---: | ---: | ---: | ---: |
| 23 bytes | 1,171.4 messages/s | 1,204.0 messages/s | +2.8% |
| 13,662 bytes | 642.8 messages/s | 644.9 messages/s | +0.3% |
| 118,786 bytes | 165.5 messages/s | 181.2 messages/s | +9.5% |
| 1,026,356 bytes | 20.6 messages/s | 24.5 messages/s | +18.9% |

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. The gain
grows with the state, because the database write dominates a small turn.

The curve matters more than the change. Throughput falls about 26 times between
13 KB and 1 MB of state, and about 49 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 55%.

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
17 changes: 17 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,14 @@ 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 26
times between 13 KB and 1 MB of state. See `docs/benchmarks.md` for the curve.
`warn_state_bytes` is the soft threshold. 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 +317,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 +354,13 @@ 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
`state_bytes` 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. Watch it to find the actors whose state grows without a
bound, because their throughput falls as the state grows.

`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 49
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
4 changes: 4 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 @@ -260,6 +263,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
41 changes: 31 additions & 10 deletions lib/solid_objects/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,15 @@ def call

SolidObjects.instrument(:"message.started", **instrumentation_payload)
result = invoke_actor(message_context)
ensure_query_did_not_mutate_state!(state_before)
observable_changes = changed_observables(observables_before, actor.observable_values)
complete(result, observable_changes, state_changed: actor.state.to_h != state_before)
state_after = actor.state.to_h
ensure_query_did_not_mutate_state!(state_before, state_after)
complete(
result,
observable_changes,
state_after:,
state_changed: state_after != state_before
)
true
rescue LostActivation
raise
Expand Down Expand Up @@ -59,11 +65,11 @@ def invoke_actor(message_context)
end
end

# @rbs (Hash[String, untyped]) -> void
def ensure_query_did_not_mutate_state!(state_before)
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> void
def ensure_query_did_not_mutate_state!(state_before, state_after)
return unless message.delivery_mode == "sync"
return unless actor.class.definition.queries.key?(message.operation.to_sym)
return if actor.state.to_h == state_before
return if state_after == state_before

raise InvalidActor, "query #{message.operation.inspect} mutated actor state"
end
Expand All @@ -75,10 +81,10 @@ def changed_observables(before, after)
end
end

# @rbs (untyped, Hash[String, untyped], state_changed: bool) -> void
def complete(result, observable_changes, state_changed:)
serialized_state = Serialization.dump(
actor.state.to_h,
# @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
def complete(result, observable_changes, state_after:, state_changed:)
dumped_state = Serialization.dump_with_byte_size(
state_after,
max_bytes: SolidObjects.configuration.max_state_bytes
)
serialized_result = Serialization.dump(
Expand All @@ -102,7 +108,7 @@ def complete(result, observable_changes, state_changed:)
locked_message = Message.lock.find(message.id)
execute_commit_actions(commit_action_intents)
instance.update!(
state: serialized_state,
state: dumped_state.value,
state_version: actor.class.state_version,
state_revision: locked_message.sequence,
last_used_at: SolidObjects.database_adapter.database_now
Expand Down Expand Up @@ -148,10 +154,25 @@ def complete(result, observable_changes, state_changed:)
actor_id: message.actor_id
)
end
report_large_state(dumped_state.byte_size)
Comment thread
greptile-apps[bot] marked this conversation as resolved.
SolidObjects.instrument(:"message.completed", **instrumentation_payload)
SolidObjects.wake_up.signal
end

# @rbs (Integer) -> void
def report_large_state(byte_size)
threshold = SolidObjects.configuration.warn_state_bytes
return if byte_size <= threshold

SolidObjects.instrument(
:"state.large",
actor_type: message.actor_type,
actor_id: message.actor_id,
state_bytes: byte_size,
threshold_bytes: threshold
)
end

# @rbs (Array[Actor::CommitActionIntent]) -> void
def execute_commit_actions(intents)
ensure_application_database_is_shared! if intents.any?
Expand Down
15 changes: 12 additions & 3 deletions lib/solid_objects/serialization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,26 @@ module SolidObjects
module Serialization
MAX_NESTING = 100

Dumped = Data.define(:value, :byte_size)

class << self
# @rbs (untyped, ?max_bytes: Integer?) -> untyped
def dump(value, max_bytes: nil)
return normalize(value) unless max_bytes

dump_with_byte_size(value, max_bytes:).value
end

# @rbs (untyped, ?max_bytes: Integer?) -> Dumped
def dump_with_byte_size(value, max_bytes: nil)
normalized = normalize(value)
encoded = JSON.generate(normalized, max_nesting: MAX_NESTING)
byte_size = JSON.generate(normalized, max_nesting: MAX_NESTING).bytesize

if max_bytes && encoded.bytesize > max_bytes
if max_bytes && byte_size > max_bytes
raise PayloadTooLarge, "serialized value exceeds #{max_bytes} bytes"
end

normalized
Dumped.new(value: normalized, byte_size:)
rescue JSON::GeneratorError, EncodingError => error
raise InvalidPayload, error.message
end
Expand Down
2 changes: 1 addition & 1 deletion lib/solid_objects/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# rbs_inline: enabled

module SolidObjects
VERSION = "0.14.2"
VERSION = "0.14.3"
end
Loading
Loading