Skip to content

Commit ef76665

Browse files
committed
Fence commit action writes
1 parent c663d68 commit ef76665

6 files changed

Lines changed: 59 additions & 21 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+
- Keep guarded application databases read-only during commit actions so the
6+
supplied fenced transaction connection is the only atomic write path.
57
- Persist the start of graceful runtime-role shutdown and expose its timestamp
68
so operators can distinguish actively draining processes from dead owners.
79
- Release actor, message, effect, reminder, and broadcast ownership atomically

‎README.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -442,10 +442,10 @@ const applicationDatabase = guardApplicationDatabase(sqlite({ path: "application
442442

443443
During actor execution, observable and payload projection, and state migration,
444444
the facade permits `SELECT` through `get()` and `all()` and rejects `run()` or
445-
row-returning write statements. Registered commit actions run outside that
446-
read-only context and may write through the same facade. This boundary is
447-
opt-in: Solid Objects cannot intercept a separate ORM pool or an unwrapped
448-
database client.
445+
row-returning write statements. A commit action stays inside the same read-only
446+
context and writes only through its supplied fenced `context.connection`. This
447+
boundary is opt-in: Solid Objects cannot intercept a separate ORM pool or an
448+
unwrapped database client.
449449

450450
## Inspect and retry terminal failures
451451

‎docs/correctness.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,9 +35,9 @@
3535
newer owner or generation.
3636
- `guardApplicationDatabase()` rejects direct application writes during actor
3737
operations, observable and payload projections, and state migrations. It
38-
permits only `SELECT` through row-returning methods; commit actions remain the
39-
fenced write path. The guarantee applies only to clients passed through the
40-
facade.
38+
permits only `SELECT` through row-returning methods. Commit actions remain in
39+
that read-only context and may write only through their supplied fenced
40+
connection. The guarantee applies only to clients passed through the facade.
4141
- Snapshots hydrate one committed state image and evaluate every inferred getter
4242
against it. Getter mutation or staged durable work rejects the whole snapshot;
4343
successful snapshots and their nested JSON values are frozen copies.

‎docs/parity.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Reference: Ruby `solid_objects` 0.12.0 at commit `a01b6f5`.
3333
| One-shot and recurring reminders | Native | Scheduling, replacement events, catch-up policy, stale-claim recovery, pausing, authorized inspection, and idempotent resume are implemented. |
3434
| Same-database commit actions | Native | Registered actions receive source-message identity, mailbox sequence, activation generation, and the fenced transaction connection. |
3535
| Ambient transaction rejection | Native | Committed calls and message waits fail before blocking when the current async context already owns a transaction on the Solid Objects adapter. |
36-
| Direct application-write isolation during actor code | Partial | `guardApplicationDatabase()` fails closed for operations, projections, and migrations, while registered commit actions remain writable. Unwrapped ORM pools and third-party clients cannot be intercepted. |
36+
| Direct application-write isolation during actor code | Partial | `guardApplicationDatabase()` fails closed for operations, projections, migrations, and commit actions; only the supplied fenced commit-action connection may write. Unwrapped clients cannot be intercepted. |
3737
| Committed snapshots | Native | `snapshot()` returns authorized persisted fields and inferred getters from one read-only committed state image; realtime replay reads explicit observables without mailbox history. |
3838
| Actor destruction and incarnation fencing | Native | Authorized cascading deletion creates a fresh instance ID on recreation; an authorized waiter receives `ActorDestroyed` when that incarnation disappears. |
3939
| Result recovery and sync timeout diagnostics | Native | Status, result, and wait reauthorize the stored operation; terminal failure raises structured `MessageFailed`; whole-call adapter deadlines distinguish enqueue, wait, database, activation, and mailbox blockers. |

‎src/runtime.ts‎

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,12 @@ import {
88
type ComponentRegistration,
99
type SolidObjectsConfiguration,
1010
} from "./configuration.js"
11-
import { currentActor, withActorContext, withActorProjection } from "./context.js"
11+
import {
12+
currentActor,
13+
withActorContext,
14+
withActorProjection,
15+
withApplicationWritesForbidden,
16+
} from "./context.js"
1217
import { DeadLetterManager, type DeadLetter } from "./dead-letters.js"
1318
import { Doctor } from "./doctor.js"
1419
import { clearDefaultRuntime, setDefaultRuntime } from "./default-runtime.js"
@@ -1149,15 +1154,17 @@ export class SolidObjectsRuntime {
11491154
}
11501155
this.emitInstrumentation("commit_action.started", attributes)
11511156
try {
1152-
await handler(intent.arguments, {
1153-
actorType: turn.message.actor_type,
1154-
actorId: turn.message.actor_id,
1155-
messageId: turn.message.id,
1156-
requestId: turn.message.request_id,
1157-
sequence: BigInt(turn.message.sequence),
1158-
activationGeneration: turn.activationGeneration,
1159-
connection,
1160-
})
1157+
await withApplicationWritesForbidden(() =>
1158+
handler(intent.arguments, {
1159+
actorType: turn.message.actor_type,
1160+
actorId: turn.message.actor_id,
1161+
messageId: turn.message.id,
1162+
requestId: turn.message.request_id,
1163+
sequence: BigInt(turn.message.sequence),
1164+
activationGeneration: turn.activationGeneration,
1165+
connection,
1166+
}),
1167+
)
11611168
this.emitInstrumentation("commit_action.completed", attributes)
11621169
} catch (error) {
11631170
this.emitInstrumentation("commit_action.failed", {

‎test/application-database.test.ts‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ class DatabaseActor extends Actor {
3636
writeAfterCommit(): void {
3737
this.commitAction("writeRecord", { value: "committed" })
3838
}
39+
40+
writeOutsideFence(): void {
41+
this.commitAction("writeExternalRecord", { value: "escaped" })
42+
}
3943
}
4044

4145
afterEach(async () => {
@@ -57,12 +61,30 @@ describe("guarded application database", () => {
5761
await expect(actor.read()).resolves.toBe("initial")
5862
})
5963

60-
it("allows a registered commit action to use the same facade", async () => {
64+
it("allows a registered commit action to use its fenced connection", async () => {
6165
await configureDatabases()
6266

6367
await DatabaseActor.ref("commit").writeAfterCommit()
6468

65-
await expect(DatabaseActor.ref("commit").read()).resolves.toBe("committed")
69+
const record = await runtime?.settings.database.connection((connection) =>
70+
connection.get<{ value: string }>("SELECT value FROM committed_records WHERE id = 1"),
71+
)
72+
expect(record?.value).toBe("committed")
73+
})
74+
75+
it("rolls back a commit action that writes outside its fenced connection", async () => {
76+
await configureDatabases()
77+
const message = await DatabaseActor.ref("external").send.writeOutsideFence()
78+
79+
expect(await runtime?.worker().runUntilIdle()).toBe(1)
80+
81+
await expect(message.result()).rejects.toMatchObject({
82+
details: {
83+
name: "ApplicationWriteForbidden",
84+
message: "application database writes are forbidden during actor execution",
85+
},
86+
})
87+
await expect(DatabaseActor.ref("external").read()).resolves.toBe("initial")
6688
})
6789

6890
it("rejects write statements disguised as row-returning reads", async () => {
@@ -129,7 +151,14 @@ async function configureDatabases(): Promise<void> {
129151
maxAttempts: 1,
130152
})
131153
runtime.register(DatabaseActor)
132-
runtime.registerCommitAction("writeRecord", async ({ value }) => {
154+
await runtime.settings.database.connection(async (connection) => {
155+
await connection.run("CREATE TABLE committed_records (id INTEGER PRIMARY KEY, value TEXT)")
156+
await connection.run("INSERT INTO committed_records (id, value) VALUES (1, 'initial')")
157+
})
158+
runtime.registerCommitAction("writeRecord", async ({ value }, context) => {
159+
await context.connection.run("UPDATE committed_records SET value = ? WHERE id = 1", [value])
160+
})
161+
runtime.registerCommitAction("writeExternalRecord", async ({ value }) => {
133162
await applicationDatabase.connection((connection) =>
134163
connection.run("UPDATE records SET value = ? WHERE id = 1", [value]),
135164
)

0 commit comments

Comments
 (0)