Fix races on the pending item transaction lists - #1013
Merged
Merged
Conversation
Item listeners appended to the shared per-player lists with get, add and put while the consumer read and then cleared them, so items could be logged twice or not at all, and the anvil path replaced pending items outright. Listeners now append inside ConcurrentHashMap.compute and the consumer takes each list with remove. The prepared copy is kept for every database type so a retried relational batch still writes its rows.
❌ Deploy Preview for coreprotect failed. Why did it fail? →
|
Contributor
|
Thanks -- automated review is requesting the following changes:
Please preserve the atomic append/take behavior while closing these failure paths. |
Listeners now append their item and register its generation in one Queue.addPendingItems call that holds the Queue lock, the same lock ItemTransactionProcess.discard takes, so discard can no longer clear an item whose transaction has not been queued yet. ItemLogger.prepare now only detaches the pending lists; cloning and merging move into PreparedTransaction.log, which runs after the prepared transaction is stored for the batch, so a cloning failure leaves the detached items available to the retried transaction.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Item transactions (pickup, drop, throw, shoot, craft, trade, anvil, item break) are staged in per-player lists that listener threads write and the consumer thread reads and clears. The two sides are not coordinated, so items can be logged twice or not at all. This change makes the append atomic, makes the consumer take each list whole, and keeps the prepared copy so a retried relational batch still writes its rows.
The problem
Each listener stages its item in a
ConcurrentHashMap<String, List<ItemStack>>inConfigHandlerkeyed byuser.x.y.z, then queues anITEM_TRANSACTIONwith a generation id fromQueue.getItemId. For exampleEntityPickupItemListener.java:29-33:The same pattern is in
CraftItemListener.java:42-63,PlayerDropItemListener.java:44-48,PlayerItemBreakListener.java:26-30andProjectileLaunchListener.java:39-55. These run on the main thread on Paper and on region threads on Folia.On the consumer thread,
ItemTransactionProcess.process(ItemTransactionProcess.java:34-45) callsItemLogger.prepare, which copies each list withsource.get(key)andtoArray(ItemLogger.java:73-76), writes the rows, and then callsclearItemTransaction, which removes all nine lists and the generation counter.The map is concurrent but the lists inside it are plain
ArrayLists shared across threads, and the get, add and put are three separate steps. Three things go wrong:clearItemTransaction. The item is added to the list that is about to be removed.clearItemTransactionremoves it and the counter, and the transaction queued for that pickup finds no counter and writes nothing.getOrDefaultand gets the current list, the consumer copies and removes it, then the listener adds andputs the old list back. The next transaction logs the items that were already logged plus the new one.ArrayList.addrunning duringtoArrayon another thread can throw or copy a partly written array.The anvil path is worse.
InventoryChangeListener.java:553-561doesitemsDestroy.put(key, newList)anditemsCreate.put(key, newList), replacing any items already pending for that player and block, for example a craft done a moment earlier at the same spot.The fix
utility/ItemUtils.java: newaddPendingItems(map, key, items...), which appends insideConcurrentHashMap.compute. The append and the consumer'sremovelock the same bin, so an item goes either into the list being taken or into a new list.EntityPickupItemListener,CraftItemListener,PlayerDropItemListener,PlayerItemBreakListener,ProjectileLaunchListener, and the anvil path inInventoryChangeListener: useaddPendingItemsinstead of get, add and put (the anvil path now appends instead of replacing).getItemIdis called after the append so the generation that gets queued is at least as new as the item it covers.database/logger/ItemLogger.java:snapshotbecomestakeand usesremoveinstead ofget. Once a list is removed, no listener can reach it, so reading it is safe.consumer/process/ItemTransactionProcess.java: the lists are already gone afterprepare, so instead ofclearItemTransactiononly the counter is dropped, withloggingItem.remove(id, generation). If a listener bumped the counter in the meantime, the remove does nothing and the newer queued transaction logs the newer items. The early return for "all lists empty" drops the counter the same way, otherwise it would stay in the map.clearItemTransactionis still used bydiscard.The prepared copy is now stored in
consumerObjectsfor every database type. Upstream only stores it for columnar databases. For MySQL and SQLite, if a batch is rolled back before commit and retained for retry, the retry callsprocessagain with the originalLocation. Upstream had already cleared the lists and counter at that point, so the retry wrote nothing and the rows were lost. With a destructive take the same thing would happen, so the relational path now logs from the storedPreparedTransactionon retry, the same way columnar does.PreparedTransaction.logstill skips blacklisted users, so the old early return inItemLogger.logis not needed on this path.ItemLogger.loghas no remaining callers but is left in place.Rejected: synchronizing each list, or swapping in
CopyOnWriteArrayList. Either fixes the torn read but not the lost and duplicate rows, which come from the get, add, put sequence racing the consumer's remove.Behaviour change
Risk
getItemIdstayssynchronizedonQueue.classas upstream has it.loggingItemis aCollections.synchronizedMap, whoseremove(key, value)is atomic.PreparedTransactionfor relational databases meansProcess.discardFailedConsumerDatano longer sees aLocationfor that id and skipsItemTransactionProcess.discard. That is fine because the lists and counter are already gone by then. Columnar databases already behave this way.computeholds the bin lock only for a list append, so the hot listener paths do not get slower in any way that matters.Testing
Build:
mvn packagepasses.Row parity: a 47-step scenario plugin (blocks, containers, hoppers, pistons, fluids, dispensers, bone meal, explosions, mob deaths, item drops and pickups, then rollback and restore) on Paper 26.2 and Folia 1.21.11 with SQLite, compared order-insensitively against upstream 3af1079 run the same way. The only differing rows on either platform are the plant that bone meal happens to grow (random on every run). No new errors. The race itself needs a burst of item events racing a consumer pass, which the scenario does not produce, so this run shows no regression rather than the fix.
Suggested live test: on MySQL or SQLite, have a player repeatedly drop and pick up the same stack in one spot while a second client crafts and uses an anvil at that block, for about a minute with the consumer busy. Then
/co lookup action:+item,-item user:<player> radius:2should show every drop, pickup, craft and anvil input and output exactly once. Repeat on Folia. For the retry path, stop the database for a few seconds while items are being dropped and check that the drops show up after it comes back.