Skip to content

perf(database): per-thread mysql connections, async player load, thread-safe saves#3989

Open
beats-dh wants to merge 6 commits into
mainfrom
beats/db-per-thread-connections
Open

perf(database): per-thread mysql connections, async player load, thread-safe saves#3989
beats-dh wants to merge 6 commits into
mainfrom
beats/db-per-thread-connections

Conversation

@beats-dh

@beats-dh beats-dh commented May 30, 2026

Copy link
Copy Markdown
Contributor

Problem

Every DB query in the server went through a single MYSQL* handle behind one global std::recursive_mutex (Database::databaseLock), so all queries — from every thread — were serialized. ProtocolGame::login() runs on the dispatcher (game-loop) thread and calls loadPlayerById, which issues ~20 sequential queries per character. Under mass login the dispatcher sat blocked on serialized DB I/O and the whole world stopped updating (movement, combat, SERVER_BEAT) while CPU stayed mostly idle.

What changed

1. One MySQL connection per worker thread (lock-free hot path)

Database no longer holds a single handle. Each thread that touches the DB gets its own ConnectionContext (handle + maxPacketSize + lastErrno), created lazily on first query and cached in a thread_local pointer. A registry vector owns the contexts for shutdown; its mutex is taken once per thread, never on the query path. Because a context belongs to a single thread, the handle needs no lock — storeQuery/executeQuery/escapeBlob use getContext().handle directly. Public Database API is unchanged (~150 call sites untouched); connection params are captured in connect() and reused for each new per-thread connection.

  • mysql_library_init() is now called explicitly once (via call_once in connect(), single-threaded at startup) — mysql_init()'s implicit init is not thread-safe when two threads race their first connection. mysql_thread_init() per connection, and mysql_thread_end() via a thread_local cleanup on thread exit.
  • Transactions keep connection affinity for free (a tx runs on one thread → one connection). With real concurrency, InnoDB deadlocks / lock-wait timeouts (1213/1205) become possible, so DBTransaction::executeWithinTransaction* now retries the whole transaction up to 3x.

2. Player load off the dispatcher (login)

login() runs the cheap checks + waiting list, reserves the login (Game::reserveLogin, a dispatcher-only set), then dispatches loadPlayerById to the thread pool. Audit found only 2 of ~27 load steps mutate shared global state and are unsafe on a pool thread:

  • loadPlayerGuild → writes the global guild map and mutates a shared Guild.
  • loadPlayerInitializeSystem → mutates a shared Party via the wheel.

These (plus updateSystem/exiva, which must run after initializeSystem) are skipped on the pool (deferWorldData = true) and run on the dispatcher in finishLogin via loadPlayerWorldData. finishLogin then does the PZ check, placeCreature, acceptPackets, and releaseLogin on every exit path.

3. Thread-safe player save

The save already ran on pool threads but read the live Player while the dispatcher mutated it — a data race. savePlayer is now split into buildPlayerSave (serialize the player to SQL — runs on the dispatcher, consistent read) and flushPlayerSave (execute the SQL in a transaction — pool). It uses a Database query-capture mode: during the build, executeQuery records SQL into a buffer instead of running it. The players.save flag is cached on the Player at login so the build needs no DB round-trip (pure CPU). Per-player flushes are serialized/ordered (m_flushInFlight / m_pendingFlushes) so an older flush can never overwrite a newer one, and a save queued behind an in-flight one keeps its built SQL so a final (logout) save is never dropped. saveAll builds on the dispatcher and flushes on the pool.

Review notes

  • Bottleneck moves to MySQL + thread-pool size. Per-thread connections remove the global lock; throughput is now bounded by worker count and the MySQL server. Highest-leverage knob: thread pool size (DEFAULT_NUMBER_OF_THREADS, default 4), bounded by MySQL max_connections. Reducing the ~20 queries/load is the next lever.
  • Load ordering change: guild now loads after the pool steps instead of mid-sequence; the initializeSystem → updateSystem → exiva order is preserved. No load step reads player->guild, so this is safe — worth a second look.
  • Query-capture edge case: an incidental executeQuery during a build (only known case: a KV cache eviction) would be captured into the save buffer. Rare and bounded; documented in docs/database-connection-model.md.
  • mysql_thread_end() is released on thread exit via a thread_local cleanup.

Not done

Mass-login load test with bots is pending. Validated so far: build, boot (per-thread connection logs appear as threads reach the DB), and login/logout/relog.

📄 Full rationale and trade-offs: docs/database-connection-model.md

Summary by CodeRabbit

  • New Features

    • Login reservation to prevent duplicate concurrent character logins
    • Asynchronous thread-pool player loading with a dispatcher continuation
    • Option to defer world-data initialization during login
  • Improvements

    • Player save split into two-phase build (dispatcher) and flush (worker) flow
    • Per-thread database connections and query capture/replay for thread-safe saves
    • Per-player cached save flag to avoid extra lookups
  • Bug Fixes

    • Bounded automatic transaction retries for deadlock/timeout recovery
  • Documentation

    • Added detailed database connection, concurrency and tuning guidance

Review Change Stack

…ad-safe saves

replace the single global-mutex mysql connection with one lazily-created
connection per worker thread, making the query hot path lock-free. this removes
the global serialization that froze the game loop during mass login.

- database: per-thread ConnectionContext, lazy getContext, lock-free hot path
- database: mysql_library_init once at startup; transaction deadlock/lock-wait
  retry (1213/1205); query capture (record-replay) for saves; connection logs
- login: dispatch the heavy loadPlayerById to the thread pool; finishLogin runs
  the two global-state steps (guild, system init) on the dispatcher; reserveLogin
  guards against duplicate concurrent logins
- save: serialize the player on the dispatcher and flush sql on the pool;
  per-player flush ordering; saveAll builds on dispatcher, flushes on pool
- player: cache the players.save flag to avoid a select in the save build
- docs: add database-connection-model.md
@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: b4779b8b-c701-4fb6-aa94-456655fe6de6

📥 Commits

Reviewing files that changed from the base of the PR and between 58155f2 and d30f180.

📒 Files selected for processing (2)
  • src/database/database.cpp
  • src/database/database.hpp
🚧 Files skipped from review as they are similar to previous changes (2)
  • src/database/database.hpp
  • src/database/database.cpp

📝 Walkthrough

Walkthrough

Per-thread lazy MySQL ConnectionContext replaces a global locked handle; query capture supports record/replay for thread-safe saves; transactions retry on transient deadlocks; heavy login DB work is moved to the pool with dispatcher-only deferred world-data and login reservation; saves are split into dispatcher build + pool flush.

Changes

Database Concurrency Refactor with Async Player Operations

Layer / File(s) Summary
Database per-thread connection infrastructure
src/database/database.hpp, src/database/database.cpp
ConnectionContext holds per-thread MySQL handle, max_allowed_packet, and query-capture storage. Per-thread contexts are lazily created via getContext()/establishConnection() with mysql_thread_init and thread_local caching; cold-path mutex remains for creation/shutdown.
Query capture and deadlock-aware transaction retry
src/database/database.hpp, src/database/database.cpp
Adds per-thread query capture (beginQueryCapture/endQueryCapture and QueryCaptureScope), updates executeQuery/storeQuery to use thread-local handles and capture mode, records lastErrno in context, and adds DBTransaction helpers that retry up to 3 attempts on deadlock/lock-wait timeouts. Also exposes getLastInsertId()/getMaxPacketSize().
IOLoginData deferred world-data loading and split save
src/io/iologindata.hpp, src/io/iologindata.cpp, src/io/functions/*
Adds deferWorldData flag to load APIs, loadPlayerWorldData() dispatcher-only, buildPlayerSave() to capture SQL on dispatcher (returns std::optional<std::vector<std::string>>), and flushPlayerSave() to execute captured SQL inside a DBTransaction retry wrapper; loadPlayerGuild signature simplified and players.save cached at login.
SaveManager two-phase dispatcher/pool coordination
src/game/scheduling/save_manager.hpp, src/game/scheduling/save_manager.cpp
Introduces PlayerSaveBatch, buildAllPlayers() (dispatcher) and flushBuiltPlayers() (pool), rewrites saveAll()/scheduleAll() to build-then-flush, and serializes per-player flushes using m_flushInFlight and m_pendingFlushes with onPlayerFlushed.
Player save flag caching and short-circuit
src/creatures/players/player.hpp, src/io/functions/iologindata_save_player.cpp
Adds Player::getSaveFlag()/setSaveFlag() with backing m_saveFlag (default true); save paths use the cached flag to avoid an extra DB SELECT when deciding to only update lastlogin/lastip.
Game login reservation and async ProtocolGame login
src/game/game.hpp, src/game/game.cpp, src/server/network/protocol/protocolgame.hpp, src/server/network/protocol/protocolgame.cpp
Adds dispatcher-only reserveLogin/releaseLogin/isLoginPending tracked in m_pendingLogins. ProtocolGame::login() reserves GUID, detaches pool load (deferWorldData=true), and schedules finishLogin() on dispatcher to apply deferred world-data and finalize placement/packet acceptance.
Architecture documentation
docs/database-connection-model.md
New design doc describing pre/post architecture, per-thread connections, mysql init sequencing, query capture/replay, deferred world-data for async loads, split save workflow, transaction retry semantics, observability, trade-offs, and tuning guidance.

Sequence Diagram(s)

sequenceDiagram
  participant Dispatcher
  participant Pool as Pool Thread
  participant Conn as ConnectionContext
  participant MySQL
  Dispatcher->>Dispatcher: reserveLogin(guid)
  Dispatcher->>Pool: detach loadPlayerById(deferWorldData=true)
  Pool->>Conn: getContext() (thread-local, lazy)
  Conn->>MySQL: mysql_thread_init + mysql_real_connect
  Pool->>MySQL: SELECT player basic data
  Pool->>Pool: skip guild/world-data steps (deferred)
  Pool-->>Dispatcher: schedule finishLogin(loaded=true)
  Dispatcher->>Dispatcher: finishLogin() continuation
  Dispatcher->>Dispatcher: loadPlayerWorldData() (guild, exiva)
  Dispatcher->>Dispatcher: place creature, enable packets
  Dispatcher->>Dispatcher: releaseLogin(guid)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • opentibiabr/canary#3871: Overlapping login/load and save pipeline refactor affecting IOLoginData and save handling.

Suggested reviewers

  • dudantas
  • majestyotbr

Poem

🐰 A rabbit's ode to threading
The mutex slept, one global key,
Now threads each hold a little door;
Capture queries, build with glee,
Workers flush while dispatchers store.
Hops of logs and retries — saves race no more.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 7.14% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main changes: per-thread MySQL connections, async player loading, and thread-safe saves.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch beats/db-per-thread-connections

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a per-thread MySQL connection model to resolve mass-login stalls by replacing the single global connection lock with lazily-created, thread-local connections. It also moves heavy player loading off the dispatcher thread and implements a thread-safe player saving mechanism. The review feedback highlights several critical issues: a potential transaction commit on failure in flushPlayerSave which should use rollback-on-failure; a data loss vulnerability where pending saves can be dropped if a player logs out while a save is in flight; a memory leak from not calling mysql_thread_end() on thread exit; and potential crashes due to missing null-pointer checks on connection handles in getLastInsertId and escapeBlob.

Comment thread src/io/iologindata.cpp
Comment thread src/game/scheduling/save_manager.hpp Outdated
Comment thread src/game/scheduling/save_manager.cpp Outdated
Comment thread src/database/database.cpp
Comment thread src/database/database.cpp Outdated
Comment thread src/database/database.cpp
Comment thread src/database/database.cpp
beats-dh added 2 commits May 30, 2026 01:26
… cleanup

- flushPlayerSave: use executeWithinTransactionRollbackOnFailure so a failing
  query rolls back the whole save (no partial commit) and triggers deadlock retry
- save_manager: queue the already-built sql of an in-flight player's next save
  (m_pendingFlushes map) instead of a guid to re-look-up, so a final save (e.g.
  logout) is never dropped when the player object is gone as the flush completes
- database: call mysql_thread_end() via a thread_local cleanup on thread exit
- database: null-check the connection handle in getLastInsertId and escapeBlob
- ConnectionContext: delete copy ops (rule of 5) to prevent a double mysql_close
- mark ignored return values from mysql_thread_init/mysql_options as (void)
- use push_back instead of emplace_back where the returned reference is unused
- drop redundant cast on mysql_insert_id (already returns uint64_t)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🧹 Nitpick comments (4)
src/database/database.cpp (1)

29-35: ⚡ Quick win

Reject nested query capture on the same thread.

beginQueryCapture() overwrites tlsQueryCapture unconditionally. A nested QueryCaptureScope will silently stop recording into the outer buffer, and endQueryCapture() then disables capture for the rest of the outer scope. Add a guard/assert here so misuse fails loudly instead of truncating the replay batch.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/database/database.cpp` around lines 29 - 35, beginQueryCapture currently
overwrites the thread-local tlsQueryCapture allowing nested QueryCaptureScope to
silently break the outer capture; modify Database::beginQueryCapture to check
tlsQueryCapture and fail loudly (e.g., assert or throw) if it is already
non-null, and add a complementary check in Database::endQueryCapture to assert
that tlsQueryCapture is non-null before setting it to nullptr so improper
nesting is detected immediately; reference Database::beginQueryCapture,
Database::endQueryCapture, tlsQueryCapture and QueryCaptureScope when making the
change.
src/game/scheduling/save_manager.hpp (1)

56-62: ⚡ Quick win

Add direct coverage for the in-flight/resave ordering logic.

m_flushInFlight and m_resavePending are the guardrails that keep same-player flushes ordered. Please add a focused test, or the smallest practical concurrency check, for “save requested during an in-flight flush triggers exactly one rebuild after the flush completes”.

As per coding guidelines, "For C++ changes, prefer focused tests or the smallest practical build/check that validates the touched code".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/game/scheduling/save_manager.hpp` around lines 56 - 62, Add a focused
unit test that simulates a player save being requested while a flush is
in-flight and asserts exactly one rebuild occurs after the flush completes:
create a test harness around the SaveManager (the class using m_flushInFlight
and m_resavePending), start a simulated async flush for a test GUID by inserting
into or triggering the code path that sets m_flushInFlight, then concurrently
call the public save request API (e.g., requestSave/savePlayer or the method
that enqueues resaves) to trigger the m_resavePending path, complete the
in-flight flush, and verify that m_resavePending results in exactly one rebuild
invocation (mock or spy the rebuild/flush callback) and that m_flushInFlight is
cleared appropriately; keep the test minimal, deterministic (use condition
variables/promises or injected schedulers to control timing), and only reference
m_flushInFlight, m_resavePending, and the public request/flush/rebuild entry
points.
src/io/functions/iologindata_save_player.cpp (1)

176-178: ⚡ Quick win

Add a focused regression test for the cached save fast path.

This branch now trusts Player::getSaveFlag() to skip the full player update. Please cover the players.save = 0 case directly, including that lastlogin/lastip still persist, so a cache-init regression does not silently change persistence behavior.

As per coding guidelines, "For C++ changes, prefer focused tests or the smallest practical build/check that validates the touched code".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/io/functions/iologindata_save_player.cpp` around lines 176 - 178, Add a
focused regression test that exercises the cached save fast-path used in
iologindata_save_player.cpp: simulate a player with players.save = 0 by
initializing the cache via loadPlayerBasicInfo (or the test helper that mirrors
login cache init), call the code path that leads to Player::getSaveFlag()
returning false so the DB SELECT is skipped, and assert that lastlogin and
lastip are still persisted to the DB despite the fast-path; locate relevant
symbols Player::getSaveFlag(), loadPlayerBasicInfo, and the function containing
the fast-path in iologindata_save_player.cpp to hook the test and verify
persistence for the players.save = 0 case.
src/game/game.hpp (1)

178-184: ⚡ Quick win

Add a focused regression check for the login reservation contract.

This dispatcher-only guard now sits on the duplicate-login path, so it deserves a small validation that covers reserve → reject duplicate → release → allow again before merge.

As per coding guidelines, **/*.{cpp,hpp,h,cc,cxx}: For C++ changes, prefer focused tests or the smallest practical build/check that validates the touched code.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/game/game.hpp` around lines 178 - 184, Add a focused regression unit test
that verifies the dispatcher-only login reservation contract: call
Game::reserveLogin(guid) and assert it returns true, then call
reserveLogin(guid) again and assert it returns false (duplicate rejected), then
call Game::releaseLogin(guid) and finally assert reserveLogin(guid) returns true
again; put the test in the smallest relevant test target (a new or existing unit
test for src/game, e.g. game_reservation_test) and ensure it runs in
single-threaded/dispatcher context so no locking is required and uses the public
methods reserveLogin, releaseLogin, and isLoginPending for assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/database/database.cpp`:
- Around line 131-154: Move publishing the thread-local cache until after a
successful connection: in Database::getContext(), create the ConnectionContext
and emplace it into connections under connectionsMutex as you do now, but call
establishConnection(*contextPtr) before assigning tlsContext = contextPtr; if
establishConnection fails, under connectionsMutex erase the just-added
unique_ptr from connections (so the failed context is destroyed and not cached)
and do not set tlsContext; only set tlsContext and log success when
establishConnection returns true. Ensure you reference ConnectionContext,
tlsContext, establishConnection, connections and connectionsMutex when making
these changes.
- Around line 23-27: The code calls mysql_library_init() in Database::connect()
but never calls mysql_library_end(); add a single global shutdown call to
mysql_library_end() executed after all worker threads are joined and after
per-thread mysql_thread_end() (ThreadCleanup) has run—either by invoking
mysql_library_end() from the Database destructor (ensure destructor runs after
thread join logic) or by installing a std::call_once/atexit hook that runs once
at process shutdown; guard the call so it only runs if mysql_library_init()
previously succeeded (use an init flag or the same call_once token).

In `@src/game/game.cpp`:
- Around line 11090-11103: Add a runtime dispatcher-thread guard to the three
functions that touch the dispatcher-only m_pendingLogins: reserveLogin,
releaseLogin, and isLoginPending. At the start of each method assert or
otherwise check DispatcherContext::isOn() (or use g_dispatcher().context()
helper) to fail fast if called off the dispatcher, then proceed with the
existing logic; this enforces the documented dispatcher-only contract and
prevents unsynchronized access to m_pendingLogins.

In `@src/game/scheduling/save_manager.cpp`:
- Around line 29-49: The full-save path in SaveManager::buildAllPlayers builds
PlayerSaveBatch entries without GUIDs and bypasses the m_flushInFlight /
m_resavePending ordering used by schedulePlayer(), allowing older full-save
snapshots to overwrite newer per-player saves; fix by including each player's
GUID in PlayerSaveBatch (use player->getGUID() or equivalent) and before
dispatching the full-save batch mark those GUIDs in m_flushInFlight (or push
each player through the same schedulePlayer() path) so the same per-GUID
ordering/in-flight tracking is used; update IOLoginData::buildPlayerSave usage
if needed to carry the GUID and ensure the batch entries are marked
in-flight/resavePending consistent with schedulePlayer() prior to dispatching
the DB flush.

In `@src/io/iologindata.cpp`:
- Around line 231-240: The lambda passed to
DBTransaction::executeWithinTransaction in IOLoginData::flushPlayerSave
currently returns false on a failed Database::executeQuery, but
executeWithinTransaction commits on boolean returns; change the logic to throw
an exception when db.executeQuery(query) fails (e.g., std::runtime_error with
context including the failed query) so the transaction mechanism will detect the
exception and roll back; keep returning true at the end of the lambda for the
success path.

In `@src/server/network/protocol/protocolgame.cpp`:
- Around line 692-697: The pool task dereferences self->player which may be
cleared by release() if the client disconnects; capture the reserved player's
GUID (and if needed a copy of the minimal player identity) into a local variable
before calling g_threadPool().detach_task and pass that captured guid into both
the worker lambda and the continuation so finishLogin and any releaseLogin(guid)
use the reserved GUID rather than reading self->player; update the detach_task
lambda and the g_dispatcher().addEvent continuation to accept the captured guid
(instead of relying on self->player), and ensure finishLogin(signature) or its
call site uses that captured guid for reservation cleanup.

---

Nitpick comments:
In `@src/database/database.cpp`:
- Around line 29-35: beginQueryCapture currently overwrites the thread-local
tlsQueryCapture allowing nested QueryCaptureScope to silently break the outer
capture; modify Database::beginQueryCapture to check tlsQueryCapture and fail
loudly (e.g., assert or throw) if it is already non-null, and add a
complementary check in Database::endQueryCapture to assert that tlsQueryCapture
is non-null before setting it to nullptr so improper nesting is detected
immediately; reference Database::beginQueryCapture, Database::endQueryCapture,
tlsQueryCapture and QueryCaptureScope when making the change.

In `@src/game/game.hpp`:
- Around line 178-184: Add a focused regression unit test that verifies the
dispatcher-only login reservation contract: call Game::reserveLogin(guid) and
assert it returns true, then call reserveLogin(guid) again and assert it returns
false (duplicate rejected), then call Game::releaseLogin(guid) and finally
assert reserveLogin(guid) returns true again; put the test in the smallest
relevant test target (a new or existing unit test for src/game, e.g.
game_reservation_test) and ensure it runs in single-threaded/dispatcher context
so no locking is required and uses the public methods reserveLogin,
releaseLogin, and isLoginPending for assertions.

In `@src/game/scheduling/save_manager.hpp`:
- Around line 56-62: Add a focused unit test that simulates a player save being
requested while a flush is in-flight and asserts exactly one rebuild occurs
after the flush completes: create a test harness around the SaveManager (the
class using m_flushInFlight and m_resavePending), start a simulated async flush
for a test GUID by inserting into or triggering the code path that sets
m_flushInFlight, then concurrently call the public save request API (e.g.,
requestSave/savePlayer or the method that enqueues resaves) to trigger the
m_resavePending path, complete the in-flight flush, and verify that
m_resavePending results in exactly one rebuild invocation (mock or spy the
rebuild/flush callback) and that m_flushInFlight is cleared appropriately; keep
the test minimal, deterministic (use condition variables/promises or injected
schedulers to control timing), and only reference m_flushInFlight,
m_resavePending, and the public request/flush/rebuild entry points.

In `@src/io/functions/iologindata_save_player.cpp`:
- Around line 176-178: Add a focused regression test that exercises the cached
save fast-path used in iologindata_save_player.cpp: simulate a player with
players.save = 0 by initializing the cache via loadPlayerBasicInfo (or the test
helper that mirrors login cache init), call the code path that leads to
Player::getSaveFlag() returning false so the DB SELECT is skipped, and assert
that lastlogin and lastip are still persisted to the DB despite the fast-path;
locate relevant symbols Player::getSaveFlag(), loadPlayerBasicInfo, and the
function containing the fast-path in iologindata_save_player.cpp to hook the
test and verify persistence for the players.save = 0 case.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0125f01a-d637-46af-88c8-da290ec5e6d6

📥 Commits

Reviewing files that changed from the base of the PR and between def12a7 and 7d73a1d.

📒 Files selected for processing (15)
  • docs/database-connection-model.md
  • src/creatures/players/player.hpp
  • src/database/database.cpp
  • src/database/database.hpp
  • src/game/game.cpp
  • src/game/game.hpp
  • src/game/scheduling/save_manager.cpp
  • src/game/scheduling/save_manager.hpp
  • src/io/functions/iologindata_load_player.cpp
  • src/io/functions/iologindata_load_player.hpp
  • src/io/functions/iologindata_save_player.cpp
  • src/io/iologindata.cpp
  • src/io/iologindata.hpp
  • src/server/network/protocol/protocolgame.cpp
  • src/server/network/protocol/protocolgame.hpp

Comment thread src/database/database.cpp
Comment thread src/database/database.cpp
Comment thread src/game/game.cpp
Comment thread src/game/scheduling/save_manager.cpp
Comment thread src/io/iologindata.cpp
Comment thread src/server/network/protocol/protocolgame.cpp Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/game/scheduling/save_manager.hpp`:
- Around line 57-64: The full-save path (scheduleAll()/flushBuiltPlayers())
currently bypasses the per-player ordering guarded by m_flushInFlight and
m_pendingFlushes, allowing stale full-save batches to overwrite newer per-player
saves; modify scheduleAll()/flushBuiltPlayers() to route each player flush
through the same per-guid coordinator used by schedulePlayer() (i.e.,
check/insert into m_flushInFlight and enqueue into m_pendingFlushes instead of
directly writing), or alternatively have scheduleAll() mark per-guid
batch-in-flight (and block/merge schedulePlayer() for those GUIDs) so all
flushes for a given guid go through the single serialized path implemented by
m_flushInFlight and m_pendingFlushes. Ensure you update flushBuiltPlayers() to
use the same enqueue/flush logic as schedulePlayer() so older full-save SQL
cannot clobber newer per-player saves.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5cead72b-cfdb-4c44-b5cf-d18d7ec0c7ee

📥 Commits

Reviewing files that changed from the base of the PR and between 7d73a1d and b16fe5c.

📒 Files selected for processing (6)
  • docs/database-connection-model.md
  • src/database/database.cpp
  • src/database/database.hpp
  • src/game/scheduling/save_manager.cpp
  • src/game/scheduling/save_manager.hpp
  • src/io/iologindata.cpp
✅ Files skipped from review due to trivial changes (1)
  • docs/database-connection-model.md
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/database/database.hpp
  • src/io/iologindata.cpp
  • src/game/scheduling/save_manager.cpp
  • src/database/database.cpp

Comment thread src/game/scheduling/save_manager.hpp
beats-dh added 2 commits May 30, 2026 01:46
- login: capture the reserved guid + a local shared_ptr before the pool load, so
  a mid-load disconnect can't null-deref self->player or leak the reservation
- save_manager: route full-save (scheduleAll) players through schedulePlayer so
  the per-guid flush ordering also covers the global save — no older snapshot
  overwriting a newer per-player save
- database: don't cache a failed connection in the thread_local (retry next call)
- database: pair mysql_library_init with mysql_library_end on shutdown
- database: assert against nested query capture
- game: assert dispatcher thread for reserveLogin/releaseLogin/isLoginPending
Replace the global std::atomic init flag with a Database member bool, avoiding
the global-mutable and CTAD code smells. The singleton that runs
mysql_library_init() is the one that pairs mysql_library_end() on shutdown
(set/read single-threaded at startup/teardown, so no atomic is needed).
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant