perf: cut one state copy per committed turn - #58
Merged
Conversation
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.
Owner
Author
|
@greptileai review |
Greptile SummaryThe 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.
Confidence Score: 5/5The 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
Sequence DiagramsequenceDiagram
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
Reviews (3): Last reviewed commit: "fix: close the last hole in post-commit ..." | Re-trigger Greptile |
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.
Owner
Author
|
@greptileai review |
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.
Owner
Author
|
@greptileai review |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #57.
What changed
Executor#callbuilt three full copies of the actor state per committedturn. It now builds two. One after image answers whether the state changed,
whether a synchronous query mutated state, and what the committed row holds.
Serialization.dumpencoded every value to measure it, then discarded thestring whenever the caller passed no
max_bytes, which most call sites do.It now encodes only when a limit applies.
dump_with_byte_sizereturns theencoded size beside the normalized value, so the commit that reports the
size pays for one encoding.
state_size_warning_bytesis a new soft threshold, 64 KB by default. Acommit above it reports
solid_objects.state.largewith 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_byteskeeps its 5 MB default.benchmark/state_size.rbreports committed throughput at each state size.Version 0.14.3.
Effects to review
Serialization.dump_with_byte_sizeis new.Serialization.dumpkeeps its signature and its return value.
the observables are read. A query whose observable block mutates state now
fails with
InvalidActor; before, that mutation was committed silently.dumpwithout amax_bytesno longer rejects a value thatJSON 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.
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.
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_bytesdescribes a limit rather than an operating point.Observed failures before the fix
Every test was written first and watched fail.
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_messagenow holdsthat behavior, and it passed against the tree before the change.
Validation
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.