mirror of
https://github.com/VolmitSoftware/Iris.git
synced 2026-10-25 13:30:08 +00:00
Reduced biome persistence overhead
- Biome encoding runs outside the region write lock - Appends use one framed write before durable publication - Concurrent claims preserve conflict checks and rollback behavior
This commit is contained in:
commit
bbf14a7967
2 files changed
+350
-11
No files matched your search
@@ -99,28 +99,30 @@ public final class SavedBiomeStore {
|
|||||||
|
|
||||||
public boolean claimAndPersist(SavedBiomeChunk chunk) throws IOException {
|
public boolean claimAndPersist(SavedBiomeChunk chunk) throws IOException {
|
||||||
SavedBiomeChunk required = Objects.requireNonNull(chunk, "chunk");
|
SavedBiomeChunk required = Objects.requireNonNull(chunk, "chunk");
|
||||||
synchronized (regionLock(required.chunkX(), required.chunkZ())) {
|
Object lock = regionLock(required.chunkX(), required.chunkZ());
|
||||||
Optional<SavedBiomeChunk> existing = get(required.chunkX(), required.chunkZ());
|
synchronized (lock) {
|
||||||
if (existing.isPresent()) {
|
if (alreadyClaimed(required)) {
|
||||||
if (!existing.get().equals(required)) {
|
return false;
|
||||||
throw new IOException("Conflicting saved biome claim at chunk " + required.chunkX() + ", " + required.chunkZ());
|
}
|
||||||
}
|
}
|
||||||
|
byte[] body = encode(required);
|
||||||
|
byte[] record = ByteBuffer.allocate(body.length + 8)
|
||||||
|
.putInt(body.length).put(body).putInt(checksum(body)).array();
|
||||||
|
synchronized (lock) {
|
||||||
|
if (alreadyClaimed(required)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
byte[] body = encode(required);
|
|
||||||
RegionIndex region = region(required.chunkX(), required.chunkZ());
|
RegionIndex region = region(required.chunkX(), required.chunkZ());
|
||||||
ensureRegionFile(region);
|
ensureRegionFile(region);
|
||||||
long offset;
|
long offset;
|
||||||
try (RandomAccessFile output = new RandomAccessFile(region.path.toFile(), "rw")) {
|
try (RandomAccessFile output = new RandomAccessFile(region.path.toFile(), "rw")) {
|
||||||
offset = output.length();
|
offset = output.length();
|
||||||
if (offset + body.length + 8L > MAXIMUM_REGION_BYTES) {
|
if (offset + record.length > MAXIMUM_REGION_BYTES) {
|
||||||
throw new IOException("Saved biome region exceeds its storage limit: " + region.path);
|
throw new IOException("Saved biome region exceeds its storage limit: " + region.path);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
output.seek(offset);
|
output.seek(offset);
|
||||||
output.writeInt(body.length);
|
output.write(record);
|
||||||
output.write(body);
|
|
||||||
output.writeInt(checksum(body));
|
|
||||||
output.getChannel().force(true);
|
output.getChannel().force(true);
|
||||||
} catch (IOException failure) {
|
} catch (IOException failure) {
|
||||||
try {
|
try {
|
||||||
@@ -202,6 +204,17 @@ public final class SavedBiomeStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean alreadyClaimed(SavedBiomeChunk chunk) throws IOException {
|
||||||
|
Optional<SavedBiomeChunk> existing = get(chunk.chunkX(), chunk.chunkZ());
|
||||||
|
if (existing.isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (!existing.get().equals(chunk)) {
|
||||||
|
throw new IOException("Conflicting saved biome claim at chunk " + chunk.chunkX() + ", " + chunk.chunkZ());
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private RegionIndex region(int chunkX, int chunkZ) throws IOException {
|
private RegionIndex region(int chunkX, int chunkZ) throws IOException {
|
||||||
int regionX = chunkX >> 5;
|
int regionX = chunkX >> 5;
|
||||||
int regionZ = chunkZ >> 5;
|
int regionZ = chunkZ >> 5;
|
||||||
|
|||||||
@@ -0,0 +1,326 @@
|
|||||||
|
package art.arcane.iris.engine.history;
|
||||||
|
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.rules.TemporaryFolder;
|
||||||
|
import org.mockito.MockedConstruction;
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.DataOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.RandomAccessFile;
|
||||||
|
import java.nio.channels.FileChannel;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.Future;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
import java.util.zip.CRC32;
|
||||||
|
import java.util.zip.Deflater;
|
||||||
|
import java.util.zip.DeflaterOutputStream;
|
||||||
|
|
||||||
|
import static org.junit.Assert.assertArrayEquals;
|
||||||
|
import static org.junit.Assert.assertEquals;
|
||||||
|
import static org.junit.Assert.assertFalse;
|
||||||
|
import static org.junit.Assert.assertNotNull;
|
||||||
|
import static org.junit.Assert.assertNull;
|
||||||
|
import static org.junit.Assert.assertSame;
|
||||||
|
import static org.junit.Assert.assertThrows;
|
||||||
|
import static org.junit.Assert.assertTrue;
|
||||||
|
import static org.mockito.AdditionalAnswers.delegatesTo;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
|
import static org.mockito.Mockito.doAnswer;
|
||||||
|
import static org.mockito.Mockito.doReturn;
|
||||||
|
import static org.mockito.Mockito.mock;
|
||||||
|
import static org.mockito.Mockito.mockConstruction;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.spy;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.withSettings;
|
||||||
|
|
||||||
|
public class SavedBiomeStoreWriteTest {
|
||||||
|
@Rule
|
||||||
|
public TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void recordMatchesTheCurrentLengthBodyChecksumFormat() throws Exception {
|
||||||
|
Path root = temporaryFolder.newFolder().toPath();
|
||||||
|
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||||
|
store.claimAndPersist(chunk(0, 3L));
|
||||||
|
assertArrayEquals(expectedRegion(), Files.readAllBytes(regionPath(root)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void encodingAllowsOtherReadsInTheSameRegion() throws Exception {
|
||||||
|
SavedBiomeStore store = SavedBiomeStore.open(temporaryFolder.newFolder().toPath());
|
||||||
|
CountDownLatch encoding = new CountDownLatch(1);
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
SavedBiomeChunk chunk = pausedChunk(0, 3L, encoding, release);
|
||||||
|
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
Future<Boolean> writing = workers.submit(() -> store.claimAndPersist(chunk));
|
||||||
|
assertTrue(encoding.await(5, TimeUnit.SECONDS));
|
||||||
|
Future<?> reading = workers.submit(() -> {
|
||||||
|
assertTrue(store.get(1, 0).isEmpty());
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
reading.get(1, TimeUnit.SECONDS);
|
||||||
|
assertFalse(writing.isDone());
|
||||||
|
release.countDown();
|
||||||
|
assertTrue(writing.get(5, TimeUnit.SECONDS));
|
||||||
|
} finally {
|
||||||
|
release.countDown();
|
||||||
|
drain(workers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void appendWritesOneFrameAndPublishesOnlyAfterForce() throws Exception {
|
||||||
|
Path root = temporaryFolder.newFolder().toPath();
|
||||||
|
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||||
|
store.claimAndPersist(chunk(0, 3L));
|
||||||
|
CountDownLatch forcing = new CountDownLatch(1);
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
ExecutorService workers = Executors.newSingleThreadExecutor();
|
||||||
|
try {
|
||||||
|
Future<Boolean> writing = workers.submit(() -> {
|
||||||
|
try (RandomAccessFile actual = new RandomAccessFile(regionPath(root).toFile(), "rw")) {
|
||||||
|
FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel()));
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
forcing.countDown();
|
||||||
|
await(release);
|
||||||
|
actual.getChannel().force(true);
|
||||||
|
return null;
|
||||||
|
}).when(channel).force(true);
|
||||||
|
try (MockedConstruction<RandomAccessFile> files = mockConstruction(RandomAccessFile.class,
|
||||||
|
withSettings().defaultAnswer(delegatesTo(actual)),
|
||||||
|
(file, context) -> doReturn(channel).when(file).getChannel())) {
|
||||||
|
boolean result = store.claimAndPersist(chunk(1, 3L));
|
||||||
|
assertEquals(1, files.constructed().size());
|
||||||
|
verify(files.constructed().getFirst()).write(any(byte[].class));
|
||||||
|
verify(files.constructed().getFirst(), never()).writeInt(anyInt());
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
assertTrue(forcing.await(5, TimeUnit.SECONDS));
|
||||||
|
assertTrue(store.cached(1, 0).isEmpty());
|
||||||
|
assertFalse(writing.isDone());
|
||||||
|
release.countDown();
|
||||||
|
assertTrue(writing.get(5, TimeUnit.SECONDS));
|
||||||
|
assertEquals(chunk(1, 3L), store.cached(1, 0).orElseThrow());
|
||||||
|
assertEquals(chunk(1, 3L), SavedBiomeStore.open(root).get(1, 0).orElseThrow());
|
||||||
|
} finally {
|
||||||
|
release.countDown();
|
||||||
|
drain(workers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void failedForceRollsBackWithoutPublishingAndRetainsRollbackFailure() throws Exception {
|
||||||
|
for (boolean failRollback : List.of(false, true)) {
|
||||||
|
Path root = temporaryFolder.newFolder().toPath();
|
||||||
|
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||||
|
store.claimAndPersist(chunk(0, 3L));
|
||||||
|
byte[] original = Files.readAllBytes(regionPath(root));
|
||||||
|
IOException failure = new IOException("Saved biome force failed");
|
||||||
|
IOException rollback = new IOException("Saved biome rollback force failed");
|
||||||
|
AtomicBoolean first = new AtomicBoolean(true);
|
||||||
|
try (RandomAccessFile actual = new RandomAccessFile(regionPath(root).toFile(), "rw")) {
|
||||||
|
FileChannel channel = mock(FileChannel.class, delegatesTo(actual.getChannel()));
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
if (first.getAndSet(false)) {
|
||||||
|
throw failure;
|
||||||
|
}
|
||||||
|
if (failRollback) {
|
||||||
|
throw rollback;
|
||||||
|
}
|
||||||
|
actual.getChannel().force(true);
|
||||||
|
return null;
|
||||||
|
}).when(channel).force(true);
|
||||||
|
try (MockedConstruction<RandomAccessFile> ignored = mockConstruction(RandomAccessFile.class,
|
||||||
|
withSettings().defaultAnswer(delegatesTo(actual)),
|
||||||
|
(file, context) -> doReturn(channel).when(file).getChannel())) {
|
||||||
|
assertSame(failure, assertThrows(IOException.class, () -> store.claimAndPersist(chunk(1, 3L))));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals(failRollback ? 1 : 0, failure.getSuppressed().length);
|
||||||
|
if (failRollback) {
|
||||||
|
assertSame(rollback, failure.getSuppressed()[0]);
|
||||||
|
}
|
||||||
|
assertArrayEquals(original, Files.readAllBytes(regionPath(root)));
|
||||||
|
assertTrue(store.get(1, 0).isEmpty());
|
||||||
|
assertTrue(SavedBiomeStore.open(root).get(1, 0).isEmpty());
|
||||||
|
assertTrue(store.claimAndPersist(chunk(1, 3L)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void claimsRecheckDuplicatesAndConflictsAfterConcurrentEncoding() throws Exception {
|
||||||
|
for (long secondActivation : List.of(3L, 4L)) {
|
||||||
|
Path root = temporaryFolder.newFolder().toPath();
|
||||||
|
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||||
|
CountDownLatch encoding = new CountDownLatch(2);
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
SavedBiomeChunk first = pausedChunk(0, 3L, encoding, release);
|
||||||
|
SavedBiomeChunk second = secondActivation == 3L ? first : pausedChunk(0, secondActivation, encoding, release);
|
||||||
|
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
Future<ClaimResult> left = workers.submit(() -> claimResult(store, first));
|
||||||
|
Future<ClaimResult> right = workers.submit(() -> claimResult(store, second));
|
||||||
|
assertTrue(encoding.await(5, TimeUnit.SECONDS));
|
||||||
|
release.countDown();
|
||||||
|
ClaimResult leftResult = left.get(5, TimeUnit.SECONDS);
|
||||||
|
ClaimResult rightResult = right.get(5, TimeUnit.SECONDS);
|
||||||
|
assertTrue(leftResult.added() ^ rightResult.added());
|
||||||
|
ClaimResult loser = leftResult.added() ? rightResult : leftResult;
|
||||||
|
if (secondActivation == 3L) {
|
||||||
|
assertNull(loser.failure());
|
||||||
|
} else {
|
||||||
|
assertNotNull(loser.failure());
|
||||||
|
}
|
||||||
|
long winner = leftResult.added() ? 3L : secondActivation;
|
||||||
|
assertEquals(chunk(0, winner), SavedBiomeStore.open(root).get(0, 0).orElseThrow());
|
||||||
|
} finally {
|
||||||
|
release.countDown();
|
||||||
|
drain(workers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void appendReloadsRegionOffsetsAfterPruningDuringEncoding() throws Exception {
|
||||||
|
Path root = temporaryFolder.newFolder().toPath();
|
||||||
|
SavedBiomeStore store = SavedBiomeStore.open(root);
|
||||||
|
store.claimAndPersist(chunk(0, 3L));
|
||||||
|
store.claimAndPersist(chunk(1, 3L));
|
||||||
|
CountDownLatch encoding = new CountDownLatch(1);
|
||||||
|
CountDownLatch release = new CountDownLatch(1);
|
||||||
|
SavedBiomeChunk pending = pausedChunk(2, 3L, encoding, release);
|
||||||
|
ExecutorService workers = Executors.newFixedThreadPool(2);
|
||||||
|
try {
|
||||||
|
Future<Boolean> writing = workers.submit(() -> store.claimAndPersist(pending));
|
||||||
|
assertTrue(encoding.await(5, TimeUnit.SECONDS));
|
||||||
|
Future<Integer> pruned = workers.submit(() -> store.discardUnstoredClaims(
|
||||||
|
WorldChunkInventory.ofPackedChunks(ChunkGenerationOwnership.packChunk(1, 0)), Set.of(3L)));
|
||||||
|
assertEquals(1, (int) pruned.get(1, TimeUnit.SECONDS));
|
||||||
|
release.countDown();
|
||||||
|
assertTrue(writing.get(5, TimeUnit.SECONDS));
|
||||||
|
assertTrue(store.get(0, 0).isEmpty());
|
||||||
|
assertEquals(chunk(1, 3L), store.get(1, 0).orElseThrow());
|
||||||
|
SavedBiomeStore reopened = SavedBiomeStore.open(root);
|
||||||
|
assertTrue(reopened.get(0, 0).isEmpty());
|
||||||
|
assertEquals(chunk(1, 3L), reopened.get(1, 0).orElseThrow());
|
||||||
|
assertEquals(chunk(2, 3L), reopened.get(2, 0).orElseThrow());
|
||||||
|
} finally {
|
||||||
|
release.countDown();
|
||||||
|
drain(workers);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ClaimResult claimResult(SavedBiomeStore store, SavedBiomeChunk chunk) {
|
||||||
|
try {
|
||||||
|
return new ClaimResult(store.claimAndPersist(chunk), null);
|
||||||
|
} catch (IOException failure) {
|
||||||
|
return new ClaimResult(false, failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SavedBiomeChunk pausedChunk(int x, long activation, CountDownLatch encoding, CountDownLatch release) {
|
||||||
|
SavedBiomeChunk chunk = spy(chunk(x, activation));
|
||||||
|
doAnswer(invocation -> {
|
||||||
|
if (release.getCount() > 0) {
|
||||||
|
encoding.countDown();
|
||||||
|
await(release);
|
||||||
|
}
|
||||||
|
return invocation.callRealMethod();
|
||||||
|
}).when(chunk).column(0, 0);
|
||||||
|
return chunk;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void await(CountDownLatch latch) throws IOException {
|
||||||
|
try {
|
||||||
|
if (!latch.await(10, TimeUnit.SECONDS)) {
|
||||||
|
throw new IOException("Timed out waiting for saved biome write");
|
||||||
|
}
|
||||||
|
} catch (InterruptedException failure) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
throw new IOException("Interrupted waiting for saved biome write", failure);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void drain(ExecutorService workers) throws InterruptedException {
|
||||||
|
workers.shutdown();
|
||||||
|
assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SavedBiomeChunk chunk(int x, long activation) {
|
||||||
|
SavedBiomeChunk.Cell cell = new SavedBiomeChunk.Cell(activation, "biome/main", "region/main");
|
||||||
|
SavedBiomeChunk.Column column = new SavedBiomeChunk.Column(cell, cell,
|
||||||
|
List.of(new SavedBiomeChunk.Span(-64, 320, cell)));
|
||||||
|
SavedBiomeChunk.Builder builder = SavedBiomeChunk.builder(new SavedBiomeChunk.Header(x, 0, activation, -64, 384));
|
||||||
|
for (int z = 0; z < 16; z++) {
|
||||||
|
for (int localX = 0; localX < 16; localX++) {
|
||||||
|
builder.column(localX, z, column);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Path regionPath(Path root) {
|
||||||
|
return root.resolve("iris/generation/biomes/r.0.0.ibio");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static byte[] expectedRegion() throws IOException {
|
||||||
|
ByteArrayOutputStream raw = new ByteArrayOutputStream();
|
||||||
|
DataOutputStream data = new DataOutputStream(raw);
|
||||||
|
data.writeLong(3L);
|
||||||
|
data.writeInt(-64);
|
||||||
|
data.writeInt(384);
|
||||||
|
data.writeInt(1);
|
||||||
|
data.writeLong(3L);
|
||||||
|
data.writeByte(0);
|
||||||
|
data.writeUTF("biome/main");
|
||||||
|
data.writeUTF("region/main");
|
||||||
|
data.writeInt(1);
|
||||||
|
data.write(new byte[]{0, 0, 1, (byte) 0x80, 3, 0});
|
||||||
|
data.write(new byte[256]);
|
||||||
|
ByteArrayOutputStream body = new ByteArrayOutputStream();
|
||||||
|
DataOutputStream coordinates = new DataOutputStream(body);
|
||||||
|
coordinates.writeInt(0);
|
||||||
|
coordinates.writeInt(0);
|
||||||
|
coordinates.writeInt(raw.size());
|
||||||
|
Deflater deflater = new Deflater(Deflater.BEST_SPEED);
|
||||||
|
try (DeflaterOutputStream compressed = new DeflaterOutputStream(coordinates, deflater)) {
|
||||||
|
raw.writeTo(compressed);
|
||||||
|
} finally {
|
||||||
|
deflater.end();
|
||||||
|
}
|
||||||
|
ByteArrayOutputStream region = new ByteArrayOutputStream();
|
||||||
|
DataOutputStream output = new DataOutputStream(region);
|
||||||
|
output.writeInt(0x4942494F);
|
||||||
|
output.writeInt(1);
|
||||||
|
output.writeInt(0);
|
||||||
|
output.writeInt(0);
|
||||||
|
output.writeInt(checksum(region.toByteArray()));
|
||||||
|
output.writeInt(body.size());
|
||||||
|
output.write(body.toByteArray());
|
||||||
|
output.writeInt(checksum(body.toByteArray()));
|
||||||
|
return region.toByteArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int checksum(byte[] bytes) {
|
||||||
|
CRC32 checksum = new CRC32();
|
||||||
|
checksum.update(bytes);
|
||||||
|
return (int) checksum.getValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
private record ClaimResult(boolean added, IOException failure) {
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user