Skip to content

Commit 805dc7a

Browse files
committed
feat(rivetkit): isolate includeState transaction reads with a committed snapshot
An includeState state transaction mutated actor state in place, so a concurrent action reading state mid-transaction observed the owner's uncommitted writes (a dirty read); the writes only reverted on rollback. That gave atomic commit but not read isolation. Take a structured-clone snapshot of the committed state when the transaction opens. The transaction owner keeps mutating the live state (so commit/rollback, onStateChange, and retained-proxy semantics are unchanged), but every non-owner context — actions, runtime save ticks, the inspector, sleep saves — reads the snapshot instead. Concurrent readers therefore observe only committed values until the owner commits, and a save driven from a non-owner context can no longer serialize uncommitted state. The snapshot is torn down on transaction exit. Note: the driver-suite state-transaction tests require the native engine and could not be run in this environment (they fail identically on unmodified main); validated by typecheck, the mock-provider unit tests, and review.
1 parent 49693dc commit 805dc7a

4 files changed

Lines changed: 68 additions & 4 deletions

File tree

rivetkit-typescript/packages/rivetkit/fixtures/driver-test-suite/actor-db-raw.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ export const dbActorRaw = actor({
400400
}
401401
await c.vars.stateTransactionStarted.promise;
402402
},
403+
readAtomicStateValue: (c) => c.state.atomicStateValue,
403404
mutateStateDuringTransaction: async (c, value: string) => {
404405
try {
405406
c.state.atomicStateValue = value;

rivetkit-typescript/packages/rivetkit/src/common/database/config.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ export interface SqliteTransactionOptions {
3939
* Atomically includes actor and hibernatable connection state.
4040
* Only single-statement `execute` calls are supported in the transaction.
4141
* Concurrent actions that try to mutate state while the transaction is
42-
* active fail with `actor.state_transaction_conflict`.
42+
* active fail with `actor.state_transaction_conflict`. Concurrent reads
43+
* observe the committed state (a snapshot taken when the transaction
44+
* opened), never the transaction's uncommitted writes.
4345
*/
4446
includeState?: boolean;
4547
};

rivetkit-typescript/packages/rivetkit/src/registry/native.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,11 @@ type NativePersistActorState = {
301301
pendingStateTransactionOwners?: Set<symbol>;
302302
stateTransactionTail?: Promise<void>;
303303
stateTransactionSaveDeferred?: boolean;
304+
// Present only while an includeState transaction is active and state is
305+
// enabled. Holds a structured clone of the state as of transaction start.
306+
// The owner keeps mutating the live `state`; every other context reads this
307+
// snapshot so it observes only committed values until the owner commits.
308+
committedStateSnapshot?: { value: unknown };
304309
};
305310
type NativeDestroyGate = {
306311
destroyCompletion?: Promise<void>;
@@ -3114,11 +3119,21 @@ export class ActorContextHandleAdapter {
31143119
pendingOwners.delete(this.#stateTransactionOwner);
31153120
actorState.activeStateTransactionOwner =
31163121
this.#stateTransactionOwner;
3122+
// Snapshot the committed state up front. The owner mutates the live
3123+
// `state` in place; every non-owner context reads this snapshot
3124+
// instead, so actions observe only committed values while the
3125+
// transaction is open. Doubles as the rollback baseline.
3126+
const actorStateBaseline = this.#stateEnabled
3127+
? structuredClone(this.#readState())
3128+
: undefined;
3129+
if (this.#stateEnabled) {
3130+
actorState.committedStateSnapshot = {
3131+
value: actorStateBaseline,
3132+
};
3133+
}
31173134
return {
31183135
actorContext: this,
3119-
actorStateBaseline: this.#stateEnabled
3120-
? structuredClone(this.#readState())
3121-
: undefined,
3136+
actorStateBaseline,
31223137
connectionStateBaselines: new Map(
31233138
callNativeSync(() =>
31243139
this.#runtime.actorConns(this.#ctx),
@@ -3152,6 +3167,9 @@ export class ActorContextHandleAdapter {
31523167
this.#restoreStateTransactionBaseline(scope);
31533168
}
31543169
} finally {
3170+
// Tear down the read snapshot so non-owner contexts see the
3171+
// committed (or restored) live state again.
3172+
actorState.committedStateSnapshot = undefined;
31553173
if (
31563174
actorState.activeStateTransactionOwner ===
31573175
this.#stateTransactionOwner
@@ -3467,6 +3485,17 @@ export class ActorContextHandleAdapter {
34673485
callNativeSync(() => this.#runtime.actorState(this.#ctx)),
34683486
);
34693487
}
3488+
// While a transaction owner is mutating the live state, every other
3489+
// context reads the committed snapshot so it never observes the owner's
3490+
// uncommitted writes. The owner itself keeps reading the live state.
3491+
const snapshot = actorState.committedStateSnapshot;
3492+
if (
3493+
snapshot !== undefined &&
3494+
actorState.activeStateTransactionOwner !==
3495+
this.#stateTransactionOwner
3496+
) {
3497+
return snapshot.value;
3498+
}
34703499
return actorState.state;
34713500
}
34723501

rivetkit-typescript/packages/rivetkit/tests/driver/actor-db.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -825,6 +825,38 @@ describeDriverMatrix(
825825
dbTestTimeout,
826826
);
827827

828+
test(
829+
"exposes only committed state to concurrent reads during a state transaction",
830+
async (c) => {
831+
const { client } = await setupDriverTest(
832+
c,
833+
driverTestConfig,
834+
);
835+
const actor = getDbActor(client, variant).getOrCreate([
836+
`db-${variant}-state-tx-read-iso-${crypto.randomUUID()}`,
837+
]);
838+
await actor.reset();
839+
// Commit a known baseline so reads have a committed value.
840+
await actor.stateTransactionCommit("committed");
841+
842+
const rollback =
843+
actor.stateTransactionHoldAndRollback("held");
844+
await actor.waitForStateTransaction();
845+
// A concurrent (non-owner) action reads the committed value,
846+
// never the owner's uncommitted "held" write.
847+
expect(await actor.readAtomicStateValue()).toBe(
848+
"committed",
849+
);
850+
await actor.releaseStateTransaction();
851+
expect(await rollback).toBe("committed");
852+
// The committed value is still what reads observe afterward.
853+
expect(await actor.readAtomicStateValue()).toBe(
854+
"committed",
855+
);
856+
},
857+
dbTestTimeout,
858+
);
859+
828860
test(
829861
"queues state transactions from separate actions",
830862
async (c) => {

0 commit comments

Comments
 (0)