Skip to content

Commit 78e264e

Browse files
committed
feat: cache idle activations
1 parent 90977bd commit 78e264e

17 files changed

Lines changed: 604 additions & 79 deletions

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@
4242
- Add MySQL 8.0+ through the optional `mysql2` driver with pooled transactions,
4343
an InnoDB schema, scoped enqueue deadlock recovery, diagnostics, and
4444
real-server coverage across MySQL 8.0 and 8.4.
45+
- Add hydrated idle activation reuse with renewable fenced leases, protected
46+
async lifecycle hooks, state restoration after failed turns, and release on
47+
timeout, fairness yield, lease loss, and shutdown.
4548

4649
## 0.1.0 - 2026-08-13
4750

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -754,6 +754,8 @@ IDs and observable values are not authorization.
754754
- Different actor identities may execute concurrently.
755755
- A worker drains at most `maxMessagesPerActivationPass` turns from one actor,
756756
then yields its still-due work behind actors that were already waiting.
757+
- Long-running workers reuse hydrated actors for
758+
`idleDeactivationTimeoutMilliseconds` while renewing the same fenced lease.
757759
- State, completion, staged messages, effects, reminders, commit actions, and
758760
observable broadcasts share one fenced commit.
759761
- A lost or expired activation lease cannot commit.
@@ -762,6 +764,11 @@ IDs and observable values are not authorization.
762764
- Effects can execute more than once.
763765
- Results and snapshots are deeply frozen copies.
764766
767+
Override protected `onActivate()` and `onDeactivate()` methods when an actor
768+
needs a process-local resource during that window. Hooks may be asynchronous,
769+
cannot write through a guarded application database, and are nondurable;
770+
`onDeactivate()` is best effort and must not carry correctness work.
771+
765772
## Current scope
766773
767774
The current runtime supports Node.js 24, SQLite through built-in `node:sqlite`,

docs/architecture.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,15 @@ moves only that actor's already-due ready memberships to current database time.
5555
Actors with older ready work therefore win the next global claim; delayed work
5656
keeps its original future availability.
5757

58+
When a pass becomes idle, a long-running worker keeps the hydrated actor and
59+
continues renewing the same fenced lease until its idle timeout. A later turn
60+
on that actor reuses both its persisted public fields and process-local private
61+
fields. Failed and rejected turns restore public fields to their pre-turn
62+
values before reuse. Fairness yield, timeout, lease loss, and shutdown run the
63+
best-effort deactivation hook and conditionally release the matching lease.
64+
One-shot drain helpers release immediately because they will not remain alive
65+
to renew.
66+
5867
Realtime delivery is transport-neutral. A host-authenticated session authorizes
5968
actor subscriptions, replays a committed observable projection, and follows
6069
the durable broadcast outbox in revision order. The browser client applies the

docs/correctness.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
- Activation passes are bounded. Yielding changes ready-membership polling
1818
order only; it neither changes durable message sequence nor makes future work
1919
due early.
20+
- Idle hydrated actors remain fenced by the same renewable lease. Cache reuse
21+
never bypasses claim membership or the commit fence, failed turns restore
22+
their public fields before reuse, and conditional release cannot clear a
23+
newer owner or generation.
2024
- `guardApplicationDatabase()` rejects direct application writes during actor
2125
operations, observable and payload projections, and state migrations. It
2226
permits only `SELECT` through row-returning methods; commit actions remain the

docs/operations.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,20 @@ when a few actor identities stay continuously busy; higher values reduce claim
2121
overhead for isolated backlogs. `solid_objects.activation.yielded` reports the
2222
actor identity, turns processed, and remaining due membership count.
2323

24+
Workers retain a hydrated actor and its fenced lease for
25+
`idleDeactivationTimeoutMilliseconds`, which defaults to 30 seconds. Idle
26+
leases renew at `leaseRenewalIntervalMilliseconds`; the worker polling cadence
27+
is capped at that interval while any activation may be cached. Fairness yield,
28+
lease loss, timeout, and shutdown release the lease. `runUntilIdle()` and the
29+
runtime's synchronous caller release before returning because they are no
30+
longer polling.
31+
32+
Actors can override protected `onActivate()` and `onDeactivate()` methods for
33+
nondurable, process-local resources. Either hook may be asynchronous. Hook code
34+
runs under the application-write guard, and `onDeactivate()` is best effort:
35+
it may not run after a crash, cannot establish a correctness guarantee, and a
36+
failure is logged without preventing lease release.
37+
2438
`runtime.processes.all()` returns administration-authorized immutable process
2539
metadata with a current `stale` flag. `cleanup()` reauthorizes separately and
2640
atomically fences stale processes out of every owned role claim before waking

docs/parity.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ Reference: Ruby `solid_objects` 0.12.0 at commit `a01b6f5`.
2626
| Ordered mailbox, sequence allocation, idempotency, retries, dead letters, leases, renewal, and fenced commits | Native | Relational ready/claimed membership tables, durable history, and adapter-appropriate sequence locking. |
2727
| Domain rejection and strict poison ordering | Native | Rejections roll back without retry; retryable failures block later operations until completion or dead-lettering. |
2828
| Bounded activation passes and hot-actor fairness | Native | Workers preferentially drain one actor to a configurable cap, then move only its already-due memberships behind actors already waiting. |
29-
| Idle activation cache | Planned | Add short-lived hydrated actor reuse with lease renewal and pressure eviction without weakening fenced commits. |
29+
| Idle activation cache | Native | Long-running workers retain hydrated actors under renewable fenced leases, restore public state after failed turns, and release on timeout, fairness yield, lease loss, or shutdown. |
3030
| Transactional effects and outcome operations | Native | At-least-once effect handlers with stable IDs and success/failure actor operations. |
3131
| Actor-to-actor delivery | Native | `sendTo(reference).operation()` stages delivery in the source actor commit. |
3232
| One-shot and recurring reminders | Native | Scheduling, replacement events, catch-up policy, stale-claim recovery, pausing, authorized inspection, and idempotent resume are implemented. |

src/actor.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,18 @@ export abstract class Actor {
117117
return {}
118118
}
119119

120+
async activate(): Promise<void> {
121+
await this.onActivate()
122+
}
123+
124+
async deactivate(): Promise<void> {
125+
await this.onDeactivate()
126+
}
127+
128+
protected onActivate(): void | Promise<void> {}
129+
130+
protected onDeactivate(): void | Promise<void> {}
131+
120132
reject(code: string, options: { message: string; details?: Record<string, unknown> }): never {
121133
if (!/^[a-z][a-z0-9_]*$/.test(code)) {
122134
throw new TypeError("rejection code must contain lowercase letters, digits, and underscores")

src/configuration.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface SolidObjectsConfiguration {
4343
syncPollingIntervalMilliseconds?: number
4444
leaseDurationMilliseconds?: number
4545
leaseRenewalIntervalMilliseconds?: number
46+
idleDeactivationTimeoutMilliseconds?: number
4647
maxMailboxLength?: number
4748
maxPayloadBytes?: number
4849
maxStateBytes?: number
@@ -107,6 +108,8 @@ export function buildSettings(configuration: SolidObjectsConfiguration): Runtime
107108
syncPollingIntervalMilliseconds: configuration.syncPollingIntervalMilliseconds ?? 50,
108109
leaseDurationMilliseconds: configuration.leaseDurationMilliseconds ?? 30_000,
109110
leaseRenewalIntervalMilliseconds: configuration.leaseRenewalIntervalMilliseconds ?? 10_000,
111+
idleDeactivationTimeoutMilliseconds:
112+
configuration.idleDeactivationTimeoutMilliseconds ?? 30_000,
110113
maxMailboxLength: configuration.maxMailboxLength ?? 10_000,
111114
maxPayloadBytes: configuration.maxPayloadBytes ?? 1_048_576,
112115
maxStateBytes: configuration.maxStateBytes ?? 5_242_880,
@@ -212,6 +215,12 @@ function validateSettings(settings: RuntimeSettings): void {
212215
if (settings.leaseDurationMilliseconds <= settings.leaseRenewalIntervalMilliseconds) {
213216
throw new TypeError("leaseDurationMilliseconds must exceed leaseRenewalIntervalMilliseconds")
214217
}
218+
if (
219+
!Number.isFinite(settings.idleDeactivationTimeoutMilliseconds) ||
220+
settings.idleDeactivationTimeoutMilliseconds < 0
221+
) {
222+
throw new TypeError("idleDeactivationTimeoutMilliseconds must be non-negative")
223+
}
215224
if (
216225
settings.supervisorMaximumRestartDelayMilliseconds < settings.supervisorRestartDelayMilliseconds
217226
) {

src/doctor.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,12 @@ export class Doctor {
358358
instanceId: message.instance_id,
359359
})
360360
if (!turn) throw new Error("doctor could not claim its probe message")
361-
await this.runtime.executeTurn(turn)
361+
const execution = await this.runtime.executeTurn(turn)
362+
await this.runtime.deactivateActor({
363+
turn,
364+
actor: execution.actor,
365+
lifecycle: execution.activated ? "activated" : "unactivated",
366+
})
362367
const completed = await this.runtime.repository.findMessage(message.id)
363368
if (completed?.result === null || JSON.parse(completed?.result ?? "null") !== value) {
364369
throw new Error("doctor round trip returned an unexpected result")

src/records.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,13 @@ export interface ClaimedTurn {
6868
nowMilliseconds: number
6969
}
7070

71+
export interface ActivationLease {
72+
instanceId: string
73+
processId: string
74+
activationToken: string
75+
activationGeneration: bigint
76+
}
77+
7178
export interface EnqueueInput {
7279
actorType: string
7380
actorId: string

0 commit comments

Comments
 (0)