Table of Contents

Class CharacterSessionOwnershipService

Namespace
FishMMO.Database.Npgsql.Services
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 sealed class CharacterSessionOwnershipService : BaseService<CharacterEntity>, ICharacterSessionOwnershipService
Inheritance
CharacterSessionOwnershipService
Implements
Inherited Members

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:

  1. 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.
  2. 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.
  3. 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.

Constructors

CharacterSessionOwnershipService(INpgsqlDbContextFactory)

Initializes a new instance of the CharacterSessionOwnershipService class.

public CharacterSessionOwnershipService(INpgsqlDbContextFactory dbContextFactory)

Parameters

dbContextFactory INpgsqlDbContextFactory

Factory for creating database contexts.

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.

public Task<DatabaseResult> AssertOwnershipAsync(long characterId, CharacterSessionLeaseData lease, bool allowUnclaimed = false, CancellationToken cancellationToken = default)

Parameters

characterId long

The character to assert. Must match lease when the lease is valid; it is supplied separately so an unclaimed-only assertion can be made with no lease at all.

lease CharacterSessionLeaseData

The ownership triple the caller believes it holds. May be default/invalid when the caller has already given the claim up — see allowUnclaimed.

allowUnclaimed bool

When true, a character whose session is unowned (state Offline with a zeroed owner) also passes, whether or not lease is 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.SaveAndDespawnCharacter takes the token out of SessionTokens and enqueues the session release before it raises OnDespawnCharacter, 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.

cancellationToken CancellationToken

Cancellation token.

Returns

Task<DatabaseResult>

Success when the caller may write. Forbidden when the session belongs to someone else, or is unowned and allowUnclaimed is false, 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.