Skip to content

Commit 8603127

Browse files
committed
fix: preserve actor runtime context
1 parent 2a89eb6 commit 8603127

6 files changed

Lines changed: 67 additions & 19 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
## Unreleased
44

5+
- Preserve isolated runtime ownership across operation, lifecycle, observable,
6+
and payload callbacks while keeping lifecycle callbacks message-free.
57
- Retry transient SQLite writer acquisition with bounded capped backoff without
68
replaying transaction callbacks or extending synchronous deadlines.
79
- Expose concise `configure()` and isolated `createRuntime()` entry points;

‎docs/correctness.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
- Permanent operation failure raises `MessageFailed` with the durable message
1313
ID and persisted error details instead of treating actor code text as the
1414
public exception contract.
15+
- Operation, lifecycle, observable, and payload callbacks retain their owning
16+
runtime through async context. Isolated runtimes therefore never fall back to
17+
a global default while actor-owned code resolves another actor reference.
1518
- Observable outbox rows are claimed in actor revision order. Subscription
1619
sessions and browser clients reject duplicate or stale revisions within an
1720
incarnation, while a recreated actor starts a new revision sequence.

‎docs/parity.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ Reference: Ruby `solid_objects` 0.12.0 at commit `a01b6f5`.
2121

2222
| Capability | Status | TypeScript shape or remaining work |
2323
| ------------------------------------------------------------------------------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
24-
| Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, and explicit migrations. |
24+
| Actor registry, durable identity, JSON state, and adjacent state migrations | Native | Ordinary classes, static actor types, inferred state, explicit migrations, and isolated runtime context across every actor-instance callback. |
2525
| Fluent committed calls and background delivery | Native | `await reference.operation()` and `reference.send.operation()`. |
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. |

‎src/context.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ interface ExecutionContext {
1313
interface ActorExecutionContext {
1414
actor: Actor
1515
runtime: SolidObjectsRuntime
16-
message: MessageContext
16+
message?: MessageContext
1717
}
1818

1919
const storage = new AsyncLocalStorage<ExecutionContext>()

‎src/runtime.ts‎

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,7 @@ import {
88
type ComponentRegistration,
99
type SolidObjectsConfiguration,
1010
} from "./configuration.js"
11-
import {
12-
currentActor,
13-
withActorContext,
14-
withActorProjection,
15-
withApplicationWritesForbidden,
16-
} from "./context.js"
11+
import { currentActor, withActorContext, withActorProjection } from "./context.js"
1712
import { DeadLetterManager, type DeadLetter } from "./dead-letters.js"
1813
import { Doctor } from "./doctor.js"
1914
import { clearDefaultRuntime, setDefaultRuntime } from "./default-runtime.js"
@@ -104,6 +99,7 @@ import type {
10499
JsonObject,
105100
JsonValue,
106101
LongRunningComponent,
102+
MessageContext,
107103
MessageStatus,
108104
SnapshotOptions,
109105
} from "./types.js"
@@ -1019,14 +1015,7 @@ export class SolidObjectsRuntime {
10191015
state: deepCopy(state),
10201016
})
10211017
let activated = cachedActor !== undefined
1022-
const messageContext = {
1023-
id: turn.message.id,
1024-
requestId: turn.message.request_id,
1025-
actorType: turn.message.actor_type,
1026-
actorId: turn.message.actor_id,
1027-
sequence: BigInt(turn.message.sequence),
1028-
attempt: Number(turn.message.attempt_count),
1029-
}
1018+
const messageContext = actorMessageContext(turn.message)
10301019
const renewalController = new AbortController()
10311020
let renewalError: unknown
10321021
const renewal = this.renewLease(turn, renewalController.signal).catch((error: unknown) => {
@@ -1036,7 +1025,7 @@ export class SolidObjectsRuntime {
10361025

10371026
try {
10381027
if (!activated) {
1039-
await withApplicationWritesForbidden(() => actor.activate())
1028+
await withActorContext({ actor, runtime: this }, () => actor.activate())
10401029
activated = true
10411030
this.emitInstrumentation("activation.started", {
10421031
actorType: turn.message.actor_type,
@@ -1191,7 +1180,7 @@ export class SolidObjectsRuntime {
11911180
const { turn, actor } = options
11921181
try {
11931182
if (actor && options.lifecycle === "activated") {
1194-
await withApplicationWritesForbidden(() => actor.deactivate())
1183+
await withActorContext({ actor, runtime: this }, () => actor.deactivate())
11951184
}
11961185
} catch (error) {
11971186
this.emitInstrumentation("activation.deactivation_failed", {
@@ -1537,7 +1526,7 @@ export class SolidObjectsRuntime {
15371526
private readObservables(actor: Actor, definition: ValidatedActorDefinition): JsonObject {
15381527
const stateBefore = stableJson(actorState(actor, definition.stateKeys))
15391528
const intentCount = actor.intentCount()
1540-
const values = withApplicationWritesForbidden(() => actor.observableValues())
1529+
const values = withActorProjection({ actor, runtime: this }, () => actor.observableValues())
15411530
if (
15421531
stableJson(actorState(actor, definition.stateKeys)) !== stateBefore ||
15431532
actor.intentCount() !== intentCount
@@ -1793,6 +1782,17 @@ function messageInstrumentation(message: MessageRow): JsonObject {
17931782
}
17941783
}
17951784

1785+
function actorMessageContext(message: MessageRow): MessageContext {
1786+
return {
1787+
id: message.id,
1788+
requestId: message.request_id,
1789+
actorType: message.actor_type,
1790+
actorId: message.actor_id,
1791+
sequence: BigInt(message.sequence),
1792+
attempt: Number(message.attempt_count),
1793+
}
1794+
}
1795+
17961796
function restoreActorState(options: {
17971797
actor: Actor
17981798
definition: ValidatedActorDefinition

‎test/runtime.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,11 +72,39 @@ class Account extends Actor {
7272
}
7373
}
7474

75+
class IsolatedLifecycle extends Actor {
76+
static override readonly actorType = "IsolatedLifecycle"
77+
static deactivationTargetType: string | null = null
78+
static lifecycleHadMessage = false
79+
80+
count = 0
81+
82+
protected override onActivate(): void {
83+
IsolatedLifecycle.lifecycleHadMessage ||= this.currentMessage !== undefined
84+
this.sendTo(AuditLog.ref("lifecycle")).record({ eventName: "activated" })
85+
}
86+
87+
protected override onDeactivate(): void {
88+
IsolatedLifecycle.lifecycleHadMessage ||= this.currentMessage !== undefined
89+
IsolatedLifecycle.deactivationTargetType = AuditLog.ref("lifecycle").actorType
90+
}
91+
92+
increment(): void {
93+
this.count += 1
94+
}
95+
96+
override observables(): Record<string, unknown> {
97+
return { count: this.count, targetType: AuditLog.ref("lifecycle").actorType }
98+
}
99+
}
100+
75101
let runtime: SolidObjectsRuntime | undefined
76102

77103
afterEach(async () => {
78104
await runtime?.close()
79105
runtime = undefined
106+
IsolatedLifecycle.deactivationTargetType = null
107+
IsolatedLifecycle.lifecycleHadMessage = false
80108
})
81109

82110
describe("typed actor references", () => {
@@ -91,6 +119,21 @@ describe("typed actor references", () => {
91119
await expect(runtime.ref(AuditLog, "audit").events).resolves.toEqual(["account_disabled"])
92120
})
93121

122+
it("keeps lifecycle and observable callbacks inside their isolated runtime", async () => {
123+
runtime = createRuntime(configuredSettings())
124+
runtime.register(IsolatedLifecycle)
125+
runtime.register(AuditLog)
126+
await runtime.install()
127+
const message = await runtime.ref(IsolatedLifecycle, "source").send.increment()
128+
129+
await runtime.testing.drain({ roles: ["actors"] })
130+
131+
await expect(message.result()).resolves.toBeNull()
132+
await expect(runtime.ref(AuditLog, "lifecycle").events).resolves.toEqual(["activated"])
133+
expect(IsolatedLifecycle.deactivationTargetType).toBe(AuditLog.actorType)
134+
expect(IsolatedLifecycle.lifecycleHadMessage).toBe(false)
135+
})
136+
94137
it("invokes messages and reads fields and getters as committed queries", async () => {
95138
runtime = configuredRuntime()
96139
await runtime.install()

0 commit comments

Comments
 (0)