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
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
# Changelog

## Unreleased
## 0.13.1 - 2026-08-16

- Back idle actor, effect, reminder, and broadcast polling off exponentially
from the configured fast interval to a new one-second idle ceiling. Any
processed work or wake-up resets the role immediately, and actor polling
remains capped by the lease-renewal interval.
- Expose each role's current polling interval and emit
`solid_objects.polling.interval_changed` instrumentation for every idle,
work, and wake-up transition.
- Warn once when live processes share the database without a configured
cross-process wake-up adapter.
- Preserve older custom wake-up adapters that return `void`; return `true` for
notifications and `false` for timeouts from the built-in PostgreSQL, Redis,
and in-process adapters so adaptive polling can distinguish them.
- Add a reproducible four-role SQLite idle benchmark.
- **Behavior change:** `pollingIntervalMilliseconds` is now the fast interval
after activity, not a constant idle cadence. Existing explicit values back
off to `idlePollingIntervalMilliseconds`, which defaults to `1_000`. Set
both options to the same value to preserve a fixed cadence.

## 0.13.0 - 2026-08-16

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,11 +57,11 @@ processes submit them concurrently.

## Run it now with SQLite

Node.js 24.15 or newer is required. The `0.13.0` release includes a
Node.js 24.15 or newer is required. The `0.13.1` release includes a
packaged quickstart:

```bash
npm exec --yes --package=solid-objects@0.13.0 -- solid-objects quickstart
npm exec --yes --package=solid-objects@0.13.1 -- solid-objects quickstart
```

The command needs no repository checkout, database server, Redis, container, or
Expand Down Expand Up @@ -157,6 +157,12 @@ Redis is optional wake-up infrastructure. It can reduce notification latency
for a multi-process MySQL deployment, but the relational database remains the
durable source of truth and polling remains the recovery path.

Idle roles back off from the configured 100 ms fast polling interval to one
second. Processed work and wake-up notifications reset that interval
immediately. The default wake-up reaches only the current Node process; use the
PostgreSQL or optional Redis adapter when separate processes need low-latency
delivery. The runtime warns once when it sees that topology without an adapter.

## Good and poor fits

| Good fit | Poor fit |
Expand Down
175 changes: 175 additions & 0 deletions benchmarks/idle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { readFile } from "node:fs/promises"
import { cpus, platform, release } from "node:os"
import { createRuntime } from "solid-objects"
import { sqlite } from "solid-objects/database/sqlite"
import type { WakeUpAdapter, WakeUpRole, WakeUpWatch } from "../src/wake-up.ts"

const roles = ["actors", "effects", "reminders", "broadcasts"] as const
const intervals = option("intervals", "20,100,500")
.split(",")
.map((value) => positiveNumber(value, "intervals"))
const warmupMilliseconds = positiveNumber(option("warmup", "3000"), "warmup")
const durationMilliseconds = positiveNumber(option("duration", "10000"), "duration")

async function main(): Promise<void> {
const packageMetadata = JSON.parse(
await readFile(new URL("../package.json", import.meta.url), "utf8"),
) as { version: string }
const results = []
const databaseVersion = await readDatabaseVersion()

for (const pollingIntervalMilliseconds of intervals) {
results.push(await measure(pollingIntervalMilliseconds))
}

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", version: databaseVersion, path: ":memory:" },
methodology: {
roles,
warmupMilliseconds,
durationMilliseconds,
cpuPercent: "process user plus system CPU time divided by wall time",
},
results,
},
null,
2,
)}\n`,
)
}

async function readDatabaseVersion(): Promise<string> {
const database = sqlite({ path: ":memory:" })
try {
return await database.connection(async (connection) => {
const row = await connection.get<{ version: string }>("SELECT sqlite_version() AS version")
return row?.version ?? "unknown"
})
} finally {
await database.close()
}
}

async function measure(pollingIntervalMilliseconds: number) {
const wakeUp = new CountingWakeUpAdapter()
const runtime = createRuntime({
database: sqlite({ path: ":memory:" }),
pollingIntervalMilliseconds,
workerCount: 1,
effectWorkerCount: 1,
reminderSchedulerCount: 1,
broadcastWorkerCount: 1,
retentionIntervalMilliseconds: 0,
deadProcessCleanupIntervalMilliseconds: 0,
authorizeSubscription: () => true,
broadcast: async () => {},
wakeUp,
})
await runtime.install()
const controller = new AbortController()
const running = [
runtime.worker().run(controller.signal),
runtime.effectWorker().run(controller.signal),
runtime.reminderScheduler().run(controller.signal),
runtime.broadcastWorker().run(controller.signal),
]

try {
await wait(warmupMilliseconds)
wakeUp.resetCounts()
const cpuStartedAt = process.cpuUsage()
const wallStartedAt = performance.now()
await wait(durationMilliseconds)
const elapsedMilliseconds = performance.now() - wallStartedAt
const cpuUsage = process.cpuUsage(cpuStartedAt)
const polls = wakeUp.pollCounts()
const totalPolls = Object.values(polls).reduce((total, count) => total + count, 0)

return {
pollingIntervalMilliseconds,
idlePollingIntervalMilliseconds: runtime.settings.idlePollingIntervalMilliseconds,
polls,
pollsPerSecond: round((totalPolls * 1_000) / elapsedMilliseconds),
idleCpuPercent: round(
((cpuUsage.user + cpuUsage.system) / 1_000 / elapsedMilliseconds) * 100,
),
}
} finally {
controller.abort()
await Promise.all(running)
await runtime.close()
}
}

class CountingWakeUpAdapter implements WakeUpAdapter {
private readonly counts = new Map<WakeUpRole, number>()

watch(role: WakeUpRole): WakeUpWatch {
return {
wait: async ({ timeoutMilliseconds, signal }) => {
this.counts.set(role, (this.counts.get(role) ?? 0) + 1)
return new Promise<boolean>((resolve) => {
let settled = false
const finish = () => {
if (settled) return
settled = true
clearTimeout(timeout)
signal?.removeEventListener("abort", finish)
resolve(false)
}
const timeout = setTimeout(finish, timeoutMilliseconds)
signal?.addEventListener("abort", finish, { once: true })
if (signal?.aborted) finish()
})
},
}
}

notify(_role: WakeUpRole): void {}

close(): void {}

resetCounts(): void {
this.counts.clear()
}

pollCounts(): Record<WakeUpRole, number> {
return Object.fromEntries(roles.map((role) => [role, this.counts.get(role) ?? 0])) as Record<
WakeUpRole,
number
>
}
}

function option(name: string, fallback: string): string {
const prefix = `--${name}=`
return (
process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? fallback
)
}

function positiveNumber(value: string, name: string): number {
const number = Number(value)
if (!Number.isFinite(number) || number <= 0) throw new TypeError(`${name} must be positive`)
return number
}

function wait(milliseconds: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, milliseconds))
}

function round(value: number): number {
return Math.round(value * 1_000) / 1_000
}

await main()
11 changes: 7 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,14 +179,17 @@ Factories should create fresh mutable state and `stop()` should be idempotent.
exported for test runners and hosts that intentionally operate roles outside
`runtime.run()`. Runtime factory methods create the same classes. Each provides
`runOnce()`, bounded `runUntilIdle()`, `run(signal)`, `requestShutdown()`,
`stopped()`, and `stop()`. Manual roles still register process ownership and
must be stopped. Prefer `runtime.run()` in production and `runtime.testing` in
tests.
`stopped()`, `stop()`, and the inspectable
`currentPollingIntervalMilliseconds`. Manual roles still register process
ownership and must be stopped. Prefer `runtime.run()` in production and
`runtime.testing` in tests.

`InProcessWakeUpAdapter`, `WakeUpAdapter`, `WakeUpRole`, `WakeUpWatch`, and
`WakeUpWaitOptions` define the notification extension. A watch must be obtained
before checking durable state so a notification cannot fall between claim and
wait.
wait. `WakeUpWatch.wait()` returns `true` for a notification and `false` for a
timeout or cancellation. A legacy `void` result remains accepted and preserves
the fast polling cadence.

### Errors

Expand Down
7 changes: 5 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,9 @@ Each role takes a generation watch before checking for work. A post-commit
wake-up therefore cannot fall into the gap between an empty claim and the
worker's wait. The default adapter broadcasts within one process; polling
remains active as the durable fallback and custom adapters can bridge process
boundaries.
boundaries. Empty passes double the role's wait up to the configured idle
ceiling. Work or a notification resets it to the fast interval, and an actor
worker's ceiling never exceeds its lease-renewal interval.

Each runtime role occupies a supervised factory slot. An unexpected promise
resolution or rejection cleans up that instance, waits with capped exponential
Expand Down Expand Up @@ -63,7 +65,8 @@ per runtime listens on role-specific channels before the worker checks durable
state, which closes the listener-startup race without holding a polling
connection per worker. A notification advances a process-local role generation
and wakes every matching waiter. Reconnection and notification loss fall back
to the ordinary polling interval.
to adaptive polling, whose current wait can be as long as the configured idle
ceiling.

The optional Redis adapter provides the same role generations through Pub/Sub
for deployments that already operate Redis. It keeps commands and subscriptions
Expand Down
40 changes: 40 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,46 @@
The benchmark harness measures committed actor operations. It is intended to
show tradeoffs and catch large regressions, not to predict application capacity.

## Idle polling

The idle harness measures process CPU and empty database passes for the four
runtime roles:

```bash
pnpm run benchmark:idle
```

It warms each interval for three seconds, measures for ten seconds, and reports
process user plus system CPU time divided by wall time.

Measured on August 16, 2026 on an Apple M5 with Node.js 26.7.0 and in-memory
SQLite. The before run used 0.13.0; the after run used the prepared 0.13.1 tree.
Each run started one actor, effect, reminder, and broadcast role.

| Fast interval | Before polls/s | Before CPU | After polls/s | After CPU |
| ------------: | -------------: | ---------: | ------------: | --------: |
| 20 ms | 188.78 | 3.254% | 4.000 | 0.129% |
| 100 ms | 39.596 | 0.906% | 3.999 | 0.121% |
| 500 ms | 7.999 | 0.251% | 3.999 | 0.104% |

The after run reached the one-second ceiling for all four roles. These are
developer-laptop measurements, not a CPU guarantee; timer scheduling, JIT,
database path, and unrelated host activity affect short samples.

Five SQLite samples measured durable enqueue through committed completion after
2.5 seconds of idleness. The polling-only multi-process harness submits just
after an empty pass, so it measures approximately the full polling wait rather
than average arrival latency.

| Topology | 0.13.0 p50 | Prepared 0.13.1 p50 |
| ------------------------------- | ---------: | ------------------: |
| One process, in-process wake-up | 2.589 ms | 2.662 ms |
| Two processes, polling only | 107.945 ms | 1,006.232 ms |

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.

## Scenarios

- `warm-hot`: all operations target one previously created identity.
Expand Down
13 changes: 11 additions & 2 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ through `runtime.ref(ActorClass, actorId)`. Both validate options immediately.
| `database` | required | A `Database` adapter. |
| `tableNamePrefix` | `"solid_objects_"` | Lowercase letters, digits, and underscores; must start with a letter. |
| `pollingIntervalMilliseconds` | `100` | Positive durable-work polling interval. |
| `idlePollingIntervalMilliseconds` | `1_000` | Positive ceiling after consecutive empty polling passes. |
| `syncPollingIntervalMilliseconds` | `50` | Positive result-wait polling interval. |
| `leaseDurationMilliseconds` | `30_000` | Positive activation lease; must exceed renewal interval. |
| `leaseRenewalIntervalMilliseconds` | `10_000` | Positive activation renewal cadence. |
Expand Down Expand Up @@ -52,8 +53,16 @@ affected failure path rather than schedule an invalid timestamp.

Counts may be zero, but the complete configuration must leave at least one
runtime role enabled. Broadcast workers are started only when `broadcast` or
`authorizeSubscription` is configured. Wake-ups reduce latency; durable polling
remains the correctness path.
`authorizeSubscription` is configured.

`pollingIntervalMilliseconds` is the fast interval after work or a wake-up.
Consecutive empty passes double it up to
`idlePollingIntervalMilliseconds`. Actor workers never wait longer than
`leaseRenewalIntervalMilliseconds`. Set the fast and idle values equal for a
fixed cadence. A custom wake-up adapter should return `true` for a notification
and `false` for a timeout; an older adapter that returns `void` remains
compatible and keeps the fast cadence. Wake-ups reduce latency, while database
polling remains the correctness path.

## Retention and cleanup

Expand Down
32 changes: 25 additions & 7 deletions docs/operations.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,30 @@
# Operations

Runtime roles use durable polling as the correctness fallback. The default
generation-based wake-up adapter interrupts waits for new actor messages,
effects, reminders, and broadcasts in the same Node process. Notification
errors are isolated and logged by role and error class without failing the
committed work. Graceful shutdown stops new claims and allows active turns to
finish within `shutdownTimeoutMilliseconds`, which defaults to 15 seconds. A
component still running or stopping at the deadline emits
Runtime roles use durable polling as the correctness fallback. Consecutive
empty passes double each role's wait from `pollingIntervalMilliseconds` to
`idlePollingIntervalMilliseconds`, which defaults to one second. Processed
work and wake-up notifications reset the role to the fast interval. Actor
workers clamp the ceiling to `leaseRenewalIntervalMilliseconds` while they may
hold cached activations.

The default generation-based wake-up adapter interrupts waits for new actor
messages, effects, reminders, and broadcasts in the same Node process. It does
not cross a process boundary. When live processes share the database without a
configured adapter, the runtime logs
`solid_objects.polling_only_cross_process_wake_up` once. Use PostgreSQL
notifications or optional Redis Pub/Sub when separate processes need prompt
delivery; without one, newly committed work can wait up to the current idle
polling interval. Notification errors are isolated and logged by role and error
class without failing the committed work.

Each role exposes `currentPollingIntervalMilliseconds`.
`solid_objects.polling.interval_changed` reports the role, reason, previous
interval, and current interval. The polling-only warning is also emitted as
`solid_objects.polling.only_cross_process_wake_up` instrumentation.

Graceful shutdown stops new claims and allows active turns to finish within
`shutdownTimeoutMilliseconds`, which defaults to 15 seconds. A component still
running or stopping at the deadline emits
`solid_objects.supervisor.component_shutdown_timeout`; the runtime then returns
without pretending JavaScript code was forcibly terminated. Operators should
monitor oldest ready work, claimed work, dead letters, effect failures,
Expand Down
4 changes: 2 additions & 2 deletions docs/parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem.
Parity means preserving a capability and its correctness or security boundary,
not copying a Rails API into Node.

Reference: Ruby `solid_objects` 0.13.0. The JavaScript package began at the
Reference: Ruby `solid_objects` 0.13.1. The JavaScript package began at the
Ruby design's `0.12` capability generation; that version number did not imply
earlier JavaScript releases.

The Node `0.13.0` implementation has capability parity with that reference. Its
The Node `0.13.1` implementation has capability parity with that reference. Its
relational runtime, correctness boundaries, administration, diagnostics,
operator dashboard, realtime projections, browser behavior, and supported
adapters have native equivalents. Rails-specific rendering surfaces are
Expand Down
Loading