From 1f73df60ae5f55902db6dafc0f66ad1d011ef41f Mon Sep 17 00:00:00 2001 From: luozaixuan <147116228+luozaixuan@users.noreply.github.com> Date: Wed, 19 Aug 2026 14:22:51 +0800 Subject: [PATCH] Fix shutdown crash when flushing actions for unregistered players Ledger (1.3.18, 1.20.1 Fabric) crashed 100% of the time on server stop when the action queue had a backlog (tens of thousands to hundreds of thousands of entries): flushing inserted into the players table and hit SQLITE_CONSTRAINT_NOTNULL (players.player_name is NOT NULL with no default), the error was mistaken for a transient failure and retried 3 times with backoff, stalling shutdown for ~5 minutes before timing out and losing the whole backlog. Root cause: insertActions called getOrCreatePlayerId(it.id); that helper reused getOrCreateObjectId, which is designed for single-key lookup tables (Sources/Worlds/ObjectResourceLocations) and, on a miss, inserts only the unique key column. The Players table has a second mandatory column, player_name, so the generated INSERT omitted it and violated the constraint. Player rows are normally created asynchronously by onJoin's logPlayer, so any action produced before that write landed (or by entities/Create fake players with no registered join) hit a missing player row when shutdown force-drained the queue. Fix: getOrCreatePlayerId now takes the full GameProfile and, on a cache/DB miss, inserts a complete row including player_name (falling back to an unknown_ + uuid-prefix name within the 16-char limit when the profile has no name), keeping both playerKeys and playernameKeys caches in sync. No schema or migration changes are needed: the NOT NULL constraint guarantees no dirty rows exist in existing databases. --- .gitignore | 4 ++- .../ledger/database/DatabaseManager.kt | 36 +++++++++++++++++-- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/.gitignore b/.gitignore index 93dfca86..14a91f7b 100644 --- a/.gitignore +++ b/.gitignore @@ -15,4 +15,6 @@ run/ minecraft/ # Mkdocs -site/ \ No newline at end of file +site/ +# zcode local state +.zcode/ diff --git a/src/main/kotlin/com/github/quiltservertools/ledger/database/DatabaseManager.kt b/src/main/kotlin/com/github/quiltservertools/ledger/database/DatabaseManager.kt index c28ee1f8..ce775f51 100644 --- a/src/main/kotlin/com/github/quiltservertools/ledger/database/DatabaseManager.kt +++ b/src/main/kotlin/com/github/quiltservertools/ledger/database/DatabaseManager.kt @@ -84,6 +84,8 @@ object DatabaseManager { private val cache = DatabaseCacheService private var databaseContext = Dispatchers.IO + CoroutineName("Ledger Database") + private const val UNKNOWN_PLAYER_PREFIX = "unknown_" + private const val UNKNOWN_PLAYER_UUID_CHARS = 8 private val ledgerLogger = object : SqlLogger { override fun log(context: StatementContext, transaction: Transaction) { Ledger.logger.info("SQL: ${context.expandArgs(transaction)}") @@ -488,7 +490,7 @@ object DatabaseManager { this[Tables.Actions.blockState] = action.objectState this[Tables.Actions.oldBlockState] = action.oldObjectState this[Tables.Actions.sourceName] = getOrCreateSourceId(action.sourceName) - this[Tables.Actions.sourcePlayer] = action.sourceProfile?.let { getOrCreatePlayerId(it.id) } + this[Tables.Actions.sourcePlayer] = action.sourceProfile?.let { getOrCreatePlayerId(it) } this[Tables.Actions.extraData] = action.extraData } } @@ -602,8 +604,36 @@ object DatabaseManager { ].id.value.also { cache.put(obj!!, it) } } - private fun getOrCreatePlayerId(playerId: UUID): Int = - getOrCreateObjectId(playerId, cache.playerKeys, Tables.Player, Tables.Players, Tables.Players.playerId) + /** + * Returns the id of the player row for the given profile, creating it if necessary. + * + * Unlike the other single-key id tables, [Tables.Players] requires a non-null + * player_name, so a row can't be created from just the uuid. Actions can be flushed + * (e.g. when draining the queue on shutdown) before [insertOrUpdatePlayer] has + * registered the player, so lazily create a complete row from the profile. + */ + private fun getOrCreatePlayerId(profile: GameProfile): Int { + val playerId = profile.id + + cache.playerKeys[playerId]?.let { return it } + Tables.Player.find { Tables.Players.playerId eq playerId }.firstOrNull()?.let { + cache.playerKeys[playerId] = it.id.value + return it.id.value + } + + // Fall back to a uuid-derived name (always <= the 16 char player_name column) for + // profiles without a name. + val name = profile.name?.takeIf { it.isNotEmpty() } + ?: UNKNOWN_PLAYER_PREFIX + playerId.toString().replace("-", "").take(UNKNOWN_PLAYER_UUID_CHARS) + val id = Tables.Player.new { + this.playerId = playerId + this.playerName = name + }.id.value + cache.playerKeys[playerId] = id + cache.playernameKeys[name] = id + + return id + } private fun getOrCreateSourceId(source: String): Int = getOrCreateObjectId(source, cache.sourceKeys, Tables.Source, Tables.Sources, Tables.Sources.name)