fix: a character save commits atomically or not at all - #2356
Conversation
SaveService walked ten DAOs sequentially, each on its own context, so a crash or a swallowed DAO failure mid-save persisted half a character - gold without inventory, quests without objectives. DAO calls on the current async flow now share one transaction via IDaoTransactionScope: the DbContext registration consults an AsyncLocal slot an active scope fills, every operation's result is checked, and the commit happens only after all of them succeed. AsyncLocal keeps the parallel save-all path isolated per character. Begin is synchronous on purpose - an AsyncLocal written inside an awaited method does not reach the caller's flow. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (5)
Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review. WalkthroughSaveService now executes character persistence through one DAO transaction. New persistence interfaces, ambient database-context handling, dependency registrations, commit and rollback behavior, and in-memory test configuration support this flow. ChangesTransactional character saves
Estimated code review effort: 4 (Complex) | ~45 minutes Merge Risk: 🔵 Low · up to Character saves now commit as one unit, preventing ordinary failures from leaving partially persisted state. Mergeability is otherwise reasonable, but transaction-start failures could retain database resources and nested save scopes could allow later work outside the intended transaction, so explicit owner awareness or follow-up is recommended. Sequence Diagram(s)sequenceDiagram
participant SaveService
participant IDaoTransactionScope
participant DAO
participant NosCoreContext
SaveService->>IDaoTransactionScope: Begin()
IDaoTransactionScope->>NosCoreContext: Start database transaction
IDaoTransactionScope-->>SaveService: Return transaction
SaveService->>DAO: Execute persistence operations
DAO->>NosCoreContext: Use ambient context
DAO-->>SaveService: Return operation result
alt All operations succeed
SaveService->>IDaoTransactionScope: CommitAsync()
IDaoTransactionScope->>NosCoreContext: Commit transaction
else An operation fails
SaveService->>IDaoTransactionScope: DisposeAsync()
IDaoTransactionScope->>NosCoreContext: Dispose without commit
end
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1📝 Generate docstrings 💡
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Architecture-review PR 3: save atomicity.
Problem
SaveService.SaveAsyncwalks ~10 DAOs sequentially — account, character, quicklist, inventory (two tables, FK-ordered), bonuses, titles, miniland, quests, objectives, respawns — and each DAO op runs on its own DbContext with its own SaveChanges. A crash mid-save, or any of the DAO-swallowed failures, persists half a character: gold updated but inventory not, quests without their objectives. Only two ops even checked their results (for FK-cascade noise, not consistency).Change
IDaoTransactionScope(NosCore.Core, no EF dependency) /DaoTransactionScope(NosCore.Database):Begin()opens one context + transaction and publishes the context in an AsyncLocal slot; the AutofacDbContextregistration consults that slot before building a fresh context, so every DAO call on the same async flow lands in the transaction.Task.WhenAllsave-all stays safe — AsyncLocal isolates concurrent flows per character.Begin()is deliberately synchronous: an AsyncLocal written inside an awaited method doesn't flow back to the caller.SaveAsyncwraps the whole walk in a scope, checks every DAO result (they swallow exceptions and report via return values), and commits only at the end; any failure or exception rolls the entire save back. FK insert/delete ordering preserved (Postgres validates per statement).DAO behavior outside a scope is unchanged (fresh context per op, as before). Tests construct DAOs with raw context builders so they bypass the ambient path; the InMemory provider ignores transactions via the standard warning suppression.
Noted for a NosCore.Dao follow-up:
Daonever disposes the contexts it builds.Verification
Build clean; GameObject.Tests (incl. SaveService persistence specs), PacketHandlers.Tests, Database.Tests all pass.
Merge note: overlaps
SaveService.cswith the upcoming LastSp-persistence PR — trivial rebase whichever lands second.🤖 Generated with Claude Code
Summary by CodeRabbit
Reliability
Bug Fixes