Skip to content

perf: cut one state copy per committed turn - #58

Merged
cardmagic merged 4 commits into
mainfrom
perf/reduce-per-turn-state-copies
Aug 29, 2026
Merged

perf: cut one state copy per committed turn#58
cardmagic merged 4 commits into
mainfrom
perf/reduce-per-turn-state-copies

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Closes #57.

What changed

  1. Executor#call built three full copies of the actor state per committed
    turn. It now builds two. One after image answers whether the state changed,
    whether a synchronous query mutated state, and what the committed row holds.
  2. Serialization.dump encoded every value to measure it, then discarded the
    string whenever the caller passed no max_bytes, which most call sites do.
    It now encodes only when a limit applies. dump_with_byte_size returns the
    encoded size beside the normalized value, so the commit that reports the
    size pays for one encoding.
  3. state_size_warning_bytes is a new soft threshold, 64 KB by default. 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
    reports after the commit, so a rolled back turn reports nothing.
    max_state_bytes keeps its 5 MB default.
  4. benchmark/state_size.rb reports committed throughput at each state size.

Version 0.14.3.

Effects to review

  • API: Serialization.dump_with_byte_size is new. Serialization.dump
    keeps its signature and its return value.
  • Correctness: the query mutation guard now compares the image taken after
    the observables are read. A query whose observable block mutates state now
    fails with InvalidActor; before, that mutation was committed silently.
  • Correctness: dump without a max_bytes no longer rejects a value that
    JSON cannot encode, such as a string holding invalid UTF-8. Every call site
    that applies a limit, including the committed state and result, still rejects
    it.
  • Migration: none. No schema change, no configuration change is required.
  • Security: none. The new event names the actor and the size only.

Measured

Apple M5, 24 GB RAM, Ruby 4.0.5, Rails 8.1.3.1, SQLite 3.53.2. One hot actor,
300 messages per size, median of five runs, with the two trees run one after
the other in each round.

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 curve matters more than the change: throughput falls about 49 times between
an empty state and 1 MB of state. That is the evidence behind the soft
threshold and behind the roadmap correction, which now records that
max_state_bytes describes a limit rather than an operating point.

Observed failures before the fix

Every test was written first and watched fail.

StateCommitTest#test_a_committed_message_builds_one_state_image_after_its_handler_runs
Expected: 1
  Actual: 2

StateCommitTest#test_a_committed_query_builds_one_state_image_after_its_handler_runs
Expected: 1
  Actual: 3

SerializationTest#test_does_not_encode_a_value_that_has_no_byte_limit
SolidObjects::InvalidPayload: source sequence is illegal/malformed utf-8

SerializationTest#test_reports_the_encoded_byte_size_beside_the_normalized_value
NoMethodError: undefined method 'dump_with_byte_size' for module SolidObjects::Serialization

ConfigurationTest#test_warns_about_large_state_well_below_the_hard_state_limit
NoMethodError: undefined method 'state_size_warning_bytes' for an instance of SolidObjects::Configuration

StateCommitTest#test_reports_committed_state_above_the_soft_threshold
NoMethodError: undefined method 'state_size_warning_bytes=' for an instance of SolidObjects::Configuration

One test is regression cover rather than a red-to-green step. The issue said
existing tests covered the query mutation guard; none did.
StateCommitTest#test_a_query_that_mutates_state_fails_its_message now holds
that behavior, and it passed against the tree before the change.

Validation

bundle exec rake            # 593 runs, 1926 assertions, 0 failures, 0 errors, 15 skips
                            # standard, rubocop, rbs validate, steep, brakeman all clean
COUNT=300 bundle exec ruby -Ilib benchmark/state_size.rb

The 15 skips are the PostgreSQL, MySQL, and Redis tests that a SQLite run
excludes. The count is unchanged by this branch.

Parity

The JavaScript port carries a larger version of the same problem and the same
5 MB default, tracked at cardmagic/solid-objects-js#32. That port is not in this
pull request.

The executor built the after image of the actor state twice, once to
answer whether the state changed and once for the committed row, and a
synchronous query built a third for its mutation guard. One image now
answers all three questions. The guard compares the image taken after
the observables are read, so a query whose observable mutates state now
fails with InvalidActor as well.

Serialization.dump encoded every value to measure it, while most call
sites pass no max_bytes and discarded that string. It now encodes only
when a limit applies. dump_with_byte_size returns the size beside the
value, so the commit that reports the size pays for one encoding.

Measured on SQLite with benchmark/state_size.rb, committed throughput
rises 9.5% at 116 KB of state and 18.9% at 1 MB. The same measurement
shows the 5 MB max_state_bytes default is a limit rather than an
operating point: throughput falls about 49 times between an empty state
and 1 MB. state_size_warning_bytes, 64 KB by default, reports each
commit above it as solid_objects.state.large, and the hard default stays
where it is so an application with a large state keeps working.

Closes #57
The Node package landed the same capability today as warnStateBytes, so
the gem carries the same name. Its default stays at 64 KB against Node's
128 KB, because the measured Ruby curve falls sooner: this gem keeps 55%
of its empty-state throughput at 13 KB, where Node keeps 98% at 16 KB.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown

Greptile Summary

The PR reduces state copying and unnecessary serialization work during committed actor turns while adding a configurable large-state warning. It also isolates post-commit notifications so subscriber and logger failures do not disrupt completed turns.

  • Reuses one post-handler state image for mutation checks and persistence.
  • Adds byte-size-aware serialization and validates string encodings during normalization.
  • Reports committed states above the configurable warning threshold.
  • Contains post-commit instrumentation failures and preserves subsequent completion reporting.
  • Adds state-size benchmarks, operational documentation, signatures, and regression coverage.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; both previously reported post-commit notification paths are contained at the current head.

Important Files Changed

Filename Overview
lib/solid_objects/executor.rb Reuses the post-handler state image, persists its measured serialization, emits the large-state event, and routes post-commit notifications through the isolation wrapper.
lib/solid_objects/instrumentation.rb Adds layered containment for subscriber, failure-reporter, and logger errors, resolving both previously reported post-commit notification failures.
lib/solid_objects/serialization.rb Avoids JSON generation when no byte limit applies while preserving payload validation and exposing encoded byte size when required.
lib/solid_objects/configuration.rb Adds a positive soft state-size threshold constrained not to exceed the existing hard limit.
test/integration/state_commit_test.rb Covers state-copy counts, query mutation detection, warning emission, rollback silence, and nested instrumentation failure isolation.

Sequence Diagram

sequenceDiagram
  participant W as Worker
  participant E as Executor
  participant A as Actor
  participant DB as Database
  participant I as Instrumentation
  W->>E: Process message
  E->>A: Invoke handler
  E->>A: Read observables and state_after
  E->>E: Check query mutation
  E->>E: Normalize and measure state
  E->>DB: Commit state and message
  DB-->>E: Commit succeeds
  E->>I: Report post-commit events
  alt Subscriber raises
    I->>I: Report instrumentation.failed
    I-->>E: Contain reporting failure
  end
  E->>W: Signal wake-up
Loading

Reviews (3): Last reviewed commit: "fix: close the last hole in post-commit ..." | Re-trigger Greptile

Comment thread lib/solid_objects/executor.rb
Skipping JSON.generate when no byte limit is given also skipped the only
check that a string held valid bytes. An invalid-encoding value then
escaped validation and failed later as a JSON::GeneratorError inside the
commit transaction, after commit actions had run, on every retry.
normalize now checks every string and key it visits, so the typed
InvalidPayload raises at the call that staged the value. The check costs
part of the measured gain, which docs/benchmarks.md now records.

Report a committed turn without letting a subscriber fail it. Every
post-commit event ran outside a rescue, so a raising subscriber skipped
message.completed and then tried to fail a message whose claim the
commit had already destroyed. instrument_after_commit reports the
subscriber as solid_objects.instrumentation.failed and continues, which
is the isolation the Node runtime already applies to every event.

Name the event payload byte_count, matching solid-objects-js, so one
alert rule matches both runtimes. Reject a warn_state_bytes above
max_state_bytes, which could never fire.

Cover the two claims that had no test: a query whose observable mutates
state fails with InvalidActor, and a rolled back turn reports no size.
Both were confirmed against the code they guard. The benchmark now
silences the warning, because only one side of an A/B run can emit it.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Comment thread lib/solid_objects/instrumentation.rb
instrument_after_commit exists so reporting cannot fail a turn that
already committed, but its own fallback called the configured logger
outside any rescue. A logger that raises, or that does not answer error,
therefore escaped the helper, reached fail_message, and hit a claim the
commit had already destroyed.

The fallback now swallows its own failure, which is the end of the
reporting chain and has nothing left to report with.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

@cardmagic
cardmagic merged commit cd4d5c9 into main Aug 29, 2026
41 checks passed
@cardmagic
cardmagic deleted the perf/reduce-per-turn-state-copies branch August 29, 2026 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cut redundant per-turn state copies and correct the 5 MB state default

1 participant