Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,37 @@
# 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
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 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.
- 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.
Expand Down
129 changes: 129 additions & 0 deletions benchmarks/large-state.ts
Original file line number Diff line number Diff line change
@@ -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<void> | 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<void> {
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
}
18 changes: 18 additions & 0 deletions benchmarks/shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -42,6 +59,7 @@ export function benchmarkRuntime(options: {
authorizeQuery: () => true,
})
runtime.register(BenchmarkCounter)
runtime.register(LargeStateCounter)
return runtime
}

Expand Down
37 changes: 37 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `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 |
| --------------: | -----------: | -----------: | ----------: | ----------: | ---: |
| 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.
Expand Down Expand Up @@ -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.
12 changes: 12 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -35,6 +36,17 @@ 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 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
[State size and throughput](state-and-lifecycle.md#state-size-and-throughput).

## Runtime roles and supervision

| Option | Default | Contract |
Expand Down
1 change: 1 addition & 0 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion docs/state-and-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.4` source
tree.

| 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

Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down Expand Up @@ -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"
},
Expand Down
3 changes: 3 additions & 0 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export interface SolidObjectsConfiguration {
maxMailboxLength?: number
maxPayloadBytes?: number
maxStateBytes?: number
warnStateBytes?: number
maxResultBytes?: number
maxAttempts?: number
maxMessagesPerActivationPass?: number
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,23 @@ export function validateDefinition<ActorType extends Actor>(
})
}

const defaultStates = new WeakMap<ValidatedActorDefinition, string>()

/**
* 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 {
Expand Down
Loading