Skip to content

Commit 13bdb2c

Browse files
committed
perf: reuse the encoding the after image already built
Copying the state encodes it and parses the result, then threw the encoded string away. complete then normalized and encoded the same hash a second time, only to learn its size. A committed turn therefore traversed the state three times and encoded it three times, where two of each are necessary. to_h_with_byte_size returns the copy beside the size of the encoding that produced it, and the commit enforces max_state_bytes against that size rather than by encoding again. The turn still fails with PayloadTooLarge before it opens its transaction, which a test now covers end to end, because nothing covered the hard limit before. Measured on SQLite against the released 0.14.3 tree, medians of five interleaved runs: 5.3% more committed messages per second at 13 KB of state, 16.1% at 116 KB, and 12.0% at 1 MB. solid-objects-js already measures the string it commits. This brings the gem to the same shape.
1 parent cd4d5c9 commit 13bdb2c

12 files changed

Lines changed: 123 additions & 21 deletions

File tree

CHANGELOG.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,25 @@
11
# Changelog
22

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

525
- Cut one of the three full state copies a committed turn made. The executor

Gemfile.lock

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
solid_objects (0.14.3)
4+
solid_objects (0.14.4)
55
actioncable (>= 7.1)
66
actionpack (>= 7.1)
77
actionview (>= 7.1)
@@ -384,7 +384,7 @@ CHECKSUMS
384384
rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d
385385
ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33
386386
securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1
387-
solid_objects (0.14.3)
387+
solid_objects (0.14.4)
388388
sqlite3 (2.9.5-aarch64-linux-gnu) sha256=78075b6337d3d182c6d2b4691049ed45cd220826160c9ea18946bf6a1de200dc
389389
sqlite3 (2.9.5-aarch64-linux-musl) sha256=18c801185deb4adc01ddb281e8f672a39e3d1729979ca91e39439cd3eac0402d
390390
sqlite3 (2.9.5-arm-linux-gnu) sha256=1bdfca0c7d63998c60b0f4a8e3c8df2d33800ccc4abd2d612eddbbbc92a4c48b

docs/benchmarks.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,22 @@ used to do, so part of the saving pays for that check. The gain grows with the
142142
state, because the database write dominates a small turn. The empty-state row
143143
sits inside run-to-run variance.
144144

145+
`0.14.4` removed the traversal that remained. Copying the state encodes it and
146+
parses the result, and the commit then normalized and encoded the same hash
147+
again to measure it. The copy now reports the size of the encoding that
148+
produced it. Measured the same way, against the released `0.14.3` tree:
149+
150+
| Committed state | 0.14.3 | 0.14.4 | Change |
151+
| ---: | ---: | ---: | ---: |
152+
| 23 bytes | 1,251.2 messages/s | 1,242.8 messages/s | -0.7% |
153+
| 13,662 bytes | 612.0 messages/s | 644.7 messages/s | +5.3% |
154+
| 118,786 bytes | 171.9 messages/s | 199.6 messages/s | +16.1% |
155+
| 1,026,356 bytes | 23.4 messages/s | 26.2 messages/s | +12.0% |
156+
157+
A committed turn now traverses the state twice and encodes it twice, against
158+
three of each before. The empty-state row again sits inside run-to-run
159+
variance.
160+
145161
The curve matters more than the change. Throughput falls about 28 times between
146162
13 KB and 1 MB of state, and about 53 times between an empty state and 1 MB.
147163
The `max_state_bytes` default of 5 MB is therefore a limit rather than an

lib/solid_objects/executor.rb

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,13 @@ def call
2626
SolidObjects.instrument(:"message.started", **instrumentation_payload)
2727
result = invoke_actor(message_context)
2828
observable_changes = changed_observables(observables_before, actor.observable_values)
29-
state_after = actor.state.to_h
30-
ensure_query_did_not_mutate_state!(state_before, state_after)
29+
state_after = actor.state.to_h_with_byte_size
30+
ensure_query_did_not_mutate_state!(state_before, state_after.value)
3131
complete(
3232
result,
3333
observable_changes,
3434
state_after:,
35-
state_changed: state_after != state_before
35+
state_changed: state_after.value != state_before
3636
)
3737
true
3838
rescue LostActivation
@@ -81,12 +81,9 @@ def changed_observables(before, after)
8181
end
8282
end
8383

84-
# @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
84+
# @rbs (untyped, Hash[String, untyped], state_after: Serialization::Dumped, state_changed: bool) -> void
8585
def complete(result, observable_changes, state_after:, state_changed:)
86-
dumped_state = Serialization.dump_with_byte_size(
87-
state_after,
88-
max_bytes: SolidObjects.configuration.max_state_bytes
89-
)
86+
ensure_state_fits!(state_after.byte_size)
9087
serialized_result = Serialization.dump(
9188
(message.delivery_mode == "sync") ? result : nil,
9289
max_bytes: SolidObjects.configuration.max_result_bytes
@@ -108,7 +105,7 @@ def complete(result, observable_changes, state_after:, state_changed:)
108105
locked_message = Message.lock.find(message.id)
109106
execute_commit_actions(commit_action_intents)
110107
instance.update!(
111-
state: dumped_state.value,
108+
state: state_after.value,
112109
state_version: actor.class.state_version,
113110
state_revision: locked_message.sequence,
114111
last_used_at: SolidObjects.database_adapter.database_now
@@ -154,11 +151,19 @@ def complete(result, observable_changes, state_after:, state_changed:)
154151
actor_id: message.actor_id
155152
)
156153
end
157-
report_large_state(dumped_state.byte_size)
154+
report_large_state(state_after.byte_size)
158155
SolidObjects.instrument_after_commit(:"message.completed", **instrumentation_payload)
159156
SolidObjects.wake_up.signal
160157
end
161158

159+
# @rbs (Integer) -> void
160+
def ensure_state_fits!(byte_size)
161+
max_bytes = SolidObjects.configuration.max_state_bytes
162+
return if byte_size <= max_bytes
163+
164+
raise PayloadTooLarge, "serialized value exceeds #{max_bytes} bytes"
165+
end
166+
162167
# @rbs (Integer) -> void
163168
def report_large_state(byte_count)
164169
threshold = SolidObjects.configuration.warn_state_bytes

lib/solid_objects/serialization.rb

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,14 @@ def load(value)
3737

3838
# @rbs (untyped) -> untyped
3939
def deep_copy(value)
40-
JSON.parse(JSON.generate(normalize(value), max_nesting: MAX_NESTING))
40+
deep_copy_with_byte_size(value).value
41+
end
42+
43+
# @rbs (untyped) -> Dumped
44+
def deep_copy_with_byte_size(value)
45+
encoded = JSON.generate(normalize(value), max_nesting: MAX_NESTING)
46+
47+
Dumped.new(value: JSON.parse(encoded), byte_size: encoded.bytesize)
4148
rescue JSON::GeneratorError, JSON::ParserError, EncodingError => error
4249
raise InvalidPayload, error.message
4350
end

lib/solid_objects/state.rb

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ def to_h
5252
Serialization.deep_copy(data)
5353
end
5454

55+
# @rbs () -> Serialization::Dumped
56+
def to_h_with_byte_size
57+
Serialization.deep_copy_with_byte_size(data)
58+
end
59+
5560
# @rbs (Symbol | String) -> untyped
5661
def fetch(name)
5762
key = name.to_s

lib/solid_objects/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# rbs_inline: enabled
22

33
module SolidObjects
4-
VERSION = "0.14.3"
4+
VERSION = "0.14.4"
55
end

sig/generated/lib/solid_objects/executor.rbs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,11 @@ module SolidObjects
3030
# @rbs (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
3131
def changed_observables: (Hash[String, untyped], Hash[String, untyped]) -> Hash[String, untyped]
3232

33-
# @rbs (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
34-
def complete: (untyped, Hash[String, untyped], state_after: Hash[String, untyped], state_changed: bool) -> void
33+
# @rbs (untyped, Hash[String, untyped], state_after: Serialization::Dumped, state_changed: bool) -> void
34+
def complete: (untyped, Hash[String, untyped], state_after: Serialization::Dumped, state_changed: bool) -> void
35+
36+
# @rbs (Integer) -> void
37+
def ensure_state_fits!: (Integer) -> void
3538

3639
# @rbs (Integer) -> void
3740
def report_large_state: (Integer) -> void

sig/generated/lib/solid_objects/serialization.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ module SolidObjects
2929
# @rbs (untyped) -> untyped
3030
def self.deep_copy: (untyped) -> untyped
3131

32+
# @rbs (untyped) -> Dumped
33+
def self.deep_copy_with_byte_size: (untyped) -> Dumped
34+
3235
# @rbs (untyped) -> untyped
3336
def self.readonly_copy: (untyped) -> untyped
3437

sig/generated/lib/solid_objects/state.rbs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ module SolidObjects
4545
# @rbs () -> Hash[String, untyped]
4646
def to_h: () -> Hash[String, untyped]
4747

48+
# @rbs () -> Serialization::Dumped
49+
def to_h_with_byte_size: () -> Serialization::Dumped
50+
4851
# @rbs (Symbol | String) -> untyped
4952
def fetch: (Symbol | String) -> untyped
5053

0 commit comments

Comments
 (0)