Interface ICharacterSessionOwnershipService
- Namespace
- FishMMO.Database.Npgsql.Services.Interfaces
- Assembly
- FishMMO-DB.dll
Proves — inside an open unit of work — that the calling server is still entitled to write a character's state, and holds that entitlement still for the remainder of the transaction.
public interface ICharacterSessionOwnershipService
Remarks
WHY THIS EXISTS. Item writes had no ownership check of any kind. Every one of them was "whoever gets here writes", which is a dupe waiting to happen in three separate ways:
- A player signs in on scene server A while still connected to B. Both processes hold a live in-memory copy of the same containers, and both persist. The later writer wins per slot, so the two inventories interleave and an item can end up recorded twice.
- A scene transfer lands mid-operation. The source server has already mutated memory and enqueued the write; the destination has already loaded the character from the database. The source's write then lands on top of the destination's freshly loaded state.
- A queued persist completes after the character despawned, by which time the character may have been claimed somewhere else.
The project already had the answer and item writes simply were not using it:
characters.session_state / session_owner_server_id / session_owner_token,
claimed by ICharacterService.TryClaimAsync and carried in memory as
CharacterSessionInfo. This service turns that triple into a precondition for writing.
HOW IT IS SAFE, not merely checked. The assertion locks the character row
(SELECT ... FOR NO KEY UPDATE) before comparing. TryClaimAsync and
ReleaseAsync are plain UPDATEs on that same row, and an UPDATE takes a
conflicting row lock, so a competing claim BLOCKS until the item transaction commits or rolls
back, and any later write from the displaced server fails the comparison. Without the lock
this would be a check-then-act with a window between them — which is the bug, not the fix.
(Measured against PostgreSQL 18.6: a concurrent claim waits for the holder's transaction.)
Consequently this method is only meaningful inside a unit of work whose transaction outlives it. Called without one it refuses rather than returning a reassuring answer that expires the instant it is given.
Methods
AssertOwnershipAsync(long, CharacterSessionLeaseData, bool, CancellationToken)
Asserts that the caller may still write this character's state, and holds the character row locked for the rest of the ambient transaction.
Task<DatabaseResult> AssertOwnershipAsync(long characterId, CharacterSessionLeaseData lease, bool allowUnclaimed = false, CancellationToken cancellationToken = default)
Parameters
characterIdlongThe character to assert. Must match
leasewhen the lease is valid; it is supplied separately so an unclaimed-only assertion can be made with no lease at all.leaseCharacterSessionLeaseDataThe ownership triple the caller believes it holds. May be default/invalid when the caller has already given the claim up — see
allowUnclaimed.allowUnclaimedboolWhen
true, a character whose session is unowned (state Offline with a zeroed owner) also passes, whether or notleaseis valid.This is not leniency, it is the correct predicate for a final flush. A character nobody has claimed has no authoritative holder for the write to conflict with, and the ONE thing a stale write must never do — overwrite a live session's state — is still refused, because a live session is by definition Online with a token that will not match.
It is needed because
CharacterSystem.SaveAndDespawnCharactertakes the token out ofSessionTokensand enqueues the session release before it raisesOnDespawnCharacter, which is the hook the logout item flush hangs off. Requiring a live claim there would silently discard everything the player did since the last periodic snapshot — a far worse failure than the one being guarded against.RESIDUAL, stated rather than hidden: a process stalled past its lease expiry (default two minutes) can have its character claimed, played and released elsewhere, and its zombie write would then find the row unowned and land. The lease duration is what bounds this, exactly as it bounds the position split-brain already documented on
ICharacterService.UpdatePositionAsync.cancellationTokenCancellationTokenCancellation token.
Returns
- Task<DatabaseResult>
Success when the caller may write. Forbidden when the session belongs to someone else, or is unowned and
allowUnclaimedisfalse, or the character has been deleted — the write must be abandoned, not retried. NotFound when no such character row exists. InvalidOperation when called outside a unit of work.