Skip to content

feat: add a singleOwner option with no idle queries - #95

Merged
cardmagic merged 15 commits into
mainfrom
feat/single-owner-idle
Nov 9, 2026
Merged

cardmagic merged 15 commits into
mainfrom
feat/single-owner-idle

Conversation

@cardmagic

Copy link
Copy Markdown
Owner

Why

An embedded runtime that is the only writer of its SQLite file still sends about 2,500 statements in each idle minute. Each role waits a fixed time: the idle poll interval, the lease renewal limit, the heartbeat or the cleanup interval. The Cloving daemon embeds Solid Objects this way, and it holds its database with PRAGMA locking_mode = EXCLUSIVE. For such a host, the in-process wake-up reaches every writer, so the fixed polls and heartbeats do no useful work.

What

A new option singleOwner (default false). With the default, nothing changes. With singleOwner: true:

  • Waits: each role (actor, effect with its recovery step, reminder, broadcast, redrive) waits for an in-process signal or for the next due time that the database reports. With nothing due, the wait is 24 hours. The due-time reads use the same conditions as the claims.
  • Leases: the actor worker wakes only to renew or release activations that it holds.
  • Process row: written at start, at shutdown and inside each claim transaction. A pass that claims nothing writes nothing. A row written only at start was not safe: after one idle minute it looks stale, and a second worker could claim the same work again. Six tests show those cases.
  • Cleanup: one stale-row cleanup at start, and no periodic cleanup.
  • Start check: run() throws SingleOwnerConflict when another process row is alive.
  • Refusals: the option is refused for PostgreSQL and MySQL, for the postgresql and redis wake-up adapters, and for a custom adapter. automatic and in_process use in-process signals.
  • Clock jumps: a timer checks the wall clock each 10 s with no query. After a jump, for example after a computer sleep, each role reads its due time again.

docs/configuration.md, docs/operations.md, docs/correctness.md, docs/errors-and-recovery.md, docs/api.md and CHANGELOG.md describe the option, its guarantee and its limit: one process only.

Measurement

Statements in 60 s on a SQLite file with every role on, counted at the database layer, with each BEGIN and COMMIT:

Scenario Default singleOwner
Idle 2,466 0
Just after one message 2,605 45 (two lease renewals and the idle release, then 0)

Checks

On Node 24.22: format:check, check, test:coverage (813 passed, 32 skipped), build, pack:check, test:package and test:recovery pass. test:redis passes against a local Redis. The PostgreSQL, MySQL and browser suites did not run locally; CI runs them.

Limits

  • The guarantee is for one process. A write from another process does not wake this runtime, and that work can wait up to 24 hours.
  • The start check cannot see an idle single owner whose row is stale. The docs recommend PRAGMA locking_mode = EXCLUSIVE; with it, a second process gets "database is locked".
  • While a single owner is idle, processes() shows its rows as stale.

A process that is the only writer of its SQLite file does not need
cross-process signals or polling to stay correct. This option lets it
say so. It refuses a database other than SQLite and a wake-up that is
not in-process, because only a reliable in-process signal can replace
polling. With the option, the runtime selects in-process signalling
and the doctor accepts it. The default stays false.
A role that does not poll must know when to look again. These queries
read the earliest time at which each role can claim something: a ready
message at the head of its mailbox, an expiring lease, a pending
effect, broadcast or reminder, a claim whose owner becomes stale, a
longer recovery timeout, or a running redrive. They mirror the claim
conditions, so a due time never points at work that a claim skips.
An idle runtime with singleOwner sent thousands of statements a minute,
because each role waited a fixed interval and rewrote its process row on
every pass. With the option, each role now waits for an in-process
wake-up or for the next due time that the database reports, bounded at
24 hours. The actor worker also wakes to renew or release a cached
activation, and stops when it holds none. Each role writes its process
row once at start. The periodic dead-process cleanup does not run. A
redrive start wakes the redrive scheduler through a local signal.

When a due time has already passed and a claim still finds nothing, the
role falls back to its polling interval, so stuck work cannot spin.
The option is safe only while no other process writes the database, so
run() now refuses with SingleOwnerConflict when another process row is
alive by the usual heartbeat rule. The error names each row and the
time at which it becomes stale. Rows of the runtime's own synchronous
callers do not count. After the check, the runtime releases stale
process rows once, which replaces the periodic cleanup that the option
turns off.
A singleOwner worker writes its process row only at start, so after an
idle minute the row looks stale by the alive rule. A worker that then
claimed work had a stale owner row until its first heartbeat. In that
window a second worker could reset and run the same effect, take the
same broadcast or reminder, the effect recovery could retire a running
effect, and the process cleanup could release a fresh claim.

With the option, each claim now writes the claiming row in the same
transaction, as registration does. An empty pass still writes nothing.
A message that failed and waits for its retry keeps the cached
activation of its worker. The due time read the lease expiry for every
held activation, so the holder slept until it renewed its lease, and
the retry ran seconds late. The holder is the one worker that can claim
that message, so its due time is now the time the message is available.
Other workers still wait for the lease to expire.
A synchronous caller can record a retry and then stop waiting. With
polling off, a background worker that slept before that write learned
of the retry only when the caller's lease expired. With singleOwner,
the runtime now notifies the actor workers after it records a retry.

The new tests show the option at work with hour-long polling intervals:
a sent message, an effect, a broadcast, a due reminder, a message retry
and an effect retry run promptly, and a reminder or message that a later
write moves earlier wakes its role for the earlier time.
Two paths have no message to wake them. A redrive starts through an
administration call, and an effect of a crashed owner becomes
recoverable only when its recovery timeout ends. The tests show that a
singleOwner runtime with hour-long polling runs both on time.
A role that waits for a due time uses a Node timer, and Node timers
stop while the computer sleeps. Without polling, a reminder due during
a laptop sleep of eight hours would run hours late. With singleOwner,
a watch now compares the wall clock with the process clock every ten
seconds, and wakes every role when they differ by a second or more.
The roles then read their due times again. The watch sends no query.
Explain what the option changes, when the runtime refuses it, and why
PostgreSQL, MySQL, and cross-process wake-ups are refused. State the
guarantee and its limit in the correctness page: it holds only while one
process writes the database, and the start check sees another writer
only while that writer's row is fresh. Describe the stale rows of an
idle single owner and the exclusive SQLite lock in the operations page.
Document SingleOwnerConflict and add a changelog entry with the measured
idle statement counts.
An actor setup failure puts its message back as ready in the same
millisecond, so the next due time was exactly now. The worker then
waited one millisecond and claimed the same message again, about fifty
times a second. A due time of zero now counts as passed, like a
negative one, and the role waits its polling interval, as it does
without singleOwner. A future due time still wakes the role one
millisecond after it.
@context7

context7 Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

Docs7 for cardmagic/solid-objects-js

Result Status Action
Deployment ➖ Not used —
Content review ➖ Did not run. This site has no agent runs available this month. Wait for the monthly reset or check your Docs7 plan. —

Commit 7e11a99

@greptile-apps

greptile-apps Bot commented Nov 9, 2026 •

Copy link
Copy Markdown

RetriggerView in GreptileConfidence Score: 5/5

[Medium impact] The PR appears safe to merge; both previous findings are fixed, and no new actionable issue was found.

Summary

Adds singleOwner for a sole SQLite writer, replacing idle polling with local signals and database due times.

  • SQLite roles wait for local signals or the next due work.
  • Single-owner startup checks live process rows before running.
  • Repeated waits no longer leave shutdown listeners behind.

Diagram

sequenceDiagram
  participant Caller as Synchronous caller
  participant DB as SQLite
  participant Worker as Actor worker
  Caller->>DB: Release activation
  DB->>DB: Check for waiting messages
  DB-->>Caller: Commit and return result
  Caller->>Worker: Signal waiting work
  Worker->>DB: Claim next message
Loading

Reviews (2) · Last reviewed commit: "docs: note the abort-listener leak fix" · Reviewed by Greptile

Comment thread src/due-work.ts Resolved
Comment thread src/wall-clock-watch.ts Resolved
waitFor added an abort listener to its signal and removed it only when
the signal aborted. A loop that waits with the same signal therefore
added a listener on every pass. The redrive scheduler added one each
idle second without singleOwner, the cleanup scheduler one each minute,
and the singleOwner clock watch one each ten seconds. The listeners
grow without bound and trigger listener-limit warnings. waitFor now
removes its listener when its timer ends.
A role that wakes for a due time can claim a few milliseconds early,
because a Node timer counts from the cached loop time and the database
clock rounds to whole milliseconds. The claim then finds nothing, and
the next due time reads as exactly now. Since the previous fix, that
case waited a full polling interval, an hour with long intervals, so a
reminder test failed in two of seventeen suite runs.

A due time of now or earlier now retries after 1 ms and doubles the
wait up to the polling interval. A future due time starts the back-off
again. Stuck work, such as an actor setup failure, still settles at the
polling interval.
With singleOwner, a synchronous caller can hold an actor while a new
message for that actor arrives. The background worker wakes, finds the
actor held, and waits until the caller's lease ends. When the caller
then releases the activation, nothing woke that worker, so the message
waited about 30 seconds with the default lease.

With singleOwner, a release that frees the activation now reports
whether ready messages wait for that actor, and the runtime then wakes
the actor workers. Without the option, the release sends no extra query.
The leak also affected runtimes without singleOwner, where the redrive
scheduler added a listener each idle second, so the fix belongs in the
release notes.
@cardmagic

Copy link
Copy Markdown
Owner Author

@greptileai

@cardmagic
cardmagic merged commit 1f92bb7 into main Nov 9, 2026
20 checks passed
@cardmagic
cardmagic deleted the feat/single-owner-idle branch November 9, 2026 15:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant