Skip to content

Commit dd72e6e

Browse files
authored
Merge pull request #59 from cardmagic/perf/reuse-the-committed-encoding
perf: reuse the encoding the after image already built
2 parents cd4d5c9 + 13bdb2c commit dd72e6e

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)