Skip to content

Skip block entity snapshots on the dispenser logging path - #1007

Open
tricrotism wants to merge 1 commit into
PlayPro:masterfrom
tricrotism:for-upstream/dispenser-block-snapshots
Open

tricrotism wants to merge 1 commit into
PlayPro:masterfrom
tricrotism:for-upstream/dispenser-block-snapshots

Conversation

@tricrotism

Copy link
Copy Markdown

Summary

On Paper and Folia, every dispense makes CoreProtect copy the dispenser's block entity twice on the region thread, and both copies are discarded right away. This change asks Paper for non-snapshot states on that path. Nothing that reads the states needs a copy.

The problem

When a dispenser fires on Paper or Folia, BlockPreDispenseEvent runs on the region thread that owns the dispenser:

  1. BlockPreDispenseListener.onBlockPreDispense calls block.getState() (paper/listener/BlockPreDispenseListener.java:87). For a block entity, Bukkit's getState() builds a snapshot: a full copy of the dispenser block entity, all 9 item slots included.
  2. It passes that state to InventoryChangeListener.inventoryTransaction(String, BlockState, ItemStack[]), which calls onInventoryInteract with inventoryHolder.getInventory().
  3. onInventoryInteract calls inventory.getHolder() (listener/player/InventoryChangeListener.java:132). On Paper, Inventory.getHolder() for a block inventory builds another full snapshot of the same block entity.

Both states are then used only for getType(), getLocation() and getInventory(). A dispenser clock or an item sorter that fires every few ticks pays for two block entity copies per shot. Folia pays for them on the region thread that owns the dispenser.

BlockDispenseListener.java:67 has the same block.getState() call. It runs on Spigot, and on Paper for droppers until the pre-dispense event has been seen for a dropper.

The fix

  • paper/PaperInterface.java: new BlockState getBlockState(Block block, boolean useSnapshot), next to the existing getHolder(Inventory, boolean).
  • paper/PaperAdapter.java (Spigot and non-Paper): returns block.getState(), same as today.
  • paper/PaperHandler.java (Paper, inherited by all Paper adapters): calls Paper's block.getState(useSnapshot). After a LinkageError it falls back to getState() for good. This copies how getHolder is already handled in the same class.
  • paper/listener/BlockPreDispenseListener.java and listener/block/BlockDispenseListener.java: the dispenser state comes from PaperAdapter.ADAPTER.getBlockState(block, false).
  • listener/player/InventoryChangeListener.java: onInventoryInteract uses the existing PaperAdapter.ADAPTER.getHolder(inventory, false), which onInventoryInteractAsync and the hopper listeners already use.

I went through the adapter instead of calling block.getState(false) directly because getState(boolean) is Paper-only API and the listeners also load on Spigot. BlockDispenseListener is included so that the Paper dropper case gets the same treatment. On Spigot that line does exactly what it did before.

Behaviour change

None. Spigot still takes full snapshots.

Risk

A non-snapshot state is a live view of the block entity, so the question is whether anything reads block entity data from it later or on another thread. I followed every reader:

  • BlockPreDispenseListener and BlockDispenseListener: ((InventoryHolder) state).getInventory().getStorageContents(), on the region thread during the event.
  • inventoryTransaction(String, BlockState, ItemStack[]): getType(), instanceof InventoryHolder, getInventory(), getLocation(), all on the calling thread before it returns. The state is not stored.
  • onInventoryInteract: the holder is used only for instanceof, getType() and getLocation() on the calling thread. getLocation() is cloned before the lambda.
  • What goes to ContainerTransactionDispatcher and the consumer is the Inventory object, the copied ItemStack[] (ItemUtils.getContainerState, still on the region thread), the cloned location and the type. No BlockState gets there.
  • For a placed container, Paper's TileStateInventoryHolder.getInventory() returns the live block inventory whether or not the state is a snapshot ("If this block state is not placed this will return the captured inventory snapshot instead"). So the inventory and contents handed on are the same objects and values as before.

onInventoryInteract is shared by the other inventoryTransaction callers: player container interaction, lectern book take, copper golem and CoreProtectAPI.logContainerTransaction. They all reach the holder only through the same three reads, so they behave the same. An API caller that runs this off the owning thread already reads world state off-thread through location.getBlock().getState(). This change does not make that worse.

Older Paper builds without Block.getState(boolean) hit the LinkageError fallback once and continue with snapshots.

Testing

Build: mvn package passes.

Performance, PathLoad dispensers mode, 2 GB heap, autosave off: 100 dispensers fired per tick (2,000 per second) for 60 s after a 60 s warmup, timing each dispense call. Two runs of each jar, plus one run with no CoreProtect for the floor.

Server No CoreProtect Upstream 3af1079 This branch CoreProtect's share per dispense
Paper 26.2 5.7 µs 32.1 / 31.3 µs 7.9 / 8.1 µs 26.0 µs to 2.3 µs (-91%)
Folia 1.21.11 4.9 µs 48.3 / 46.4 µs 15.9 / 15.9 µs 42.4 µs to 11.0 µs (-74%)

Average tick time on Folia during the run: 8.9 / 8.6 ms upstream, 5.7 / 5.5 ms with this branch, 4.5 ms with no CoreProtect.

Dispensers firing bone meal at moss (PathLoad bonemeal mode, 50 dispenses per tick, same setup) go through the same dispense path plus the bone meal growth logging:

Server No CoreProtect Upstream 3af1079 This branch CoreProtect's share per dispense
Paper 26.2 7.5 µs 43.7 / 44.4 µs 14.4 / 16.6 µs 36.5 µs to about 8.0 µs (-78%)
Folia 1.21.11 8.1 µs 60.8 / 60.9 µs 24.7 / 24.2 µs 52.8 µs to about 16.4 µs (-69%)

Average tick time on Folia during the bone meal run: 5.0 / 5.2 ms upstream, 3.3 / 3.2 ms with this branch, 2.6 ms with no CoreProtect.

Row parity: the 47-step scenario on Paper 26.2 and Folia 1.21.11 with SQLite, compared order-insensitively against upstream. The only differing rows are the random plant that bone meal grows and one far-basin water row whose settling window lands either side of the boundary depending on run pacing (seen on upstream-to-upstream reruns too). The dispense and container rows are identical.

Every dispense copied the dispenser's block entity twice on the region thread (block.getState() in the dispense listeners and inventory.getHolder() in onInventoryInteract), and both copies were only used for their type, location and live inventory. Paper can hand out non-snapshot states for this, so the dispense path now asks for those through a new PaperInterface.getBlockState(Block, boolean), falling back to a normal snapshot on Spigot.
@netlify

netlify Bot commented Sep 23, 2026

Copy link
Copy Markdown

❌ Deploy Preview for coreprotect failed. Why did it fail? →

Name Link
🔨 Latest commit 64ef22e
🔍 Latest deploy log https://app.netlify.com/projects/coreprotect/deploys/6ab3e9fb6dc33e0009c523fb

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