diff --git a/src/main/java/net/tfminecraft/marketblock/trade/MarketSale.java b/src/main/java/net/tfminecraft/marketblock/trade/MarketSale.java index cdb2b45..cf4dd28 100644 --- a/src/main/java/net/tfminecraft/marketblock/trade/MarketSale.java +++ b/src/main/java/net/tfminecraft/marketblock/trade/MarketSale.java @@ -7,7 +7,9 @@ import java.nio.file.Path; import java.nio.file.StandardOpenOption; import java.util.Properties; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import net.tfminecraft.denareconomy.api.Denars; import net.tfminecraft.denareconomy.api.Result; @@ -20,6 +22,9 @@ * after the payment is applied. */ public final class MarketSale { + /** Sale folders whose entry in their parent folder this server run has flushed to disk. */ + private static final Set FLUSHED_FOLDERS = ConcurrentHashMap.newKeySet(); + private final String opId; private final CharacterRef seller; private final SaleTake take; @@ -53,15 +58,26 @@ public Result settle() throws IOException { } if (result == Result.APPLIED || result == Result.REFUSED) { Files.deleteIfExists(intent); + // Must stay: an intent that comes back after a crash blocks the seller's next sale (CREATE_NEW). syncDirectory(); } return result; } private void record() throws IOException { - Files.createDirectories(intent.getParent()); - try (FileChannel parent = FileChannel.open(intent.getParent().getParent(), StandardOpenOption.READ)) { - parent.force(true); + Path folder = intent.getParent(); + Path key = folder.toAbsolutePath().normalize(); + boolean created = !Files.isDirectory(folder); + if (created) { + // A recreated folder is a new entry: forget the earlier flush until this one succeeds. + FLUSHED_FOLDERS.remove(key); + Files.createDirectories(folder); + } + // A new folder survives a crash only once its parent is flushed. An existing one is flushed once per + // run, in case the flush after it was made failed; after that its entry is already on disk. + if (created || !FLUSHED_FOLDERS.contains(key)) { + force(folder.getParent()); + FLUSHED_FOLDERS.add(key); } Properties data = new Properties(); data.setProperty("operation", opId); @@ -80,8 +96,12 @@ private void record() throws IOException { } private void syncDirectory() throws IOException { - try (FileChannel directory = FileChannel.open(intent.getParent(), StandardOpenOption.READ)) { - directory.force(true); + force(intent.getParent()); + } + + static void force(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { + channel.force(true); } } } diff --git a/src/test/java/net/tfminecraft/marketblock/trade/MarketSaleTest.java b/src/test/java/net/tfminecraft/marketblock/trade/MarketSaleTest.java index 492191f..0079e4d 100644 --- a/src/test/java/net/tfminecraft/marketblock/trade/MarketSaleTest.java +++ b/src/test/java/net/tfminecraft/marketblock/trade/MarketSaleTest.java @@ -117,6 +117,46 @@ void paidRetryUsesStoredResultAfterTaxChangesAndRefusesChangedGoods() throws Exc } + @Test + void theParentFolderIsFlushedOnlyForANewSaleFolderOrOnceARunForAnExistingOne() throws Exception { + var seller = money.play(player); + player.getInventory().setItem(0, new ItemStack(Material.CARROT, 14)); + Path sales = folder.resolve("sales"); + Path existing = java.nio.file.Files.createDirectories(folder.resolve("existing")); + try (var flushes = mockStatic(MarketSale.class, CALLS_REAL_METHODS)) { + flushes.when(() -> MarketSale.force(any(Path.class))).thenAnswer(call -> null); + + assertEquals(Result.APPLIED, sell(sales, seller)); + assertEquals(Result.APPLIED, sell(sales, seller)); + flushes.verify(() -> MarketSale.force(folder), times(1)); + // Each sale still flushes its intent being written and being removed. + flushes.verify(() -> MarketSale.force(sales), times(4)); + + assertEquals(Result.APPLIED, sell(existing, seller)); + assertEquals(Result.APPLIED, sell(existing, seller)); + flushes.verify(() -> MarketSale.force(folder), times(2)); + + java.nio.file.Files.delete(sales); + assertEquals(Result.APPLIED, sell(sales, seller)); + flushes.verify(() -> MarketSale.force(folder), times(3)); + assertTrue(java.nio.file.Files.isDirectory(sales)); + + // A recreated folder whose flush fails is flushed again by the next sale, before it pays. + java.nio.file.Files.delete(sales); + flushes.when(() -> MarketSale.force(folder)).thenThrow(new java.io.IOException("simulated parent flush failure")); + assertThrows(java.io.IOException.class, () -> sell(sales, seller)); + flushes.when(() -> MarketSale.force(folder)).thenAnswer(call -> null); + assertEquals(Result.APPLIED, sell(sales, seller)); + flushes.verify(() -> MarketSale.force(folder), times(5)); + } + assertEquals(2, player.getInventory().getItem(0).getAmount()); + assertEquals(600, Denars.balance(Denars.pouch(seller))); + } + + private Result sell(Path sales, net.tfminecraft.tlibs.character.CharacterRef seller) throws Exception { + return new MarketSale(sales, seller, InventoryUtils.planTake(player, "v.carrot", 2), 100).settle(); + } + @Test void waitingForFactionTaxDoesNotCreateAnAmbiguousSale() throws Exception { var seller=money.play(player); MockBukkit.createMockPlugin("SimpleFactions");