feat(rivetkit): isolate includeState transaction reads with a committed snapshot - #5638
Conversation
|
Stack for rivet-dev/actors Get stack: change snxxvxko |
|
🚅 Deployed to the actors-pr-5638 environment in rivet-frontend
|
|
Review (re-checked New finding: In If any of the native calls while building
Compare with Carried over from the previous review pass (still valid, no code has changed):
What holds up well
Solid fix for a real dirty-read/durability bug. The exception-safety gap on the setup path is worth closing before merge given the severity (silent, hard-to-detect state corruption) if it is ever hit. |
805dc7a to
8b21384
Compare
8b21384 to
575b775
Compare
| const connSnapshot = isStateTransactionOwner | ||
| ? undefined | ||
| : actorState.committedConnStateSnapshot; | ||
| const connHibernation = callNativeSync(() => |
There was a problem hiding this comment.
🔴 High · Preserve connection updates after a snapshot save
A non-owner saveState({ immediate: true }) during an includeState transaction serializes the old bytes here, but the successful save consumes the core's pending hibernation update for the connection. The transaction owner later calls serializeForTick to commit, finds no dirty connection in actorDirtyHibernatableConns, and therefore omits its new connection state from the atomic commit. The SQL transaction succeeds while the connection reverts to the pre-transaction state after hibernation/reload.
Keep or restore the connection's pending update when serializing a non-owner snapshot (or otherwise ensure the owner commit emits every connection changed in the transaction). Add a regression that changes hibernatable connection state in a held transaction, performs a concurrent immediate save, commits, then reloads.
…ed 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. The same held for hibernatable
connection state — and worse: a background save tick (serializeForTick, used
for periodic/sleep saves) could serialize and durably persist uncommitted
connection bytes, which rollback does not undo in storage.
Snapshot the committed actor and connection state when the transaction opens.
The transaction owner keeps mutating 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:
- actor state: ActorContextHandleAdapter#readState returns the snapshot for
non-owner contexts.
- connection state: NativeConnAdapter#readState returns the committed
connection snapshot for non-owner contexts (gated by a new
ownsActiveStateTransaction() predicate threaded like assertCanMutateState),
and serializeForTick serializes the committed connection bytes for non-owner
contexts so a background save can't durably flush uncommitted connection
state.
Snapshots are 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 — internal_error on stateTransactionCommit); validated by typecheck, the
mock-provider unit tests, biome, and review.
575b775 to
5e50428
Compare
| const connSnapshot = isStateTransactionOwner | ||
| ? undefined | ||
| : actorState.committedConnStateSnapshot; | ||
| const connHibernation = callNativeSync(() => |
There was a problem hiding this comment.
🔴 High · Preserve connection updates after a snapshot save
A non-owner saveState({ immediate: true }) during an includeState transaction serializes the old bytes here, but the successful save consumes the core's pending hibernation update for the connection. The transaction owner later calls serializeForTick to commit, finds no dirty connection in actorDirtyHibernatableConns, and therefore omits its new connection state from the atomic commit. The SQL transaction succeeds while the connection reverts to the pre-transaction state after hibernation/reload.
Keep or restore the connection's pending update when serializing a non-owner snapshot (or otherwise ensure the owner commit emits every connection changed in the transaction). Add a regression that changes hibernatable connection state in a held transaction, performs a concurrent immediate save, commits, then reloads.
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. The same held for hibernatable
connection state — and worse: a background save tick (serializeForTick, used
for periodic/sleep saves) could serialize and durably persist uncommitted
connection bytes, which rollback does not undo in storage.
Snapshot the committed actor and connection state when the transaction opens.
The transaction owner keeps mutating 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:
non-owner contexts.
connection snapshot for non-owner contexts (gated by a new
ownsActiveStateTransaction() predicate threaded like assertCanMutateState),
and serializeForTick serializes the committed connection bytes for non-owner
contexts so a background save can't durably flush uncommitted connection
state.
Snapshots are 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 — internal_error on stateTransactionCommit); validated by typecheck, the
mock-provider unit tests, biome, and review.