Skip to content

perf: cut redundant per-turn state serialization - #33

Merged
cardmagic merged 3 commits into
mainfrom
perf/cut-redundant-state-serialization
Aug 29, 2026
Merged

perf: cut redundant per-turn state serialization#33
cardmagic merged 3 commits into
mainfrom
perf/cut-redundant-state-serialization

Conversation

@cardmagic

@cardmagic cardmagic commented Aug 29, 2026

Copy link
Copy Markdown
Owner

Closes #32.

What changed

The runtime built the whole actor state image 8 times for each committed
operation, and 9 times for a query. Two are necessary. Both counts are now 4.

1. normalizeJson no longer builds a string it discards

src/serialization.ts called JSON.stringify on every value, then used the
result only when maxBytes was given. actorState, deepCopy, and
stableJson all pass no limit.

2. initialStateFor computes the default state once

Every send and every hydration constructed a throwaway actor and serialized
its full default state. The constructor must not depend on external state, so
one cached image per validated definition is correct. The cache holds the
encoded image, so each caller parses a detached copy and cannot reach the
cache.

3. readObservables accepts the caller's state image

Both call sites sit next to a place where the runtime already holds the image,
so the image is now passed in. The query mutation check reads the committed
image instead of taking one of its own.

4. warnStateBytes

A soft threshold that defaults to 128 KB. A commit above it reports one
solid_objects.state.large instrumentation event with the actor type, the
actor ID, the byte count, and the threshold. The event holds no application
state, and the runtime measures the size only when an instrumentation
callback is configured. maxStateBytes keeps its 5 MB hard default.

Measured

pnpm run benchmark:large-state is new: one actor, one increment(),
sequential turns. Apple M5, macOS 26.6, Node.js 24.18.0, SQLite 3.53.1 through
node:sqlite. Median of three runs of 300 operations.

Persisted state Before ms/op Before ops/s After ms/op After ops/s Gain
0 KB 1.85 540 1.60 625 1.2x
16 KB 2.12 472 1.64 611 1.3x
128 KB 4.01 250 2.53 395 1.6x
1 MB 19.11 52 9.32 107 2.1x

One correction to the issue

The issue sets Goal 1 as "8 to 2". Four is the floor while the guard stays
exact. The caller can supply the image from before observables() runs, but
the image from after it must be a fresh read, or the guard cannot see a
mutation. The issue's own acceptance criterion says the same: "The guard still
compares the state at the moment observables() runs, not an earlier image."
The turn holds only two necessary images, and the operation runs between them,
so neither one can serve as the after-image for either guard. Every acceptance
criterion in the issue is met; only that headline number changes.

Tests

test/state-serialization.test.ts is new and was written first. Against main
its traversal tests report 8 and 9, which reproduces the issue's evidence
table. It covers:

  • normalizeJson does not encode without a byte limit, and still raises
    PayloadTooLarge at one.
  • initialStateFor computes once per validated definition and returns a
    detached copy.
  • One committed operation and one query each traverse the state 4 times.
  • The warning event fires once above the threshold, carries the actor type,
    actor ID, and byte count, holds no application state, and stays silent below.

The existing observables and query guard tests in test/correctness.test.ts
and test/realtime.test.ts pass unchanged.

pnpm run check passes. pnpm test reports 1038 passed. Two failures remain
in .claude/worktrees/, which are stale gitignored checkouts that vitest
scans; they fail on main too and no file in this branch touches them.

One behavior change to note

Building the rollback image before the first readObservables call means an
observables() implementation that mutates state now has that mutation rolled
back in the cached activation, where before the turn failed without a rollback.

Parity

docs/parity.md records the new warning and points at
cardmagic/solid-objects-ruby#57 for the Ruby port, which the issue tracks
separately.

Release

This PR is 0.14.4. package.json, src/version.ts, and the dated
CHANGELOG.md section are updated, and scripts/release-notes.mjs 0.14.4
resolves that section. The CHANGELOG.md entry also carries the README and
docs prose that was already sitting in Unreleased, because it ships in the
same version.

A patch matches this repository's own precedent. 0.13.1 shipped a new
configuration option, a new instrumentation event, and a new benchmark as a
patch, which is the shape of this change. The lockfile does not record the
package's own version, so it needs no refresh.

Follow the rest of docs/releasing.md after merge: run the full release
command list, then tag v0.14.4 on main.

The runtime built the whole actor state image eight times for each
committed operation, and nine times for a query. Two are necessary. At
the default state limit the repeated work dominated the turn.

Pass the image the commit path already holds to the observables guard,
and read the committed image for the query mutation check. The guard is
unchanged: it still reads the state after observables() returns, because
only that read sees a mutation. Both counts are now four.

Compute the default state once for each registered class. Every send and
every hydration built a throwaway actor and serialized its defaults. The
constructor must not depend on external state, so one cached image per
validated definition is correct. Each caller parses a detached copy.

Stop building the string normalizeJson discards when no byte limit is
given, which is every call from actorState, deepCopy, and stableJson.

Add warnStateBytes, because maxStateBytes advertised an operating point
the runtime does not support. The 5 MB hard default stands, and a commit
above the 128 KB soft threshold now reports one instrumentation event
with the actor type, actor ID, and byte count, so an operator learns the
constraint before an application meets it.

Closes #32
@greptile-apps

greptile-apps Bot commented Aug 29, 2026

Copy link
Copy Markdown

Greptile Summary

The PR reduces redundant actor-state serialization, caches detached default-state images, and adds instrumentation for large committed states.

  • Reuses existing state images for query and observables mutation guards.
  • Adds the validated warnStateBytes configuration and emits warnings only after successful commits.
  • Adds serialization regression tests, large-state benchmarks, documentation, and the 0.14.4 release metadata.

Confidence Score: 5/5

The PR appears safe to merge.

No blocking failure remains; both previously reported issues are fixed at the current head.

Important Files Changed

Filename Overview
src/runtime.ts Reuses stable state images during turns and emits large-state instrumentation after durable completion; the previously reported ordering defect is fixed.
src/serialization.ts Avoids JSON encoding when no byte limit applies and uses the requested early-return structure.
src/definition.ts Caches encoded default state per validated definition while returning detached state objects.
src/configuration.ts Adds a validated 128 KB default warning threshold alongside the existing hard state limit.
test/state-serialization.test.ts Covers serialization traversal counts, detached default-state caching, warning contents, thresholds, and failed-commit suppression.

Reviews (2): Last reviewed commit: "fix: report large state only after the c..." | Re-trigger Greptile

Comment thread src/runtime.ts Outdated
Comment thread src/serialization.ts Outdated
A new configuration option, a new instrumentation event, and a new
benchmark match what 0.13.1 shipped as a patch, so this is a patch.
The warning ran before repository.complete, so a turn that lost its
activation or failed in a commit action reported a size for state the
runtime rolled back and never wrote.

Use an early return for the byte limit in normalizeJson.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai review

Both review comments are addressed in b66d23d:

  • P1: warnAboutLargeState moved after repository.complete returns, with a regression test that fails against the old order.
  • P2: early return for the byte limit in normalizeJson.

Please re-review at b66d23d.

@cardmagic
cardmagic merged commit 49d4eca into main Aug 29, 2026
19 checks passed
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 serialization and correct the 5 MB state default

1 participant