Repository navigation
feat: add a singleOwner option with no idle queries - #95
Merged
Merged
Conversation
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.
|
Docs7 for cardmagic/solid-objects-js
Commit |
[Medium impact] The PR appears safe to merge; both previous findings are fixed, and no new actionable issue was found. SummaryAdds
DiagramsequenceDiagram
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
Reviews (2) · Last reviewed commit: "docs: note the abort-listener leak fix" · Reviewed by Greptile |
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.
Owner
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(defaultfalse). With the default, nothing changes. WithsingleOwner: true:run()throwsSingleOwnerConflictwhen another process row is alive.postgresqlandrediswake-up adapters, and for a custom adapter.automaticandin_processuse in-process signals.docs/configuration.md,docs/operations.md,docs/correctness.md,docs/errors-and-recovery.md,docs/api.mdandCHANGELOG.mddescribe 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:
singleOwnerChecks
On Node 24.22:
format:check,check,test:coverage(813 passed, 32 skipped),build,pack:check,test:packageandtest:recoverypass.test:redispasses against a local Redis. The PostgreSQL, MySQL and browser suites did not run locally; CI runs them.Limits
PRAGMA locking_mode = EXCLUSIVE; with it, a second process gets "database is locked".processes()shows its rows asstale.