The real root cause of 'inventory appears 30-60s after connect' — and it
had nothing to do with ghost sessions or heartbeat thresholds.
Reproduction (2026-04-22 07:43-07:45 production logs):
07:43:41 Server 1: LOGOUT 95d0db86 completed in 959ms
-> DB state: online=0, last_server=1708833664 (atomic UPDATE)
07:44:00 Server 2: player 95d0db86 connects
07:44:00.x onPlayerLoggedInKickCheck executed
-> executor.execute(UPDATE SET online=1 WHERE uuid=?)
-> DB state: online=1, last_server=1708833664 <-- BUG: we wrote 1
07:44:00.y doPlayerJoin poll: SELECT online, last_server
-> sees online=1, last_server=1708833664
-> 'Waiting for server 1708833664 to finish saving' for 60s
07:45:01 poll times out at 120/120, restore completes in 61219ms
Server 2 was waiting for Server 1 to flush its save — but Server 1 had
ALREADY flushed 19s earlier. Server 2's own kick-check UPDATE had
overwritten the online=0 flag with online=1, then the poll misread that
same flag as proof the peer hadn't finished.
Fix:
- onPlayerLoggedInKickCheck no longer writes online=1. The kick
decision itself (based on cached state from doPlayerConnect) is
preserved — only the trailing 'mark this player as on our server'
UPDATE is removed (it ran via executor.execute and raced the poll).
- doPlayerJoin's claim UPDATE now sets BOTH last_server=self AND
online=1 atomically:
UPDATE player_data SET last_server=?, online=1 WHERE uuid=?
This is the single source of truth for 'player is now here'. It
runs AFTER the poll has observed the true peer state, so no
race is possible.
Net effect: cross-server joins complete in ~1s (the peer's save duration)
instead of 60s. Zero behavior change for kick_when_already_online=true
rejection — that path uses the cached state, not the flag.
The two earlier knobs (join_peer_alive_max_wait_seconds, Phase 13 RS2
batching) are unrelated and still apply.
|
||
|---|---|---|
| .github | ||
| compat-mods | ||
| gradle/wrapper | ||
| src/main | ||
| .gitattributes | ||
| .gitignore | ||
| build.gradle | ||
| CHANGELOG.md | ||
| docker-compose.yml | ||
| ERROR_LOG.md | ||
| gradle.properties | ||
| gradlew | ||
| gradlew.bat | ||
| LICENSE | ||
| README.md | ||
| settings.gradle | ||
| TEST_PROCEDURE_v2.1.5.html | ||
PlayerSync
PlayerSync is a Minecraft Forge mod that synchronizes player data across multiple servers using a MySQL backend. It allows players to maintain their inventory, equipment, experience, advancements, and more when moving between servers in a network.
Mod Support
Any other mods support is also possible.
Development Setup
Database Setup (Docker)
A docker-compose.yml file is provided for easily setting up a MariaDB database instance for development testing.
- Make sure Docker is installed.
- Inside your work directory run:
This will download the MariaDB image (if not already present) and start a database container in the background.docker compose up -d - Stoppinng the Database
docker compose down
Data Persistence: The database uses a Docker volume, ensuring your data persists even if you stop and restart the containers.
Database Management Tool
The docker-compose.yml also includes an Adminer service, a lightweight database management tool.
- Access Adminer in your web browser at http://localhost:8080.
- Log in using the server with
- username:
playersync - database:
playersync - password: see docker-compose.yml
- username:
For debugging purposes, you can enable use_legacy_serialization to have readable database fields. This can cause crashes and unintended side-effects. Do not enable this on a production server if not absolutely necessary!
Running the Mod
The project uses Gradle for building and running. Use the provided Gradle wrapper (gradlew for Linux/macOS, gradlew.bat for Windows).
- Make sure that the MySQL database you configured is running.
- Run the Server
or on Windows:./gradlew runServer
This task compiles the mod and starts a dedicated Minecraft server instance with the mod loaded in the.\gradlew.bat runServerrundirectory. - Run the Client
or on Windows:./gradlew runClient.\gradlew.bat runClient