OwnerService/HostService exchange only SessionOffer, PortableSessionStarted, PortableCheckpoint and OwnerAck. Every checkpoint is merge3(C0, B0, current B1); sequences are monotonic and idempotent; stale/superseded/malformed/oversized messages change nothing. Design and login-path audit in docs/PORTABLE_SESSIONS.md. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
14 KiB
Runtime portable sessions (Phase 5): design and audit
Code: core src/server/coa/CoAPortableSession*.{h,cpp} and CoAPortableImport*.{h,cpp} (branch feat/portable-session-bridge of the
core fork), Manager crates/coa-core/src/portable/session/. CoA only; Wildcard portable transfer stays disabled.
Phase 4 proved the offline model with a real client. Its one fragile rule is that the baseline B0 must be captured after
the realm's own normalisation and before the character is played. Phase 5 makes that rule a property of the running
server instead of a user procedure:
running worldserver -> automatic B0 -> 60-second checkpoints -> Owner canonical revisions -> final logout checkpoint
1. Roles (the API reflects the real deployment)
| OWNER (player's Manager) | HOST (the realm's Manager) | |
|---|---|---|
| owns | canonical PortableCharacter, revision history, C0 per session, applied-checkpoint log |
realm binding, item/pet mappings, synced snapshot of what the realm was given, outbox |
| does | validates every message, runs merge3(C0, B0, B1), writes revision N+1 |
controls the local worldserver, captures B0/B1 from the realm, emits messages |
| never | touches a realm | opens or mutates the Owner's database; holds canonical history |
Both roles run on Store (one schema) in this phase, but the APIs are OwnerService and HostService, they exchange only
the messages of section 5, and every test runs them on two separate stores (two files, two processes' worth of state).
A later Registry/Relay carries the same messages unchanged.
2. Audit of the login path (what the core actually does)
WorldSession::HandlePlayerLoginFromDB (Handlers/CharacterHandler.cpp) is one synchronous function on the world thread:
Player::LoadFromDB(the realm's normalisation: unknown spells deleted, default spells/skills/reputation added, honor and title resets, items that cannot be placed mailed or dropped);SendInitialPacketsBeforeAddToMap,AddPlayerToMap,SendInitialPacketsAfterAddToMap, social/guild/group announcements,LoadCorpse, at-login flags (resetSpells,resetTalents, first-login spells),LoadPet;sScriptMgr->OnPlayerLoginandOnPlayerFirstLogin(module normalisation, e.g. the starter-kit restore) as the last statements.
OnPlayerLogin is therefore not early (it is the end of the function) but the end of the function is the right point:
everything the realm does to a loading character has run, and nothing the player does can have run, because
- client packets are handled in
WorldSession::Update, which cannot run before this handler returns (same thread), and - the map's
Update(mobs, auras, regeneration,Player::Update) runs later in the same world tick.
So the character state at the end of the handler is exactly "normalised, not yet played". Bots enter through the same function
(BotMgr calls HandlePlayerLoginFromDB), so the same code serves tests with bot-logged characters. The "login after
disconnect" shortcut (HandlePlayerLoginToCharInWorld, a player that never left the world) does not reload the character and
does not start a session.
Other facts that shape the design:
Player::SaveToDB(trans, create, logout)is the single place that writes a character; manual save, autosave, logout save,.savealland checkpoints all go through it. It returns early (a delayed save) whileIsBeingTeleportedFar(), and it saves the pet through a separate transaction (Pet::SavePetToDB). WithCharacterDatabase.WorkerThreads = 1(the default, and enforced by the portable subsystem at startup) transactions commit in FIFO order, so the pet's commit precedes ours.CommitTransactionis asynchronous: an RA reply is not persistence. The Host Manager reads the database and treats a save as having happened only when it sees the marker row.- The save is a snapshot taken when
SaveToDBruns, not when the commit lands. That is what makesB0exact. - RA/console commands run on the world thread (
QueueCliCommand), so a command can safely touch aPlayer*. - The character list is stale on already connected clients after an import;
CharacterCacheandWorld::UpdateRealmCharCountmust be updated (the importer does, section 7). PlayerDumpReader::LoadDumpis the precedent for the importer's runtime invariants (live generators advanced only after the commit, name check, cache entry, realm character count).
3. Session marker (core table coa_portable_session)
One row per portable character on the realm (pending SQL data/sql/updates/pending_db_characters/):
| column | meaning |
|---|---|
guid |
local character guid (primary key) |
session_id, character_id |
UUIDv7 strings: the session and the portable character |
imported_revision |
canonical revision C0 that the realm holds (base_canonical_revision) |
baseline_generation |
increments each time the session is (re)armed |
state |
0 waiting_baseline, 1 baseline_ready (gated), 2 active, 3 ended |
checkpoint_seq |
the last checkpoint sequence the core saved |
save_seq |
incremented by every save of this character |
updated_at |
unix time |
The Host arms a session (state 0) in the same realm transaction that imports/updates the character (offline importer, in-place update) or the core's import service does (online). That is the "pending portable-session marker" the character carries until its first load.
Automatic B0
At the end of HandlePlayerLoginFromDB, if the character's row is in state 0, the core, in one transaction:
SaveToDB(trans, false, false) + UPDATE coa_portable_session SET state=1, baseline_generation=..., save_seq=save_seq+1,
commits, and gates the session:
- while gated the session's packet queue is not processed (the client stays connected;
CMSG_PINGis answered by the socket layer), the character is made non-attackable and rooted, nothing the client sends is lost - it is processed after release; - the gate ends when the Host sends
portable release <session_id>(the Host has seenstate=1, exportedB0in one read-only consistent snapshot, verified inside that snapshot that the marker is still the baseline's, and sentPortableSessionStarted); - if no release arrives within
PortableSession.GateTimeoutSeconds(default 30) the character is kicked, never released: no gameplay may happen beforeB0exists. The row stays in state 1; the next login re-runs the baseline.
Characters whose row is in state 2 (a worldserver restart or a relog in the middle of a session) get no baseline, only the
in-memory registration; B0 and C0 are already held by the Owner.
Every save carries a marker
Player::SaveToDB(trans, ...) appends, for a registered portable character, UPDATE coa_portable_session SET save_seq=save_seq+1, updated_at=... to the same transaction (plus checkpoint_seq=N when the save is a checkpoint and
state=3 when it is the logout save). So the rows of a character in a read snapshot always belong to exactly the save whose
marker is in that snapshot; an autosave between two checkpoints is visible as a higher save_seq.
Checkpoint (typed core operation)
portable checkpoint <local_guid> <session_id> <sequence> (console/RA only): the character is addressed by guid, never by name;
the command verifies the player is online, a portable session is registered, session_id matches, the state is 2, the
sequence is greater than checkpoint_seq, and the character is not mid-teleport (BUSY, retry). Then one SaveToDB
transaction with the checkpoint marker, committed. The reply is QUEUED <sequence>; it is not an acknowledgement of
persistence. .saveall is never used, so one checkpoint writes one character (verified, section 9).
Logout: the logout save itself carries state=3; the Host exports and sends the final checkpoint.
4. Host flow
import / update of a portable character -> arm session (state 0), outbox: nothing yet
poll realm every ~1 s (cheap indexed read of coa_portable_session):
state 1, no PortableSessionStarted sent -> read B0 (consistent snapshot, marker re-checked) -> persist -> release (RA)
-> emit PortableSessionStarted
state 2, online, every 60 s -> RA checkpoint(seq) -> wait for checkpoint_seq == seq -> read B1 ->
persist in outbox -> emit PortableCheckpoint(seq)
state 3 (logout) or character offline -> read final B1 -> emit PortableCheckpoint(final) -> on ack: re-arm (state 0)
A failed intermediate checkpoint changes nothing canonical and is retried at the next tick. The sequence number is persisted before the RA call, so a Host restart resumes with the same number (the marker tells whether the save happened). Messages wait in the outbox until acknowledged; redelivery is harmless (section 6).
5. Messages (transport-neutral, versioned, JSON)
protocol_version = 1; each message is canonical JSON with unknown fields refused. Snapshots travel in the existing
snapshot envelope (zstd of canonical JSON, SHA-256 of the canonical JSON).
PortableSessionStarted { protocol_version, session_id, character_id, server_id, base_canonical_revision,
baseline_generation, b0: envelope, content_hash }
PortableCheckpoint { protocol_version, session_id, character_id, server_id, base_canonical_revision,
sequence, final_checkpoint, realm_snapshot: envelope, content_hash }
OwnerAck { protocol_version, session_id, sequence, outcome, canonical_revision, canonical_hash,
[final: canonical snapshot envelope] }
outcome is applied, duplicate (same sequence and hash, already applied), stale_sequence, stale_session
(superseded: another session or revision), or rejected(reason).
The Owner validates, before merge3: protocol version; message size and decompression limits; content_hash against the
payload; character_id exists and the ruleset is CoA; the session is known (opened by PortableSessionStarted whose
base_canonical_revision equals the revision the Owner recorded as C0 for that server_id); server_id and
character_id match the session; the payload's own character_id/ruleset match; the sequence.
6. Ordering, idempotency, no double counting
- Per session the Owner keeps
last_sequenceand a log of(sequence, content_hash, resulting_revision). - sequence =
last_sequence + 1: applied. Same sequence with the same hash:duplicate, nothing happens, the original result is returned (lost acknowledgements are healed by redelivery). Same sequence, different hash:rejected.sequence <= last_sequenceotherwise:stale_sequence, ignored. A gap (sequence > last_sequence + 1) is accepted only when the Owner holds a later state (checkpoints are snapshots, not deltas, so skipping one loses nothing). - Every checkpoint is
merge3(C0, B0, current B1), anchored to the session's originalC0andB0(Phase 4's property), nevermerge(previous canonical, previous B1, B1). A repeated or re-sent state therefore cannot add money, honor or xp twice; the canonical revision can advance while the anchors stay. - A superseded session (the character's canonical revision no longer equals the session head) refuses checkpoints
(
stale_session); no divergent-state merging is invented here. - Crash: the last acknowledged checkpoint is canonical. A worldserver crash leaves the row in state 2 and the character offline: the Host marks nothing, the next login continues the same session (no new baseline). A final export after a crash is a normal offline export of the last committed save.
7. PortableImportService (production import without stopping the realm)
Follows PORTABLE_IMPORT_SERVICE.md with one concrete decision: the job file is canonical JSON, not zstd, because the core
has no zstd or JSON dependency and a hand-rolled bounded strict parser is easier to audit than adding either. The size cap
(16 MiB, as the Manager's decompression cap) is enforced before parsing; the header carries the SHA-256 the file must match.
Manager writes <JobDir>/<job_id>.job (temp name + rename)
-> RA: portable import <job_id> (job_id = UUIDv7, nothing else)
-> world thread: validate -> allocate from live generators -> prepared statements -> ONE CharacterDatabaseTransaction
(character, items, spells, ..., session marker in state 0) -> commit
-> after the commit only: CharacterCache entry, generators advanced, UpdateRealmCharCount
-> <JobDir>/<job_id>.result (temp + rename); RA reply "OK <job_id>" or "ERR <code>"
No path, SQL or snapshot travels through RA. Idempotency: the marker row carries the job nonce; re-running a finished job rewrites the same result. Never touches a character that is online or already exists.
8. What is not in this phase
VPS Registry/Relay and any network transport, public server list, remote account provisioning, Wildcard, vanity/wardrobe transfer, personal Ascension bank, equipment sets, level-cap projection.
9. Verification plan
- Owner/Host on two stores: canonical revision 10 -> running realm -> automatic
B0before gameplay -> play -> checkpoint 1 (rev 11) -> same checkpoint again (still 11) -> more play -> checkpoint 2 (rev 12) -> delayed checkpoint 1 refused -> final checkpoint. - Money/item/spell/quest/pet progression; realm normalisation not propagated; retry; lost acknowledgement; Host, Owner and worldserver restart; stale session; wrong character or session; malformed message; over-limit payload; logout during a checkpoint; disconnect/crash before the final checkpoint.
- Real worldserver with the character online and several checkpoints without logout; the number of
charactersrows written during a checkpoint is counted on the database (general log) to show one character is saved, not all.