From 3528307d6e31a8669073a39c7149fcf41bfe681c Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 29 Aug 2026 08:47:54 -0700 Subject: [PATCH 1/3] perf: cut redundant per-turn state serialization 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 --- CHANGELOG.md | 30 ++++ benchmarks/large-state.ts | 129 +++++++++++++++++ benchmarks/shared.ts | 18 +++ docs/benchmarks.md | 37 +++++ docs/configuration.md | 10 ++ docs/parity.md | 1 + docs/state-and-lifecycle.md | 31 ++++- package.json | 1 + src/configuration.ts | 3 + src/definition.ts | 17 ++- src/runtime.ts | 62 +++++++-- src/serialization.ts | 12 +- test/state-serialization.test.ts | 230 +++++++++++++++++++++++++++++++ 13 files changed, 562 insertions(+), 19 deletions(-) create mode 100644 benchmarks/large-state.ts create mode 100644 test/state-serialization.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8327373..c6469b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,36 @@ ## Unreleased +- Cut the per-turn state traversals from eight to four. The runtime built the + whole state image eight times for each committed operation, and nine times + for a query, where two are necessary. The commit path now passes the image it + already holds to the observables guard, and the query check reads the + committed image instead of taking its own. The guard is unchanged: it still + reads the state after `observables()` returns, because only that read sees a + mutation. +- Compute the default state once for each registered actor class. 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. Each caller receives a + detached copy. +- Stop building a string that `normalizeJson` discards. It called + `JSON.stringify` on every value, then used the result only when a byte limit + was given. `actorState`, `deepCopy`, and `stableJson` all pass no limit. +- Measured on an Apple M5 with SQLite: 1.2x throughput at 0 KB of state, 1.3x + at 16 KB, 1.6x at 128 KB, and 2.1x at 1 MB. See + [Large state](docs/benchmarks.md#large-state). +- Add `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, which fails the turn. Throughput at that size is about one operation + per second, so the warning names the constraint before an application meets + it. +- Add a `large-state` benchmark scenario, `pnpm run benchmark:large-state`, + that reports operations per second at 0 KB, 16 KB, 128 KB, and 1 MB, and + document the measured curve in `docs/state-and-lifecycle.md`. + - Align the use-case claims with the Ruby gem. The README table sold per-key rate limits, while the Ruby fit guide called a rate limiter an anti-pattern. Both projects now draw one line: a low-rate quota that a reminder refills diff --git a/benchmarks/large-state.ts b/benchmarks/large-state.ts new file mode 100644 index 0000000..69e5570 --- /dev/null +++ b/benchmarks/large-state.ts @@ -0,0 +1,129 @@ +import { readFile } from "node:fs/promises" +import { mkdtemp, rm } from "node:fs/promises" +import { cpus, platform, release, tmpdir } from "node:os" +import { join } from "node:path" +import { performance } from "node:perf_hooks" +import type { MessageReference } from "solid-objects" +import { LargeStateCounter, benchmarkRuntime } from "./shared.ts" + +const sizes = option("sizes", "0,16384,131072,1048576") + .split(",") + .map((value) => nonNegativeInteger(value, "sizes")) +const operations = positiveInteger(option("operations", "50"), "operations") +const warmupOperations = positiveInteger(option("warmup", "5"), "warmup") + +const temporaryDirectory = await mkdtemp(join(tmpdir(), "solid-objects-large-state-")) +const databasePath = join(temporaryDirectory, "large-state.sqlite3") +const tableNamePrefix = `solid_objects_large_state_${process.pid}_` + +try { + const packageMetadata = JSON.parse( + await readFile(new URL("../package.json", import.meta.url), "utf8"), + ) as { version: string } + const results = [] + for (const size of sizes) results.push(await measure(size)) + + process.stdout.write( + `${JSON.stringify( + { + measuredAt: new Date().toISOString(), + packageVersion: packageMetadata.version, + runtime: { + node: process.version, + platform: `${platform()} ${release()}`, + cpu: cpus()[0]?.model ?? "unknown", + logicalCpus: cpus().length, + }, + database: { adapter: "sqlite", path: databasePath }, + methodology: { + sizes, + operations, + warmupOperations, + concurrency: 1, + shape: "one actor, one increment operation, sequential turns", + latencyBoundary: "durable enqueue through committed result", + }, + results, + }, + null, + 2, + )}\n`, + ) +} finally { + await rm(temporaryDirectory, { recursive: true }) +} + +async function measure(size: number) { + const runtime = benchmarkRuntime({ + database: "sqlite", + databasePath, + tableNamePrefix: `${tableNamePrefix}${size}_`, + workerCount: 1, + }) + const shutdown = new AbortController() + let running: Promise | undefined + try { + await runtime.install() + running = runtime.run(shutdown.signal) + const reference = runtime.ref(LargeStateCounter, `large-state-${size}`) + if (size > 0) await waitForResult(await reference.send.resize({ size })) + for (let index = 0; index < warmupOperations; index += 1) { + await waitForResult(await reference.send.increment()) + } + + const startedAt = performance.now() + for (let index = 0; index < operations; index += 1) { + await waitForResult(await reference.send.increment()) + } + const elapsedMilliseconds = performance.now() - startedAt + + return { + stateBytes: size, + operations, + millisecondsPerOperation: round(elapsedMilliseconds / operations), + throughputPerSecond: round((operations * 1_000) / elapsedMilliseconds), + } + } finally { + shutdown.abort() + await running + await runtime.testing.reset() + await runtime.close() + } +} + +async function waitForResult(message: MessageReference): Promise { + const deadline = performance.now() + 120_000 + while (performance.now() < deadline) { + if ((await message.result()) !== undefined) return + await new Promise((resolve) => setTimeout(resolve, 1)) + } + throw new Error(`message ${message.id} did not complete within 120 seconds`) +} + +function option(name: string, fallback: string): string { + const index = process.argv.indexOf(`--${name}`) + if (index === -1) return fallback + const value = process.argv[index + 1] + if (!value) throw new TypeError(`--${name} requires a value`) + return value +} + +function positiveInteger(value: string, name: string): number { + const number = Number(value) + if (!Number.isSafeInteger(number) || number < 1) { + throw new TypeError(`${name} must be a positive integer`) + } + return number +} + +function nonNegativeInteger(value: string, name: string): number { + const number = Number(value) + if (!Number.isSafeInteger(number) || number < 0) { + throw new TypeError(`${name} must be a non-negative integer`) + } + return number +} + +function round(value: number): number { + return Math.round(value * 100) / 100 +} diff --git a/benchmarks/shared.ts b/benchmarks/shared.ts index 72d0fe1..55a6ac5 100644 --- a/benchmarks/shared.ts +++ b/benchmarks/shared.ts @@ -22,6 +22,23 @@ export class BenchmarkCounter extends Actor { } } +export class LargeStateCounter extends Actor { + static override readonly actorType = "LargeStateCounter" + + count = 0 + payload = "" + + resize({ size }: { size: number }): number { + this.payload = "s".repeat(size) + return this.payload.length + } + + increment(): number { + this.count += 1 + return this.count + } +} + export function benchmarkRuntime(options: { database: BenchmarkDatabase databasePath?: string @@ -42,6 +59,7 @@ export function benchmarkRuntime(options: { authorizeQuery: () => true, }) runtime.register(BenchmarkCounter) + runtime.register(LargeStateCounter) return runtime } diff --git a/docs/benchmarks.md b/docs/benchmarks.md index dd06223..68ccd47 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -43,6 +43,42 @@ The local wake-up keeps the one-process path prompt after backoff. The polling-only row is the explicit tradeoff: use PostgreSQL notifications or optional Redis Pub/Sub when separate processes need low-latency delivery. +## Large state + +The large-state harness measures how the committed state size changes +throughput. It runs one actor and one `increment()` operation with sequential +turns, so each row isolates the per-turn serialization and row write: + +```bash +pnpm run benchmark:large-state +``` + +Use `--sizes`, `--operations`, and `--warmup` to change the recorded dataset. + +Measured on August 29, 2026 on an Apple M5, macOS 26.6, Node.js 24.18.0, and +SQLite 3.53.1 through `node:sqlite` on a temporary file. Each row is the median +of three runs of 300 measured operations. The before column used the `0.14.3` +tree; the after column used the same tree with the redundant per-turn state +serialization removed. + +| 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 | + +The before tree traversed the whole state eight times for each committed +operation, and nine times for a query. The after tree traverses it four times: +one image for rollback and comparison, one committed image, and one read for +each of the two observables guards. The remaining cost at 1 MB is the database +write of a large row, which the whole-image commit model cannot avoid. + +The curve, not the ratio, is the operating instruction. Throughput falls by +about 6x between 0 KB and 1 MB even after the change. Keep one actor's state +small. See +[State size and throughput](state-and-lifecycle.md#state-size-and-throughput). + ## Scenarios - `warm-hot`: all operations target one previously created identity. @@ -180,6 +216,7 @@ much as the database, so the tables above replace them. - Docker Desktop costs between 1.0x and 7.8x of the native throughput. The tables above use native servers. See [Virtualization cost](#virtualization-cost). - The payload is a small counter, not a representative application state size. + See [Large state](#large-state) for the measured effect of state size. - The harness measures default durability settings and one client concurrency. - Hot-identity results deliberately include serialization and cannot be scaled by adding workers. diff --git a/docs/configuration.md b/docs/configuration.md index 245e38a..3afe908 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -19,6 +19,7 @@ through `runtime.ref(ActorClass, actorId)`. Both validate options immediately. | `maxMailboxLength` | `10_000` | Positive maximum ready and claimed messages for one actor. | | `maxPayloadBytes` | `1_048_576` | Positive byte limit for operation arguments and personalized payloads. | | `maxStateBytes` | `5_242_880` | Positive persisted actor-state byte limit. | +| `warnStateBytes` | `131_072` | Positive persisted actor-state byte threshold that reports one warning. | | `maxResultBytes` | `1_048_576` | Positive operation-result byte limit. | | `maxAttempts` | `5` | Positive maximum operation, effect, and broadcast attempts. | | `maxMessagesPerActivationPass` | `50` | Positive integer turn budget before fairness yield. | @@ -35,6 +36,15 @@ names enter the broadcast outbox. `retryDelayMilliseconds` should return a non-negative finite number; an invalid application callback will fail the affected failure path rather than schedule an invalid timestamp. +`maxStateBytes` is a hard limit that fails the turn. `warnStateBytes` is a soft +threshold that keeps the turn. A commit above the threshold 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. +The runtime measures the size only when an `instrumentation` callback is +configured. Throughput falls as the persisted state grows, so treat the warning +as an instruction to divide the actor. See +[State size and throughput](state-and-lifecycle.md#state-size-and-throughput). + ## Runtime roles and supervision | Option | Default | Contract | diff --git a/docs/parity.md b/docs/parity.md index 673485c..c64ccd0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -75,6 +75,7 @@ such boundary between a gem and its dependents. | CLI | Native | The packaged executable loads an application runtime and exposes start, diagnostics, processes, dead letters, reminders, and explicit retention pruning as JSON. | | Operator dashboard | Native | The opt-in `solid-objects/web` export provides Fetch and Node/Connect mounting, authorized runtime views and actions, session-backed CSRF, filtering, paging, charts, and immutable extension hooks. Matches the Ruby dashboard's own documented limits: no audit trail of admin actions, dead-letter retry is one at a time, and pause sets a flag rather than interrupting an in-flight turn. | | Structured instrumentation | Native | An isolated transport-neutral sink emits immutable lifecycle metadata and structurally excludes application payloads. | +| Large committed state warning | Native | `warnStateBytes` reports one `solid_objects.state.large` event, holding the actor type, actor ID, byte count, and threshold, when a committed image passes a 128 KB soft threshold. The event holds no application state. The Ruby gem shares the 5 MB hard default and has no soft threshold yet; the Ruby port is tracked at cardmagic/solid-objects-ruby#57. | | Public test helper | Native | `runtime.testing` provides role-selective deterministic draining, explicit-time due-reminder execution, and dependency-ordered reset without relying on cascades. | ## Databases and wake-up diff --git a/docs/state-and-lifecycle.md b/docs/state-and-lifecycle.md index 4d89fca..51ccc9a 100644 --- a/docs/state-and-lifecycle.md +++ b/docs/state-and-lifecycle.md @@ -18,7 +18,36 @@ fields, then walks its prototype chain to discover methods and getters. The constructor must establish every persisted field and must not depend on external state. Solid Objects invokes it at four points: class validation, -default creation, state hydration, and snapshot projection. +default creation, state hydration, and snapshot projection. Because the +constructor must not depend on external state, the runtime computes the default +state once for each registered class and gives each caller a detached copy. + +## State size and throughput + +Solid Objects commits the whole state image on each turn. The turn therefore +reads, encodes, and writes every persisted field, and its cost grows with the +size of the state rather than with the size of the change. + +Measured on August 29, 2026 on an Apple M5, macOS 26.6, Node.js 24.18.0, and +SQLite 3.53.1 through `node:sqlite`. One actor, one `increment()` operation, +sequential turns, 300 measured operations per row, and the `0.14.3` source tree +with the serialization changes applied. + +| Persisted state | ms per operation | Operations per second | +| --------------: | ---------------: | --------------------: | +| 0 KB | 1.60 | 625 | +| 16 KB | 1.64 | 611 | +| 128 KB | 2.53 | 395 | +| 1 MB | 9.32 | 107 | + +These are developer-laptop numbers. They show the shape of the curve, not a +capacity guarantee. See [Benchmarks](benchmarks.md#large-state) for the harness +and the earlier numbers. + +Keep one actor's state small, and divide a large state across more identities. +`warnStateBytes` reports one `solid_objects.state.large` instrumentation event +when a committed image passes its threshold, which defaults to 128 KB. +`maxStateBytes` is the hard limit, and it fails the turn. ## Observable broadcast modes diff --git a/package.json b/package.json index 0814922..7bb8668 100644 --- a/package.json +++ b/package.json @@ -112,6 +112,7 @@ "test:watch": "vitest", "benchmark": "pnpm run build && node benchmarks/run.ts", "benchmark:idle": "pnpm run build && node benchmarks/idle.ts", + "benchmark:large-state": "pnpm run build && node benchmarks/large-state.ts", "pack:check": "pnpm pack --dry-run && node scripts/check-package.mjs", "prepack": "pnpm run build" }, diff --git a/src/configuration.ts b/src/configuration.ts index 014ad0f..ed27fe9 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -48,6 +48,7 @@ export interface SolidObjectsConfiguration { maxMailboxLength?: number maxPayloadBytes?: number maxStateBytes?: number + warnStateBytes?: number maxResultBytes?: number maxAttempts?: number maxMessagesPerActivationPass?: number @@ -122,6 +123,7 @@ export function buildSettings(configuration: SolidObjectsConfiguration): Runtime maxMailboxLength: configuration.maxMailboxLength ?? 10_000, maxPayloadBytes: configuration.maxPayloadBytes ?? 1_048_576, maxStateBytes: configuration.maxStateBytes ?? 5_242_880, + warnStateBytes: configuration.warnStateBytes ?? 131_072, maxResultBytes: configuration.maxResultBytes ?? 1_048_576, maxAttempts: configuration.maxAttempts ?? 5, maxMessagesPerActivationPass: configuration.maxMessagesPerActivationPass ?? 50, @@ -210,6 +212,7 @@ function validateSettings(settings: RuntimeSettings): void { maxMailboxLength: settings.maxMailboxLength, maxPayloadBytes: settings.maxPayloadBytes, maxStateBytes: settings.maxStateBytes, + warnStateBytes: settings.warnStateBytes, maxResultBytes: settings.maxResultBytes, maxAttempts: settings.maxAttempts, maxActivationDurationMilliseconds: settings.maxActivationDurationMilliseconds, diff --git a/src/definition.ts b/src/definition.ts index 2e30603..7936282 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -116,8 +116,23 @@ export function validateDefinition( }) } +const defaultStates = new WeakMap() + +/** + * A constructor must not depend on external state, so one default image per + * validated definition is correct. The cache holds the encoded image, so every + * call parses a detached copy that a caller cannot use to reach the cache. + */ export function initialStateFor(definition: ValidatedActorDefinition): JsonObject { - return actorState(new definition.actorClass("__solid_objects_defaults__"), definition.stateKeys) + const cached = defaultStates.get(definition) + if (cached !== undefined) return JSON.parse(cached) as JsonObject + + const state = actorState( + new definition.actorClass("__solid_objects_defaults__"), + definition.stateKeys, + ) + defaultStates.set(definition, JSON.stringify(state)) + return state } export function actorState(actor: Actor, stateKeys?: readonly string[]): JsonObject { diff --git a/src/runtime.ts b/src/runtime.ts index fc9c3bc..15d09ec 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -93,7 +93,14 @@ import { type ResumeReminderOptions, } from "./reminder-administration.js" import { RetentionManager, type RetentionOptions, type RetentionResult } from "./retention.js" -import { deepCopy, jsonObject, normalizeJson, readonlyCopy, stableJson } from "./serialization.js" +import { + deepCopy, + jsonObject, + normalizeJson, + readonlyCopy, + stableJson, + utf8ByteLength, +} from "./serialization.js" import { installSchema } from "./schema.js" import type { ActorIdentifier, @@ -786,7 +793,7 @@ export class SolidObjectsRuntime { actorId: options.actorId, instanceId: instance?.id ?? "0", revision: String(instance?.state_revision ?? 0), - ...broadcastProjection(this.readObservables(actor, registered.definition)), + ...broadcastProjection(this.readObservables({ actor, definition: registered.definition })), }) } @@ -1153,9 +1160,9 @@ export class SolidObjectsRuntime { const startedAt = Date.now() this.emitInstrumentation("message.started", messageInstrumentation(turn.message)) try { - const oldObservables = this.readObservables(actor, definition) stateBefore = deepCopy(actorState(actor, definition.stateKeys)) const before = stableJson(stateBefore) + const oldObservables = this.readObservables({ actor, definition, stateJson: before }) const query = this.isQuery(definition, turn.message.operation) const argumentsValue = jsonObject(JSON.parse(turn.message.arguments)) const rawResult = await withActorContext( @@ -1167,7 +1174,11 @@ export class SolidObjectsRuntime { return actor.invoke(turn.message.operation, argumentsValue) }, ) - if (query && stableJson(actorState(actor, definition.stateKeys)) !== before) { + const committedState = jsonObject(actorState(actor, definition.stateKeys), { + maxBytes: this.settings.maxStateBytes, + }) + const committed = stableJson(committedState) + if (query && committed !== before) { throw new QueryMutatedState(`query ${turn.message.operation} mutated actor state`) } if (query && actor.hasIntents()) { @@ -1176,10 +1187,8 @@ export class SolidObjectsRuntime { const result = normalizeJson(rawResult === undefined ? null : rawResult, { maxBytes: this.settings.maxResultBytes, }) - const committedState = jsonObject(actorState(actor, definition.stateKeys), { - maxBytes: this.settings.maxStateBytes, - }) - const observables = this.readObservables(actor, definition) + this.warnAboutLargeState(turn.message, committed) + const observables = this.readObservables({ actor, definition, stateJson: committed }) const changedObservableNames = Object.keys(observables.values).filter( (name) => stableJson(observables.values[name]) !== stableJson(oldObservables.values[name]) || @@ -1188,7 +1197,7 @@ export class SolidObjectsRuntime { const changedProjection = selectBroadcastProjection(observables, changedObservableNames) const broadcastProjectionValue = changedObservableNames.length > 0 || - (Object.keys(definition.payloads).length > 0 && stableJson(committedState) !== before) + (Object.keys(definition.payloads).length > 0 && committed !== before) ? changedProjection : undefined renewalController.abort() @@ -1809,11 +1818,18 @@ export class SolidObjectsRuntime { ) } - private readObservables( - actor: Actor, - definition: ValidatedActorDefinition, - ): ObservableProjection { - const stateBefore = stableJson(actorState(actor, definition.stateKeys)) + /** + * `stateJson` is the stable encoding of the state at the call, which the + * commit path already holds. The guard still reads the state after + * `observables()` returns, because only that read sees a mutation. + */ + private readObservables(options: { + actor: Actor + definition: ValidatedActorDefinition + stateJson?: string + }): ObservableProjection { + const { actor, definition } = options + const stateBefore = options.stateJson ?? stableJson(actorState(actor, definition.stateKeys)) const intentCount = actor.intentCount() const values = withActorProjection({ actor, runtime: this }, () => actor.observableValues()) if ( @@ -1939,6 +1955,24 @@ export class SolidObjectsRuntime { return definition.queries.includes(name) } + /** + * The event reports the size of the committed image, never the image itself. + * A stable encoding holds the same characters as the stored encoding, so its + * byte count is the byte count of the row the runtime writes. + */ + private warnAboutLargeState(message: MessageRow, committed: string): void { + if (!this.settings.instrumentation) return + const byteCount = utf8ByteLength(committed) + if (byteCount <= this.settings.warnStateBytes) return + + this.emitInstrumentation("state.large", { + actorType: message.actor_type, + actorId: message.actor_id, + byteCount, + thresholdBytes: this.settings.warnStateBytes, + }) + } + private wakeUp(role: WakeUpRole): void { try { Promise.resolve(this.settings.wakeUp.notify(role)).catch((error: unknown) => { diff --git a/src/serialization.ts b/src/serialization.ts index 23b6af1..496989c 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -6,15 +6,21 @@ const utf8Encoder = new TextEncoder() export function normalizeJson(value: unknown, options: { maxBytes?: number } = {}): JsonValue { const normalized = normalize(value, 0) - const encoded = JSON.stringify(normalized) - if (options.maxBytes !== undefined && utf8Encoder.encode(encoded).length > options.maxBytes) { - throw new PayloadTooLarge(`serialized value exceeds ${options.maxBytes} bytes`) + if (options.maxBytes !== undefined) { + const encoded = JSON.stringify(normalized) + if (utf8ByteLength(encoded) > options.maxBytes) { + throw new PayloadTooLarge(`serialized value exceeds ${options.maxBytes} bytes`) + } } return normalized } +export function utf8ByteLength(value: string): number { + return utf8Encoder.encode(value).length +} + export function jsonObject( value: unknown, options: { maxBytes?: number } = {}, diff --git a/test/state-serialization.test.ts b/test/state-serialization.test.ts new file mode 100644 index 0000000..8f35c63 --- /dev/null +++ b/test/state-serialization.test.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { Actor } from "../src/actor.js" +import { buildSettings, type InstrumentationEvent } from "../src/configuration.js" +import { sqlite } from "../src/database/sqlite.js" +import { initialStateFor, validateDefinition } from "../src/definition.js" +import { PayloadTooLarge } from "../src/errors.js" +import { configure, type SolidObjectsRuntime } from "../src/runtime.js" +import { normalizeJson } from "../src/serialization.js" +import type { SolidObjectsConfiguration } from "../src/configuration.js" + +const stateReads = { count: 0 } + +class TracedStateActor extends Actor { + static override readonly actorType = "TracedStateActor" + + count = 0 + + constructor(actorId?: string) { + super(actorId) + let payload: string = "traced" + Object.defineProperty(this, "payload", { + enumerable: true, + configurable: true, + get: () => { + stateReads.count += 1 + return payload + }, + set: (value: string) => { + payload = value + }, + }) + } + + increment(): number { + this.count += 1 + return this.count + } +} + +class DefaultsActor extends Actor { + static override readonly actorType = "DefaultsActor" + static constructions = 0 + + count = 0 + items: string[] = [] + + constructor(actorId?: string) { + super(actorId) + DefaultsActor.constructions += 1 + } +} + +class LargeStateActor extends Actor { + static override readonly actorType = "LargeStateActor" + + payload = "" + + grow({ size }: { size: number }): number { + this.payload = "s".repeat(size) + return this.payload.length + } +} + +let runtime: SolidObjectsRuntime | undefined + +afterEach(async () => { + await runtime?.close() + runtime = undefined +}) + +describe("normalizeJson byte limit", () => { + it("does not encode the value when no byte limit is given", () => { + const stringify = vi.spyOn(JSON, "stringify") + try { + normalizeJson({ nested: { count: 1, items: ["one", "two"] } }) + expect(stringify).not.toHaveBeenCalled() + } finally { + stringify.mockRestore() + } + }) + + it("raises PayloadTooLarge above the configured limit", () => { + expect(() => normalizeJson({ payload: "x".repeat(64) }, { maxBytes: 16 })).toThrow( + PayloadTooLarge, + ) + }) + + it("returns the normalized value below the configured limit", () => { + expect(normalizeJson({ count: 1 }, { maxBytes: 1_024 })).toEqual({ count: 1 }) + }) +}) + +describe("initialStateFor memoization", () => { + it("computes the default state once for one validated definition", () => { + const definition = validateDefinition(DefaultsActor) + DefaultsActor.constructions = 0 + + initialStateFor(definition) + initialStateFor(definition) + initialStateFor(definition) + + expect(DefaultsActor.constructions).toBe(1) + }) + + it("returns a detached copy that a caller cannot use to mutate the cache", () => { + const definition = validateDefinition(DefaultsActor) + const first = initialStateFor(definition) as { count: number; items: string[] } + first.count = 99 + first.items.push("mutated") + + expect(initialStateFor(definition)).toEqual({ count: 0, items: [] }) + }) + + it("computes the default state again for a separate validated definition", () => { + DefaultsActor.constructions = 0 + const first = validateDefinition(DefaultsActor) + const second = validateDefinition(DefaultsActor) + const constructionsAfterValidation = DefaultsActor.constructions + + initialStateFor(first) + initialStateFor(second) + + expect(DefaultsActor.constructions).toBe(constructionsAfterValidation + 2) + }) +}) + +describe("per-turn state traversals", () => { + it("traverses the whole state twice for the commit and once for each observables guard", async () => { + runtime = configuredRuntime() + runtime.register(TracedStateActor) + await runtime.install() + const reference = runtime.ref(TracedStateActor, "traced") + await reference.send.increment() + await runtime.worker().runUntilIdle() + + stateReads.count = 0 + await reference.send.increment() + await runtime.worker().runUntilIdle() + + expect(stateReads.count).toBe(4) + }) + + it("reuses the committed image for the query mutation check", async () => { + runtime = configuredRuntime() + runtime.register(TracedStateActor) + await runtime.install() + const reference = runtime.ref(TracedStateActor, "traced-query") + await reference.send.increment() + await runtime.worker().runUntilIdle() + + stateReads.count = 0 + expect(await reference.count).toBe(1) + + expect(stateReads.count).toBe(4) + }) +}) + +describe("large committed state warning", () => { + it("defaults warnStateBytes to 131072", () => { + expect(buildSettings({ database: sqlite({ path: ":memory:" }) }).warnStateBytes).toBe(131_072) + }) + + it("emits one instrumentation event with the actor type, actor id, and byte count", async () => { + const events: InstrumentationEvent[] = [] + runtime = configuredRuntime({ + warnStateBytes: 256, + instrumentation: (event) => events.push(event), + }) + runtime.register(LargeStateActor) + await runtime.install() + + await runtime.ref(LargeStateActor, "wide").send.grow({ size: 1_024 }) + await runtime.worker().runUntilIdle() + + const warnings = events.filter((event) => event.name === "solid_objects.state.large") + expect(warnings).toHaveLength(1) + expect(warnings[0]?.attributes).toMatchObject({ + actorType: "LargeStateActor", + actorId: "wide", + thresholdBytes: 256, + }) + expect(Number(warnings[0]?.attributes.byteCount)).toBeGreaterThan(1_024) + }) + + it("keeps application state out of the warning event", async () => { + const events: InstrumentationEvent[] = [] + runtime = configuredRuntime({ + warnStateBytes: 256, + instrumentation: (event) => events.push(event), + }) + runtime.register(LargeStateActor) + await runtime.install() + + await runtime.ref(LargeStateActor, "quiet").send.grow({ size: 1_024 }) + await runtime.worker().runUntilIdle() + + const warning = events.find((event) => event.name === "solid_objects.state.large") + expect(JSON.stringify(warning?.attributes)).not.toContain("ss") + }) + + it("stays silent below the threshold", async () => { + const events: InstrumentationEvent[] = [] + runtime = configuredRuntime({ + warnStateBytes: 131_072, + instrumentation: (event) => events.push(event), + }) + runtime.register(LargeStateActor) + await runtime.install() + + await runtime.ref(LargeStateActor, "small").send.grow({ size: 16 }) + await runtime.worker().runUntilIdle() + + expect(events.filter((event) => event.name === "solid_objects.state.large")).toHaveLength(0) + }) +}) + +function configuredRuntime( + overrides: Partial = {}, +): SolidObjectsRuntime { + return configure({ + database: sqlite({ path: ":memory:" }), + authorizeMessage: () => true, + authorizeQuery: () => true, + authorizeDestroy: () => true, + pollingIntervalMilliseconds: 1, + syncPollingIntervalMilliseconds: 1, + maxAttempts: 1, + ...overrides, + }) +} From 8b93e54a0449caa93d695d341ddc58874695aa63 Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 29 Aug 2026 08:50:37 -0700 Subject: [PATCH 2/3] chore: release 0.14.4 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. --- CHANGELOG.md | 2 +- docs/benchmarks.md | 4 ++-- docs/state-and-lifecycle.md | 4 ++-- package.json | 2 +- src/version.ts | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6469b1..783f75b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## 0.14.4 - 2026-08-29 - Cut the per-turn state traversals from eight to four. The runtime built the whole state image eight times for each committed operation, and nine times diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 68ccd47..33fded5 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -58,8 +58,8 @@ Use `--sizes`, `--operations`, and `--warmup` to change the recorded dataset. Measured on August 29, 2026 on an Apple M5, macOS 26.6, Node.js 24.18.0, and SQLite 3.53.1 through `node:sqlite` on a temporary file. Each row is the median of three runs of 300 measured operations. The before column used the `0.14.3` -tree; the after column used the same tree with the redundant per-turn state -serialization removed. +tree; the after column used the `0.14.4` tree, which removes the redundant +per-turn state serialization. | Persisted state | Before ms/op | Before ops/s | After ms/op | After ops/s | Gain | | --------------: | -----------: | -----------: | ----------: | ----------: | ---: | diff --git a/docs/state-and-lifecycle.md b/docs/state-and-lifecycle.md index 51ccc9a..553a2db 100644 --- a/docs/state-and-lifecycle.md +++ b/docs/state-and-lifecycle.md @@ -30,8 +30,8 @@ size of the state rather than with the size of the change. Measured on August 29, 2026 on an Apple M5, macOS 26.6, Node.js 24.18.0, and SQLite 3.53.1 through `node:sqlite`. One actor, one `increment()` operation, -sequential turns, 300 measured operations per row, and the `0.14.3` source tree -with the serialization changes applied. +sequential turns, 300 measured operations per row, and the `0.14.4` source +tree. | Persisted state | ms per operation | Operations per second | | --------------: | ---------------: | --------------------: | diff --git a/package.json b/package.json index 7bb8668..f33535a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "solid-objects", - "version": "0.14.3", + "version": "0.14.4", "description": "Race-free realtime state per application identity, backed by your SQL database", "type": "module", "license": "MIT", diff --git a/src/version.ts b/src/version.ts index 0d29b78..d2ef35b 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1 +1 @@ -export const VERSION = "0.14.3" +export const VERSION = "0.14.4" From b66d23ddb31f722f808b934b2b4b088a4a25cf7c Mon Sep 17 00:00:00 2001 From: Lucas Carlson Date: Sat, 29 Aug 2026 08:54:27 -0700 Subject: [PATCH 3/3] fix: report large state only after the commit succeeds 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. --- CHANGELOG.md | 7 ++++--- docs/configuration.md | 4 +++- src/runtime.ts | 5 +++-- src/serialization.ts | 9 ++++----- test/state-serialization.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 39 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 783f75b..e71b929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,9 +22,10 @@ [Large state](docs/benchmarks.md#large-state). - Add `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 + the actor type, the actor ID, the byte count, and the threshold. The runtime + reports it only after the commit succeeds, so a turn that rolls back stays + silent. 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, which fails the turn. Throughput at that size is about one operation per second, so the warning names the constraint before an application meets it. diff --git a/docs/configuration.md b/docs/configuration.md index 3afe908..6c24112 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,7 +39,9 @@ affected failure path rather than schedule an invalid timestamp. `maxStateBytes` is a hard limit that fails the turn. `warnStateBytes` is a soft threshold that keeps the turn. A commit above the threshold 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. +ID, the byte count, and the threshold. The runtime reports it only after the +commit succeeds, so a turn that rolls back stays silent. The event holds no +application state. The runtime measures the size only when an `instrumentation` callback is configured. Throughput falls as the persisted state grows, so treat the warning as an instruction to divide the actor. See diff --git a/src/runtime.ts b/src/runtime.ts index 15d09ec..7c283b9 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1187,7 +1187,6 @@ export class SolidObjectsRuntime { const result = normalizeJson(rawResult === undefined ? null : rawResult, { maxBytes: this.settings.maxResultBytes, }) - this.warnAboutLargeState(turn.message, committed) const observables = this.readObservables({ actor, definition, stateJson: committed }) const changedObservableNames = Object.keys(observables.values).filter( (name) => @@ -1257,6 +1256,7 @@ export class SolidObjectsRuntime { nextRunAt: new Date(replacement.nextRunAtMilliseconds).toISOString(), }) } + this.warnAboutLargeState(turn.message, committed) this.emitInstrumentation("message.completed", { ...messageInstrumentation(turn.message), durationMilliseconds: Date.now() - startedAt, @@ -1958,7 +1958,8 @@ export class SolidObjectsRuntime { /** * The event reports the size of the committed image, never the image itself. * A stable encoding holds the same characters as the stored encoding, so its - * byte count is the byte count of the row the runtime writes. + * byte count is the byte count of the row the runtime writes. The caller + * reports only after the commit succeeds, so a rolled-back turn stays silent. */ private warnAboutLargeState(message: MessageRow, committed: string): void { if (!this.settings.instrumentation) return diff --git a/src/serialization.ts b/src/serialization.ts index 496989c..74b55cc 100644 --- a/src/serialization.ts +++ b/src/serialization.ts @@ -6,12 +6,11 @@ const utf8Encoder = new TextEncoder() export function normalizeJson(value: unknown, options: { maxBytes?: number } = {}): JsonValue { const normalized = normalize(value, 0) + if (options.maxBytes === undefined) return normalized - if (options.maxBytes !== undefined) { - const encoded = JSON.stringify(normalized) - if (utf8ByteLength(encoded) > options.maxBytes) { - throw new PayloadTooLarge(`serialized value exceeds ${options.maxBytes} bytes`) - } + const encoded = JSON.stringify(normalized) + if (utf8ByteLength(encoded) > options.maxBytes) { + throw new PayloadTooLarge(`serialized value exceeds ${options.maxBytes} bytes`) } return normalized diff --git a/test/state-serialization.test.ts b/test/state-serialization.test.ts index 8f35c63..4b9b0dc 100644 --- a/test/state-serialization.test.ts +++ b/test/state-serialization.test.ts @@ -59,6 +59,12 @@ class LargeStateActor extends Actor { this.payload = "s".repeat(size) return this.payload.length } + + growThenFailTheCommit({ size }: { size: number }): number { + this.payload = "s".repeat(size) + this.commitAction("explode") + return this.payload.length + } } let runtime: SolidObjectsRuntime | undefined @@ -198,6 +204,25 @@ describe("large committed state warning", () => { expect(JSON.stringify(warning?.attributes)).not.toContain("ss") }) + it("stays silent when the commit fails and the state is rolled back", async () => { + const events: InstrumentationEvent[] = [] + runtime = configuredRuntime({ + warnStateBytes: 256, + instrumentation: (event) => events.push(event), + }) + runtime.register(LargeStateActor) + runtime.registerCommitAction("explode", () => { + throw new Error("the commit action failed") + }) + await runtime.install() + + await runtime.ref(LargeStateActor, "rolled-back").send.growThenFailTheCommit({ size: 1_024 }) + await runtime.worker().runUntilIdle() + + expect(events.map((event) => event.name)).toContain("solid_objects.message.failed") + expect(events.filter((event) => event.name === "solid_objects.state.large")).toHaveLength(0) + }) + it("stays silent below the threshold", async () => { const events: InstrumentationEvent[] = [] runtime = configuredRuntime({