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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,25 @@
# Changelog

## 0.14.4 - 2026-08-30

- Reuse the encoding the after image already built. `State#to_h` copies the
state by encoding it and parsing the result, then threw the encoded string
away, and `complete` normalized and encoded the same hash a second time to
measure it. `to_h_with_byte_size` returns the copy with the size of the
encoding that produced it, so a committed turn now traverses the state twice
rather than three times and encodes it twice rather than three times.
Measured on SQLite against 0.14.3, committed throughput rises 5.3% at 13 KB
of state, 16.1% at 116 KB, and 12.0% at 1 MB. `docs/benchmarks.md` holds the
numbers. `solid-objects-js` already measured the string it commits; this
brings the gem to the same shape.
- Add `Serialization.deep_copy_with_byte_size`, which returns a deep copy
beside the size of its encoded form. `deep_copy` now calls it, so both
encode once.
- Enforce `max_state_bytes` against the size the after image reports rather
than by encoding the state again. The turn still fails with
`PayloadTooLarge` before it opens its commit transaction, which a test now
covers end to end.

## 0.14.3 - 2026-08-29

- Cut one of the three full state copies a committed turn made. The executor
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.3)
solid_objects (0.14.4)
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.3)
solid_objects (0.14.4)
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
16 changes: 16 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,22 @@ 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.

`0.14.4` removed the traversal that remained. Copying the state encodes it and
parses the result, and the commit then normalized and encoded the same hash
again to measure it. The copy now reports the size of the encoding that
produced it. Measured the same way, against the released `0.14.3` tree:

| Committed state | 0.14.3 | 0.14.4 | Change |
| ---: | ---: | ---: | ---: |
| 23 bytes | 1,251.2 messages/s | 1,242.8 messages/s | -0.7% |
| 13,662 bytes | 612.0 messages/s | 644.7 messages/s | +5.3% |
| 118,786 bytes | 171.9 messages/s | 199.6 messages/s | +16.1% |
| 1,026,356 bytes | 23.4 messages/s | 26.2 messages/s | +12.0% |

A committed turn now traverses the state twice and encodes it twice, against
three of each before. The empty-state row again 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
Expand Down
25 changes: 15 additions & 10 deletions lib/solid_objects/executor.rb
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,13 @@ def call
SolidObjects.instrument(:"message.started", **instrumentation_payload)
result = invoke_actor(message_context)
observable_changes = changed_observables(observables_before, actor.observable_values)
state_after = actor.state.to_h
ensure_query_did_not_mutate_state!(state_before, state_after)
state_after = actor.state.to_h_with_byte_size
ensure_query_did_not_mutate_state!(state_before, state_after.value)
complete(
result,
observable_changes,
state_after:,
state_changed: state_after != state_before
state_changed: state_after.value != state_before
)
true
rescue LostActivation
Expand Down Expand Up @@ -81,12 +81,9 @@ def changed_observables(before, after)
end
end

# @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
# @rbs (untyped, Hash[String, untyped], state_after: Serialization::Dumped, 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
)
ensure_state_fits!(state_after.byte_size)
serialized_result = Serialization.dump(
(message.delivery_mode == "sync") ? result : nil,
max_bytes: SolidObjects.configuration.max_result_bytes
Expand All @@ -108,7 +105,7 @@ def complete(result, observable_changes, state_after:, state_changed:)
locked_message = Message.lock.find(message.id)
execute_commit_actions(commit_action_intents)
instance.update!(
state: dumped_state.value,
state: state_after.value,
state_version: actor.class.state_version,
state_revision: locked_message.sequence,
last_used_at: SolidObjects.database_adapter.database_now
Expand Down Expand Up @@ -154,11 +151,19 @@ def complete(result, observable_changes, state_after:, state_changed:)
actor_id: message.actor_id
)
end
report_large_state(dumped_state.byte_size)
report_large_state(state_after.byte_size)
SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
SolidObjects.wake_up.signal
end

# @rbs (Integer) -> void
def ensure_state_fits!(byte_size)
max_bytes = SolidObjects.configuration.max_state_bytes
return if byte_size <= max_bytes

raise PayloadTooLarge, "serialized value exceeds #{max_bytes} bytes"
end

# @rbs (Integer) -> void
def report_large_state(byte_count)
threshold = SolidObjects.configuration.warn_state_bytes
Expand Down
9 changes: 8 additions & 1 deletion lib/solid_objects/serialization.rb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ def load(value)

# @rbs (untyped) -> untyped
def deep_copy(value)
JSON.parse(JSON.generate(normalize(value), max_nesting: MAX_NESTING))
deep_copy_with_byte_size(value).value
end

# @rbs (untyped) -> Dumped
def deep_copy_with_byte_size(value)
encoded = JSON.generate(normalize(value), max_nesting: MAX_NESTING)

Dumped.new(value: JSON.parse(encoded), byte_size: encoded.bytesize)
rescue JSON::GeneratorError, JSON::ParserError, EncodingError => error
raise InvalidPayload, error.message
end
Expand Down
5 changes: 5 additions & 0 deletions lib/solid_objects/state.rb
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ def to_h
Serialization.deep_copy(data)
end

# @rbs () -> Serialization::Dumped
def to_h_with_byte_size
Serialization.deep_copy_with_byte_size(data)
end

# @rbs (Symbol | String) -> untyped
def fetch(name)
key = name.to_s
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.3"
VERSION = "0.14.4"
end
7 changes: 5 additions & 2 deletions sig/generated/lib/solid_objects/executor.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,11 @@ module SolidObjects
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
def changed_observables: (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]

# @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
def complete: (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
# @rbs (untyped, Hash[String, untyped], state_after: Serialization::Dumped, state_changed: bool) -> void
def complete: (untyped, Hash[String, untyped], state_after: Serialization::Dumped, state_changed: bool) -> void

# @rbs (Integer) -> void
def ensure_state_fits!: (Integer) -> void

# @rbs (Integer) -> void
def report_large_state: (Integer) -> void
Expand Down
3 changes: 3 additions & 0 deletions sig/generated/lib/solid_objects/serialization.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@ module SolidObjects
# @rbs (untyped) -> untyped
def self.deep_copy: (untyped) -> untyped

# @rbs (untyped) -> Dumped
def self.deep_copy_with_byte_size: (untyped) -> Dumped

# @rbs (untyped) -> untyped
def self.readonly_copy: (untyped) -> untyped

Expand Down
3 changes: 3 additions & 0 deletions sig/generated/lib/solid_objects/state.rbs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ module SolidObjects
# @rbs () -> Hash[String, untyped]
def to_h: () -> Hash[String, untyped]

# @rbs () -> Serialization::Dumped
def to_h_with_byte_size: () -> Serialization::Dumped

# @rbs (Symbol | String) -> untyped
def fetch: (Symbol | String) -> untyped

Expand Down
30 changes: 25 additions & 5 deletions test/integration/state_commit_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,11 @@ def increment

def count_state_copies
actor_class = self.class
state.define_singleton_method(:to_h) do |*arguments|
actor_class.state_copies += 1
super(*arguments)
%i[to_h to_h_with_byte_size].each do |name|
state.define_singleton_method(name) do |*arguments|
actor_class.state_copies += 1
super(*arguments)
end
end
end
end
Expand Down Expand Up @@ -97,6 +99,23 @@ def respond_to_missing?(*)
ObservableMutatingActor.mutate_on_read = false
end

test "a state above the hard limit fails its message and commits nothing" do
SolidObjects.configuration.max_state_bytes = 512
SolidObjects.configuration.warn_state_bytes = 256
SolidObjects.configuration.max_attempts = 1

LargeStateActor.ref("over").async.grow(size: 4_096)
worker = SolidObjects::Worker.new
worker.run_until_idle

message = SolidObjects::Message.find_by!(actor_type: "state-commit-large", actor_id: "over")
assert_equal "SolidObjects::PayloadTooLarge", message.error.fetch("class")
instance = SolidObjects::Instance.find_by!(actor_type: "state-commit-large", actor_id: "over")
assert_empty instance.state
ensure
worker&.stop
end

test "a committed message copies the state once after its handler runs" do
CountingActor.ref("message").async.increment
worker = SolidObjects::Worker.new
Expand All @@ -109,11 +128,12 @@ def respond_to_missing?(*)
worker&.stop
end

test "a committed message encodes the state once and the result once" do
test "a committed message does not encode the state a second time" do
CountingActor.ref("encodes").async.increment
worker = SolidObjects::Worker.new

assert_equal 2, measured_dumps { worker.run_until_idle }
assert_equal 1, measured_dumps { worker.run_until_idle },
"only the result should reach a byte-limited dump; the state carries its own size"
ensure
worker&.stop
end
Expand Down
20 changes: 20 additions & 0 deletions test/unit/serialization_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,26 @@ def as_json
end
end

test "reports the encoded byte size beside a deep copy" do
copied = SolidObjects::Serialization.deep_copy_with_byte_size({ quantity: 1 })

assert_equal({ "quantity" => 1 }, copied.value)
assert_equal 14, copied.byte_size
end

test "copies independently while it reports the byte size" do
original = { "items" => [ { "quantity" => 1 } ] }

copied = SolidObjects::Serialization.deep_copy_with_byte_size(original)
copied.value.fetch("items").first["quantity"] = 2

assert_equal 1, original.fetch("items").first.fetch("quantity")
end

test "encodes a deep copy once" do
assert_equal 1, json_generate_calls { SolidObjects::Serialization.deep_copy_with_byte_size({ quantity: 1 }) }
end

test "returns an independent deep copy" do
original = { "items" => [ { "quantity" => 1 } ] }
copy = SolidObjects::Serialization.deep_copy(original)
Expand Down
Loading