Skip to content

Commit 85255e6

Browse files
committed
fix: back off idle polling
Idle runtime roles currently query the database at a fixed cadence even when no work exists. Back empty passes off to a bounded ceiling while keeping work and wake-up paths prompt, observable, and lease-safe. Warn when separate processes rely on polling alone, document the resulting latency tradeoff, add reproducible measurements, and prepare 0.13.1.
1 parent 9d12eab commit 85255e6

30 files changed

Lines changed: 1071 additions & 96 deletions

‎CHANGELOG.md‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
11
# Changelog
22

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

523
## 0.13.0 - 2026-08-16
624

‎README.md‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,11 +57,11 @@ processes submit them concurrently.
5757

5858
## Run it now with SQLite
5959

60-
Node.js 24.15 or newer is required. The `0.13.0` release includes a
60+
Node.js 24.15 or newer is required. The `0.13.1` release includes a
6161
packaged quickstart:
6262

6363
```bash
64-
npm exec --yes --package=solid-objects@0.13.0 -- solid-objects quickstart
64+
npm exec --yes --package=solid-objects@0.13.1 -- solid-objects quickstart
6565
```
6666

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

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

162168
| Good fit | Poor fit |

‎benchmarks/idle.ts‎

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
import { readFile } from "node:fs/promises"
2+
import { cpus, platform, release } from "node:os"
3+
import { createRuntime } from "solid-objects"
4+
import { sqlite } from "solid-objects/database/sqlite"
5+
import type { WakeUpAdapter, WakeUpRole, WakeUpWatch } from "../src/wake-up.ts"
6+
7+
const roles = ["actors", "effects", "reminders", "broadcasts"] as const
8+
const intervals = option("intervals", "20,100,500")
9+
.split(",")
10+
.map((value) => positiveNumber(value, "intervals"))
11+
const warmupMilliseconds = positiveNumber(option("warmup", "3000"), "warmup")
12+
const durationMilliseconds = positiveNumber(option("duration", "10000"), "duration")
13+
14+
async function main(): Promise<void> {
15+
const packageMetadata = JSON.parse(
16+
await readFile(new URL("../package.json", import.meta.url), "utf8"),
17+
) as { version: string }
18+
const results = []
19+
const databaseVersion = await readDatabaseVersion()
20+
21+
for (const pollingIntervalMilliseconds of intervals) {
22+
results.push(await measure(pollingIntervalMilliseconds))
23+
}
24+
25+
process.stdout.write(
26+
`${JSON.stringify(
27+
{
28+
measuredAt: new Date().toISOString(),
29+
packageVersion: packageMetadata.version,
30+
runtime: {
31+
node: process.version,
32+
platform: `${platform()} ${release()}`,
33+
cpu: cpus()[0]?.model ?? "unknown",
34+
logicalCpus: cpus().length,
35+
},
36+
database: { adapter: "sqlite", version: databaseVersion, path: ":memory:" },
37+
methodology: {
38+
roles,
39+
warmupMilliseconds,
40+
durationMilliseconds,
41+
cpuPercent: "process user plus system CPU time divided by wall time",
42+
},
43+
results,
44+
},
45+
null,
46+
2,
47+
)}\n`,
48+
)
49+
}
50+
51+
async function readDatabaseVersion(): Promise<string> {
52+
const database = sqlite({ path: ":memory:" })
53+
try {
54+
return await database.connection(async (connection) => {
55+
const row = await connection.get<{ version: string }>("SELECT sqlite_version() AS version")
56+
return row?.version ?? "unknown"
57+
})
58+
} finally {
59+
await database.close()
60+
}
61+
}
62+
63+
async function measure(pollingIntervalMilliseconds: number) {
64+
const wakeUp = new CountingWakeUpAdapter()
65+
const runtime = createRuntime({
66+
database: sqlite({ path: ":memory:" }),
67+
pollingIntervalMilliseconds,
68+
workerCount: 1,
69+
effectWorkerCount: 1,
70+
reminderSchedulerCount: 1,
71+
broadcastWorkerCount: 1,
72+
retentionIntervalMilliseconds: 0,
73+
deadProcessCleanupIntervalMilliseconds: 0,
74+
authorizeSubscription: () => true,
75+
broadcast: async () => {},
76+
wakeUp,
77+
})
78+
await runtime.install()
79+
const controller = new AbortController()
80+
const running = [
81+
runtime.worker().run(controller.signal),
82+
runtime.effectWorker().run(controller.signal),
83+
runtime.reminderScheduler().run(controller.signal),
84+
runtime.broadcastWorker().run(controller.signal),
85+
]
86+
87+
try {
88+
await wait(warmupMilliseconds)
89+
wakeUp.resetCounts()
90+
const cpuStartedAt = process.cpuUsage()
91+
const wallStartedAt = performance.now()
92+
await wait(durationMilliseconds)
93+
const elapsedMilliseconds = performance.now() - wallStartedAt
94+
const cpuUsage = process.cpuUsage(cpuStartedAt)
95+
const polls = wakeUp.pollCounts()
96+
const totalPolls = Object.values(polls).reduce((total, count) => total + count, 0)
97+
98+
return {
99+
pollingIntervalMilliseconds,
100+
idlePollingIntervalMilliseconds: runtime.settings.idlePollingIntervalMilliseconds,
101+
polls,
102+
pollsPerSecond: round((totalPolls * 1_000) / elapsedMilliseconds),
103+
idleCpuPercent: round(
104+
((cpuUsage.user + cpuUsage.system) / 1_000 / elapsedMilliseconds) * 100,
105+
),
106+
}
107+
} finally {
108+
controller.abort()
109+
await Promise.all(running)
110+
await runtime.close()
111+
}
112+
}
113+
114+
class CountingWakeUpAdapter implements WakeUpAdapter {
115+
private readonly counts = new Map<WakeUpRole, number>()
116+
117+
watch(role: WakeUpRole): WakeUpWatch {
118+
return {
119+
wait: async ({ timeoutMilliseconds, signal }) => {
120+
this.counts.set(role, (this.counts.get(role) ?? 0) + 1)
121+
return new Promise<boolean>((resolve) => {
122+
let settled = false
123+
const finish = () => {
124+
if (settled) return
125+
settled = true
126+
clearTimeout(timeout)
127+
signal?.removeEventListener("abort", finish)
128+
resolve(false)
129+
}
130+
const timeout = setTimeout(finish, timeoutMilliseconds)
131+
signal?.addEventListener("abort", finish, { once: true })
132+
if (signal?.aborted) finish()
133+
})
134+
},
135+
}
136+
}
137+
138+
notify(_role: WakeUpRole): void {}
139+
140+
close(): void {}
141+
142+
resetCounts(): void {
143+
this.counts.clear()
144+
}
145+
146+
pollCounts(): Record<WakeUpRole, number> {
147+
return Object.fromEntries(roles.map((role) => [role, this.counts.get(role) ?? 0])) as Record<
148+
WakeUpRole,
149+
number
150+
>
151+
}
152+
}
153+
154+
function option(name: string, fallback: string): string {
155+
const prefix = `--${name}=`
156+
return (
157+
process.argv.find((argument) => argument.startsWith(prefix))?.slice(prefix.length) ?? fallback
158+
)
159+
}
160+
161+
function positiveNumber(value: string, name: string): number {
162+
const number = Number(value)
163+
if (!Number.isFinite(number) || number <= 0) throw new TypeError(`${name} must be positive`)
164+
return number
165+
}
166+
167+
function wait(milliseconds: number): Promise<void> {
168+
return new Promise((resolve) => setTimeout(resolve, milliseconds))
169+
}
170+
171+
function round(value: number): number {
172+
return Math.round(value * 1_000) / 1_000
173+
}
174+
175+
await main()

‎docs/api.md‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,14 +179,17 @@ Factories should create fresh mutable state and `stop()` should be idempotent.
179179
exported for test runners and hosts that intentionally operate roles outside
180180
`runtime.run()`. Runtime factory methods create the same classes. Each provides
181181
`runOnce()`, bounded `runUntilIdle()`, `run(signal)`, `requestShutdown()`,
182-
`stopped()`, and `stop()`. Manual roles still register process ownership and
183-
must be stopped. Prefer `runtime.run()` in production and `runtime.testing` in
184-
tests.
182+
`stopped()`, `stop()`, and the inspectable
183+
`currentPollingIntervalMilliseconds`. Manual roles still register process
184+
ownership and must be stopped. Prefer `runtime.run()` in production and
185+
`runtime.testing` in tests.
185186

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

191194
### Errors
192195

‎docs/architecture.md‎

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

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

6871
The optional Redis adapter provides the same role generations through Pub/Sub
6972
for deployments that already operate Redis. It keeps commands and subscriptions

‎docs/benchmarks.md‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,46 @@
33
The benchmark harness measures committed actor operations. It is intended to
44
show tradeoffs and catch large regressions, not to predict application capacity.
55

6+
## Idle polling
7+
8+
The idle harness measures process CPU and empty database passes for the four
9+
runtime roles:
10+
11+
```bash
12+
pnpm run benchmark:idle
13+
```
14+
15+
It warms each interval for three seconds, measures for ten seconds, and reports
16+
process user plus system CPU time divided by wall time.
17+
18+
Measured on August 16, 2026 on an Apple M5 with Node.js 26.7.0 and in-memory
19+
SQLite. The before run used 0.13.0; the after run used the prepared 0.13.1 tree.
20+
Each run started one actor, effect, reminder, and broadcast role.
21+
22+
| Fast interval | Before polls/s | Before CPU | After polls/s | After CPU |
23+
| ------------: | -------------: | ---------: | ------------: | --------: |
24+
| 20 ms | 188.78 | 3.254% | 4.000 | 0.129% |
25+
| 100 ms | 39.596 | 0.906% | 3.999 | 0.121% |
26+
| 500 ms | 7.999 | 0.251% | 3.999 | 0.104% |
27+
28+
The after run reached the one-second ceiling for all four roles. These are
29+
developer-laptop measurements, not a CPU guarantee; timer scheduling, JIT,
30+
database path, and unrelated host activity affect short samples.
31+
32+
Five SQLite samples measured durable enqueue through committed completion after
33+
2.5 seconds of idleness. The polling-only multi-process harness submits just
34+
after an empty pass, so it measures approximately the full polling wait rather
35+
than average arrival latency.
36+
37+
| Topology | 0.13.0 p50 | Prepared 0.13.1 p50 |
38+
| ------------------------------- | ---------: | ------------------: |
39+
| One process, in-process wake-up | 2.589 ms | 2.662 ms |
40+
| Two processes, polling only | 107.945 ms | 1,006.232 ms |
41+
42+
The local wake-up keeps the one-process path prompt after backoff. The
43+
polling-only row is the explicit tradeoff: use PostgreSQL notifications or
44+
optional Redis Pub/Sub when separate processes need low-latency delivery.
45+
646
## Scenarios
747

848
- `warm-hot`: all operations target one previously created identity.

‎docs/configuration.md‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ through `runtime.ref(ActorClass, actorId)`. Both validate options immediately.
1111
| `database` | required | A `Database` adapter. |
1212
| `tableNamePrefix` | `"solid_objects_"` | Lowercase letters, digits, and underscores; must start with a letter. |
1313
| `pollingIntervalMilliseconds` | `100` | Positive durable-work polling interval. |
14+
| `idlePollingIntervalMilliseconds` | `1_000` | Positive ceiling after consecutive empty polling passes. |
1415
| `syncPollingIntervalMilliseconds` | `50` | Positive result-wait polling interval. |
1516
| `leaseDurationMilliseconds` | `30_000` | Positive activation lease; must exceed renewal interval. |
1617
| `leaseRenewalIntervalMilliseconds` | `10_000` | Positive activation renewal cadence. |
@@ -52,8 +53,16 @@ affected failure path rather than schedule an invalid timestamp.
5253

5354
Counts may be zero, but the complete configuration must leave at least one
5455
runtime role enabled. Broadcast workers are started only when `broadcast` or
55-
`authorizeSubscription` is configured. Wake-ups reduce latency; durable polling
56-
remains the correctness path.
56+
`authorizeSubscription` is configured.
57+
58+
`pollingIntervalMilliseconds` is the fast interval after work or a wake-up.
59+
Consecutive empty passes double it up to
60+
`idlePollingIntervalMilliseconds`. Actor workers never wait longer than
61+
`leaseRenewalIntervalMilliseconds`. Set the fast and idle values equal for a
62+
fixed cadence. A custom wake-up adapter should return `true` for a notification
63+
and `false` for a timeout; an older adapter that returns `void` remains
64+
compatible and keeps the fast cadence. Wake-ups reduce latency, while database
65+
polling remains the correctness path.
5766

5867
## Retention and cleanup
5968

‎docs/operations.md‎

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
# Operations
22

3-
Runtime roles use durable polling as the correctness fallback. The default
4-
generation-based wake-up adapter interrupts waits for new actor messages,
5-
effects, reminders, and broadcasts in the same Node process. Notification
6-
errors are isolated and logged by role and error class without failing the
7-
committed work. Graceful shutdown stops new claims and allows active turns to
8-
finish within `shutdownTimeoutMilliseconds`, which defaults to 15 seconds. A
9-
component still running or stopping at the deadline emits
3+
Runtime roles use durable polling as the correctness fallback. Consecutive
4+
empty passes double each role's wait from `pollingIntervalMilliseconds` to
5+
`idlePollingIntervalMilliseconds`, which defaults to one second. Processed
6+
work and wake-up notifications reset the role to the fast interval. Actor
7+
workers clamp the ceiling to `leaseRenewalIntervalMilliseconds` while they may
8+
hold cached activations.
9+
10+
The default generation-based wake-up adapter interrupts waits for new actor
11+
messages, effects, reminders, and broadcasts in the same Node process. It does
12+
not cross a process boundary. When live processes share the database without a
13+
configured adapter, the runtime logs
14+
`solid_objects.polling_only_cross_process_wake_up` once. Use PostgreSQL
15+
notifications or optional Redis Pub/Sub when separate processes need prompt
16+
delivery; without one, newly committed work can wait up to the current idle
17+
polling interval. Notification errors are isolated and logged by role and error
18+
class without failing the committed work.
19+
20+
Each role exposes `currentPollingIntervalMilliseconds`.
21+
`solid_objects.polling.interval_changed` reports the role, reason, previous
22+
interval, and current interval. The polling-only warning is also emitted as
23+
`solid_objects.polling.only_cross_process_wake_up` instrumentation.
24+
25+
Graceful shutdown stops new claims and allows active turns to finish within
26+
`shutdownTimeoutMilliseconds`, which defaults to 15 seconds. A component still
27+
running or stopping at the deadline emits
1028
`solid_objects.supervisor.component_shutdown_timeout`; the runtime then returns
1129
without pretending JavaScript code was forcibly terminated. Operators should
1230
monitor oldest ready work, claimed work, dead letters, effect failures,

‎docs/parity.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ This ledger tracks capability parity with the Ruby `solid_objects` gem.
44
Parity means preserving a capability and its correctness or security boundary,
55
not copying a Rails API into Node.
66

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

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

0 commit comments

Comments
 (0)