diff --git a/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/data/BufferedLinearRegionFile.java b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/data/BufferedLinearRegionFile.java
index df07737..2555fb9 100644
--- a/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/data/BufferedLinearRegionFile.java
+++ b/shiroha-server/src/main/java/io/nanachiyo0721/shiroha/data/BufferedLinearRegionFile.java
@@ -40,8 +40,15 @@ import java.util.concurrent.locks.ReentrantReadWriteLock;
*
{@code masterFileLock} — master file read / append / replace
* {@code regionObjectLock} — in-memory sector table + swap file channel
*
- * The atomic flags (closed / synced / beingSynced / lastWritten) and the bucket
- * epochs are lock-free and may be touched while holding any (or no) lock.
+ * The atomic flags (closed / synced / beingSynced / lastWritten), the bucket epochs
+ * and the swap space counters (currentAcquiredIndex / liveBytes, mutated only under
+ * the region write lock) are lock-free readable and may be touched while holding any
+ * (or no) lock.
+ *
+ * The swap file is fully transient: it is deleted at open, opened with
+ * DELETE_ON_CLOSE and never parsed back after a crash, so it carries no header and
+ * is never fsynced. Durability comes exclusively from the master file, whose v3
+ * on-disk format is unchanged.
*/
public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.RegionFile {
private static final double SWAP_FILE_AUTO_COMPACT_PERCENT = 3.0 / 5.0; // 60 %
@@ -52,9 +59,7 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
private static final double MASTER_FILE_AUTO_COMPACT_PERCENT = SWAP_FILE_AUTO_COMPACT_PERCENT;
private static final long MASTER_FILE_AUTO_COMPACT_SIZE = SWAP_FILE_AUTO_COMPACT_SIZE;
- private static final long SWAP_FILE_SUPER_BLOCK = 0x1145141919810L;
- private static final int SWAP_FILE_HASH_SEED = 0x0721; // ~(∠・ω< )⌒★
- private static final byte SWAP_FILE_VERSION = 0x02; // ver 2.0
+ private static final int XXHASH32_SEED = 0x0721; // ~(∠・ω< )⌒★
private static final long MASTER_FILE_SUPER_BLOCK = -0x200812250269L;
private static final byte MASTER_FILE_VERSION = 0x02; // ver 2.0
@@ -68,6 +73,24 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
private static final long MAX_SIZE_PER_CHUNK = RegionFile.MAX_CHUNK_SIZE;
+ // on-disk sector layout in the swap file:
+ // dataLen(int) + timestamp(long) + xxhash32(int) + lz4(chunk data)
+ // the 16 meta bytes stay OUTSIDE the compression so neither the write nor the read
+ // path needs a full-size intermediate copy of the chunk data; dataLen doubles as
+ // the lz4 original size, so no separate length prefix is needed
+ private static final int SECTOR_META_SIZE = Integer.BYTES + Long.BYTES + Integer.BYTES;
+
+ // all three are stateless and thread-safe
+ private static final LZ4Compressor LZ4_COMPRESSOR = LZ4Factory.fastestInstance().fastCompressor();
+ private static final LZ4FastDecompressor LZ4_DECOMPRESSOR = LZ4Factory.fastestInstance().fastDecompressor();
+ private static final XXHash32 XX_HASH_32 = XXHashFactory.fastestInstance().hash32();
+
+ // per-thread staging buffer for the hot chunk read/write paths: the compressed
+ // bytes never outlive the single pread/pwrite they are staged for, so they never
+ // need to escape into a fresh allocation
+ private static final int SCRATCH_RETAIN_LIMIT = 2 * 1024 * 1024; // 2 MiB
+ private static final ThreadLocal SCRATCH = ThreadLocal.withInitial(() -> ByteBuffer.allocate(64 * 1024));
+
private static final StandardOpenOption[] SWAP_FILE_CHANNEL_OPTIONS = new StandardOpenOption[]{
StandardOpenOption.CREATE,
StandardOpenOption.WRITE,
@@ -93,15 +116,16 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
private final Object syncLock = new Object();
private final ReadWriteLock regionObjectLock = new ReentrantReadWriteLock();
- private final XXHash32 xxHash32 = XXHashFactory.fastestInstance().hash32();
private Sector[] sectors = new Sector[1024];
- private long currentAcquiredIndex = this.headerSize();
- private int xxHash32Seed = SWAP_FILE_HASH_SEED;
private FileChannel swapFileChannel;
+ // mutated only under regionObjectLock's write lock; volatile so flushInternal()
+ // can run its garbage estimate without taking any lock at all
+ private volatile long currentAcquiredIndex;
+ private volatile long liveBytes;
+
private final byte compressionLevel;
private final MasterFileParser masterFileParser = new MasterFileParser();
- private final CompressingOps compressingOps = new CompressingOps();
// managed by VarHandles following
private boolean closed = false;
@@ -132,10 +156,31 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
this.initSwapFile();
this.tryLoadOldBlinearMasterFileData();
+ // resume WAL mode directly from an existing v3 master file: without this, the
+ // first sync after every open rewrites the whole file even for one dirty chunk
+ this.masterFileParser.tryEnterWalMode(this.masterFilePath);
+
this.flusher = flusher;
this.flusher.addFile(this);
}
+ private static @NotNull ByteBuffer acquireScratch(int capacity) {
+ ByteBuffer buf = SCRATCH.get();
+
+ if (buf.capacity() < capacity) {
+ buf = ByteBuffer.allocate(Math.max(capacity, buf.capacity() << 1));
+
+ // oversized one-off requests get a throwaway buffer instead of pinning
+ // megabytes onto every io thread forever
+ if (buf.capacity() <= SCRATCH_RETAIN_LIMIT) {
+ SCRATCH.set(buf);
+ }
+ }
+
+ buf.clear();
+ return buf;
+ }
+
private static void writeFullyAt(FileChannel channel, @NonNull ByteBuffer buf, long startOffset) throws IOException {
long offset = startOffset;
while (buf.hasRemaining()) {
@@ -182,6 +227,10 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
private void cleanUpSwapFile() throws IOException {
Files.deleteIfExists(this.swapFilePath);
+
+ // a crash between compact's tmp creation and the atomic replace leaves a stale
+ // .swp.tmp behind, which would make every future compact fail at CREATE_NEW
+ Files.deleteIfExists(Path.of(this.swapFilePath + ".tmp"));
}
private void ensureBucketLoaded(int chunkIndex) throws IOException {
@@ -309,99 +358,69 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
SWAP_FILE_CHANNEL_OPTIONS
);
- // fill default sectors
+ // fill default sectors; the swap file has no header, data starts at offset 0
for (int i = 0; i < 1024; i++) {
- this.sectors[i] = new Sector(i, this.headerSize(), 0);
+ this.sectors[i] = new Sector(i, 0, 0);
}
+
+ this.currentAcquiredIndex = 0;
+ this.liveBytes = 0;
}
- private void recalculateAcquiredIndex() {
- long newValue = this.headerSize();
+ private void recalculateCounters() {
+ long acquired = 0;
+ long live = 0;
for (Sector sector : this.sectors) {
+ // cleared sectors keep their stale extent for in-place reuse (see store()),
+ // so their extent MUST still be counted into the acquired watermark here,
+ // or later appends could land inside it and get overwritten by a reuse
+ acquired = Math.max(acquired, sector.offset + sector.length);
+
if (sector.hasData()) {
- newValue = Math.max(newValue, sector.offset + sector.length);
+ live += sector.length;
}
}
- this.currentAcquiredIndex = newValue;
- }
-
- private void writeSwapFileHeaders(boolean forceFile, boolean forceMeta) throws IOException {
- final ByteBuffer buffer = ByteBuffer.allocate(this.headerSize());
-
- buffer.putLong(SWAP_FILE_SUPER_BLOCK); // Magic
- buffer.put(SWAP_FILE_VERSION); // Version
- buffer.putInt(this.xxHash32Seed); // XXHash32 seed
- buffer.putLong(this.currentAcquiredIndex); // Acquired index
-
- for (Sector sector : this.sectors) {
- // encode each sector
- buffer.put(sector.getEncoded());
- }
-
- buffer.flip();
-
- writeFullyAt(this.swapFileChannel, buffer, 0);
-
- if (forceFile) {
- this.swapFileChannel.force(forceMeta);
- }
- }
-
- private int sectorSize() {
- return this.sectors.length * Sector.sizeOfSingle();
- }
-
- private int headerSize() {
- int result = 0;
-
- result += Long.BYTES; // Magic
- result += Byte.BYTES; // Version
- result += Integer.BYTES; // XXHash32 seed
- result += Long.BYTES; // Acquired index
- result += this.sectorSize(); // Sectors
-
- return result;
+ this.currentAcquiredIndex = acquired;
+ this.liveBytes = live;
}
private void flushInternal() throws IOException {
- boolean initiallySyncRequired;
-
- this.regionObjectLock.writeLock().lock();
- try {
- if (this.isClosedRaw()) {
- return;
- }
-
- long liveSize = 0;
- for (Sector sector : this.sectors) {
- // skip no data sectors
- if (!sector.hasData()) {
- continue;
- }
-
- liveSize += sector.length;
- }
-
- // everything acquired but not covered by a live sector is garbage
- final long spareSize = this.currentAcquiredIndex - this.headerSize() - liveSize;
-
- final boolean compactRequested = spareSize > SWAP_FILE_AUTO_COMPACT_SIZE && (double) spareSize > ((double) liveSize) * SWAP_FILE_AUTO_COMPACT_PERCENT;
-
- // try auto compact to clean the garbage area
- if (compactRequested) {
- // do compact
- this.compactSwapFile();
- }
-
- // prevent syncing after compact because it could be time costing sometimes
- initiallySyncRequired = !Files.exists(this.masterFilePath) && !compactRequested;
- } finally {
- this.regionObjectLock.writeLock().unlock();
+ if (this.isClosedRaw()) {
+ return;
}
- if (initiallySyncRequired) {
+ // lock-free garbage estimate from the incrementally maintained counters:
+ // this runs after EVERY chunk write, so no write lock, no O(1024) sector
+ // scan and no Files.exists() stat on the hot path
+ final long live = this.liveBytes;
+ final long spare = this.currentAcquiredIndex - live;
+ final boolean compactRequested = spare > SWAP_FILE_AUTO_COMPACT_SIZE && (double) spare > (double) live * SWAP_FILE_AUTO_COMPACT_PERCENT;
+
+ // try auto compact to clean the garbage area
+ if (compactRequested) {
+ this.regionObjectLock.writeLock().lock();
+ try {
+ if (!this.isClosedRaw()) {
+ // recheck with the authoritative values under the lock
+ final long liveNow = this.liveBytes;
+ final long spareNow = this.currentAcquiredIndex - liveNow;
+
+ if (spareNow > SWAP_FILE_AUTO_COMPACT_SIZE && (double) spareNow > (double) liveNow * SWAP_FILE_AUTO_COMPACT_PERCENT) {
+ // do compact
+ this.compactSwapFile();
+ }
+ }
+ } finally {
+ this.regionObjectLock.writeLock().unlock();
+ }
+ }
+
+ // create the master file eagerly on the very first write of a fresh region;
+ // afterwards this is a single volatile read per chunk write.
+ // prevent syncing after compact because it could be time costing sometimes
+ if (!compactRequested && !this.masterFileParser.masterFileExists()) {
this.syncToMasterFile(false, false);
}
}
@@ -462,8 +481,6 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
}
private void compactSwapFile() throws IOException {
- this.writeSwapFileHeaders(true, true); // save headers for compact
-
final Sector[] newSectorsToBeReplaced = new Sector[this.sectors.length];
for (int i = 0; i < this.sectors.length; i++) {
@@ -477,7 +494,7 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
// note:
// we reset length to 0 and this would make length <= newLength(which is >= 0) is always true.
// so that the following write operation wouldn't override the data of other sectors
- // see the write method in Sector class
+ // see the store method in Sector class
newSectorsToBeReplaced[i] = new Sector(i, 0, 0);
}
@@ -492,7 +509,7 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
StandardOpenOption.READ,
StandardOpenOption.TRUNCATE_EXISTING
)) {
- long offsetPointer = this.headerSize();
+ long offsetPointer = 0;
for (Sector sector : newSectorsToBeReplaced) {
// skip cleared or no data-contained sectors
@@ -511,12 +528,13 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
newSectorsToBeReplaced[sector.index] = newRecalculated; // update sector infos
}
- tempChannel.force(true);
+ // note: NO force here — the swap file is transient and never read back
+ // after a crash, so fsyncing it (twice, like before) was pure overhead
newAcquiredIndex = offsetPointer;
} catch (Throwable ex) {
- // recalculate acquired index
- this.recalculateAcquiredIndex();
+ // recalculate counters
+ this.recalculateCounters();
// delete the target temp file
Files.deleteIfExists(targetTemp);
// fast-fail
@@ -530,8 +548,8 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
try {
atomicReplace(targetTemp, this.swapFilePath);
} catch (Throwable e) {
- // recalculate acquired index
- this.recalculateAcquiredIndex();
+ // recalculate counters
+ this.recalculateCounters();
// reopen closed channel
this.reopenSwapFileChannel();
// fast-fail
@@ -543,12 +561,10 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
// reopen file channel
this.reopenSwapFileChannel();
- // replace with recalculated file headers
+ // replace with recalculated infos: after a compact everything left is live
this.sectors = newSectorsToBeReplaced;
this.currentAcquiredIndex = newAcquiredIndex;
-
- // flush to file
- this.writeSwapFileHeaders(true, true);
+ this.liveBytes = newAcquiredIndex;
} catch (Throwable ex) {
// we are totally failed here,
// directly mark as closed as the swap file is already replaced, and we failed to update the
@@ -571,14 +587,13 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
);
}
- private void writeChunkDataRaw(int index, ByteBuffer chunkData, boolean skipSync) throws IOException {
- final ByteBuffer committed = this.compressingOps.compress(chunkData); // run compression out of lock
-
+ // stores an already lz4-encoded sector (meta + compressed data), typically staged
+ // in the thread-local scratch: nothing here escapes to the heap
+ private void storeSector(int index, @NotNull ByteBuffer encoded, boolean skipSync) throws IOException {
this.regionObjectLock.writeLock().lock();
try {
- final Sector sector = this.sectors[index];
+ this.sectors[index].store(encoded, this.swapFileChannel);
- sector.store(committed, this.swapFileChannel);
if (!skipSync) {
this.markBucketDirty(index);
}
@@ -593,23 +608,26 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
this.markAsToSync();
}
- private @Nullable ByteBuffer readChunkDataRaw(int index) throws IOException {
- final ByteBuffer raw;
-
- this.regionObjectLock.readLock().lock();
- try {
- final Sector sector = this.sectors[index];
-
- if (!sector.hasData()) {
- return null;
- }
-
- raw = sector.read(this.swapFileChannel);
- } finally {
- this.regionObjectLock.readLock().unlock();
+ // section = dataLen(int) + timestamp(long) + xxhash32(int) + data, i.e. the exact
+ // per-chunk byte layout persisted inside master file bucket records
+ private void writeSection(int index, @NotNull ByteBuffer section, boolean skipSync) throws IOException {
+ if (section.remaining() < SECTOR_META_SIZE) {
+ throw new IOException("Truncated chunk section (" + section.remaining() + " bytes) for index " + index);
}
- return this.compressingOps.decompress(raw);
+ final int dataLen = section.remaining() - SECTOR_META_SIZE;
+ final ByteBuffer out = acquireScratch(SECTOR_META_SIZE + LZ4_COMPRESSOR.maxCompressedLength(dataLen));
+
+ // meta bytes are carried over verbatim, only the chunk data goes through lz4
+ final int oldLimit = section.limit();
+ section.limit(section.position() + SECTOR_META_SIZE);
+ out.put(section);
+ section.limit(oldLimit);
+
+ LZ4_COMPRESSOR.compress(section, out);
+ out.flip();
+
+ this.storeSector(index, out, skipSync);
}
private void clearChunkData(int index) throws IOException {
@@ -617,9 +635,7 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
this.regionObjectLock.writeLock().lock();
try {
- final Sector sector = this.sectors[index];
-
- sector.clear();
+ this.sectors[index].clear();
this.markBucketDirty(index);
} finally {
this.regionObjectLock.writeLock().unlock();
@@ -653,24 +669,26 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
this.ensureBucketLoaded(chunkIndex);
- if (data.remaining() > MAX_SIZE_PER_CHUNK) {
- throw new RegionFileStorage.RegionFileSizeException("Writing too large chunk, limit : " + MAX_SIZE_PER_CHUNK + " but got : " + data.remaining());
+ final int dataLen = data.remaining();
+
+ if (dataLen > MAX_SIZE_PER_CHUNK) {
+ throw new RegionFileStorage.RegionFileSizeException("Writing too large chunk, limit : " + MAX_SIZE_PER_CHUNK + " but got : " + dataLen);
}
- final int oldPositionOfData = data.position();
- final int xxHash32OfData = this.xxHash32.hash(data, this.xxHash32Seed);
- data.position(oldPositionOfData);
+ // absolute-offset hash: no position save/restore dance needed
+ final int xxHash32OfData = XX_HASH_32.hash(data, data.position(), dataLen, XXHASH32_SEED);
- // uncompressed length(int) + timestamp(long) + xxhash32(int)
- final ByteBuffer chunkSectionBuilder = ByteBuffer.allocate(data.remaining() + 4 + 8 + 4);
+ // meta + compressed data are built directly in the reusable scratch: no
+ // full-size intermediate copy of the chunk data, no allocation that escapes
+ final ByteBuffer out = acquireScratch(SECTOR_META_SIZE + LZ4_COMPRESSOR.maxCompressedLength(dataLen));
- chunkSectionBuilder.putInt(data.remaining()); // Length(int)
- chunkSectionBuilder.putLong(System.currentTimeMillis()); // Timestamp(long)
- chunkSectionBuilder.putInt(xxHash32OfData); // xxHash32 of the original data(int)
- chunkSectionBuilder.put(data); // Data(bytes)
- chunkSectionBuilder.flip();
+ out.putInt(dataLen); // uncompressed length, doubles as the lz4 original size
+ out.putLong(System.currentTimeMillis()); // timestamp
+ out.putInt(xxHash32OfData); // xxHash32 of the original data
+ LZ4_COMPRESSOR.compress(data, out);
+ out.flip();
- this.writeChunkDataRaw(chunkIndex, chunkSectionBuilder, false);
+ this.storeSector(chunkIndex, out, false);
}
private @Nullable ByteBuffer readChunk(int x, int z) throws IOException {
@@ -678,34 +696,42 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
this.ensureBucketLoaded(chunkIndex);
- final ByteBuffer data = this.readChunkDataRaw(chunkIndex);
+ final ByteBuffer stage;
- if (data == null) {
- return null;
+ this.regionObjectLock.readLock().lock();
+ try {
+ final Sector sector = this.sectors[chunkIndex];
+
+ if (!sector.hasData()) {
+ return null;
+ }
+
+ // only the pread runs under the lock, staged into the reusable scratch
+ stage = acquireScratch((int) sector.length);
+ stage.limit((int) sector.length);
+
+ readFullyAt(this.swapFileChannel, stage, sector.offset);
+ } finally {
+ this.regionObjectLock.readLock().unlock();
}
- final int length = data.getInt(); // compressed length(int)
- final long timestamp = data.getLong(); // TODO use this timestamp(long) for something?
- final int dataXXHash32 = data.getInt(); // XXHash32 for validation(int)
+ stage.flip();
- final IOException xxHash32CheckFailedEx = this.checkXXHash32(dataXXHash32, data);
- if (xxHash32CheckFailedEx != null) {
- throw xxHash32CheckFailedEx; // prevent from loading
+ final int dataLen = stage.getInt();
+ stage.getLong(); // TODO use this timestamp(long) for something?
+ final int expectedXXHash32 = stage.getInt();
+
+ // lz4 decompresses straight from the scratch into the result buffer: the
+ // compressed bytes are never copied into an intermediate array
+ final byte[] data = new byte[dataLen];
+ LZ4_DECOMPRESSOR.decompress(stage.array(), stage.arrayOffset() + SECTOR_META_SIZE, data, 0, dataLen);
+
+ final int actualXXHash32 = XX_HASH_32.hash(data, 0, dataLen, XXHASH32_SEED);
+ if (actualXXHash32 != expectedXXHash32) {
+ throw new IOException("XXHash32 check failed ! Expected: " + expectedXXHash32 + ",but got: " + actualXXHash32); // prevent from loading
}
- return data;
- }
-
- private @Nullable IOException checkXXHash32(long originalXXHash32, @NotNull ByteBuffer input) {
- final int oldPositionOfInput = input.position();
- final int currentXXHash32 = this.xxHash32.hash(input, this.xxHash32Seed);
- input.position(oldPositionOfInput);
-
- if (originalXXHash32 != currentXXHash32) {
- return new IOException("XXHash32 check failed ! Expected: " + originalXXHash32 + ",but got: " + currentXXHash32);
- }
-
- return null;
+ return ByteBuffer.wrap(data);
}
@Override
@@ -821,34 +847,6 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
}
}
- // here we use this tool to prevent the swap file goes too large
- // sometimes when a region contains all chunks, it might be very huge without any compressions(around 100MiB)
- private static class CompressingOps {
- private final LZ4Compressor lz4Compressor = LZ4Factory.fastestInstance().fastCompressor();
- private final LZ4FastDecompressor lz4Decompressor = LZ4Factory.fastestInstance().fastDecompressor();
-
- public @NotNull ByteBuffer compress(@NotNull ByteBuffer in) {
- final int bufferLenToAllocate = this.lz4Compressor.maxCompressedLength(in.remaining());
- final ByteBuffer result = ByteBuffer.allocate(bufferLenToAllocate + 4);
-
- result.putInt(in.remaining());
- this.lz4Compressor.compress(in, result);
-
- return result.flip();
- }
-
- public @NotNull ByteBuffer decompress(@NotNull ByteBuffer flippedIn) {
- final int originalLen = flippedIn.getInt();
- final byte[] raw = new byte[flippedIn.remaining()];
- flippedIn.get(raw);
-
- final byte[] decompressed = new byte[originalLen];
- this.lz4Decompressor.decompress(raw, decompressed);
-
- return ByteBuffer.wrap(decompressed);
- }
- }
-
public class Sector {
private final int index;
private long offset;
@@ -861,76 +859,51 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
this.length = length;
}
- public @NotNull ByteBuffer read(@NotNull FileChannel channel) throws IOException {
- final ByteBuffer result = ByteBuffer.allocate((int) this.length);
-
- readFullyAt(channel, result, this.offset);
-
- result.flip();
- return result;
- }
-
public void store(@NotNull ByteBuffer newData, @NotNull FileChannel channel) throws IOException {
final long oldLength = this.length;
+ final long oldLive = this.hasData ? oldLength : 0L;
final long newDataLength = newData.remaining();
this.hasData = true;
this.length = newDataLength;
- // data is smaller or its length equals to the local buffer we hold, write it directly
+ // data fits into the extent this sector already owns (a cleared sector keeps
+ // its stale extent exactly for this reuse), write it in place
if (newDataLength <= oldLength) {
writeFullyAt(channel, newData, this.offset);
+ } else {
+ // or we will append to the end of file
+ this.offset = BufferedLinearRegionFile.this.currentAcquiredIndex;
+ BufferedLinearRegionFile.this.currentAcquiredIndex = this.offset + newDataLength;
- return;
+ writeFullyAt(channel, newData, this.offset);
}
- // or we will append to the end of file
- this.offset = BufferedLinearRegionFile.this.currentAcquiredIndex;
-
- BufferedLinearRegionFile.this.currentAcquiredIndex += this.length;
-
- writeFullyAt(channel, newData, this.offset);
- }
-
- private @NotNull ByteBuffer getEncoded() {
- final ByteBuffer buffer = ByteBuffer.allocate(sizeOfSingle());
-
- buffer.putLong(this.offset);
- buffer.putLong(this.length);
- buffer.put((byte) (this.hasData ? 1 : 0));
- buffer.flip();
-
- return buffer;
- }
-
- public void restoreFrom(@NotNull ByteBuffer buffer) {
- this.offset = buffer.getLong();
- this.length = buffer.getLong();
- this.hasData = buffer.get() == 1;
-
- if (this.length < 0 || this.offset < 0) {
- throw new IllegalStateException("Invalid sector data: " + this);
- }
+ // single mutator under the region write lock; keeps the garbage estimate
+ // in flushInternal() lock-free and scan-free
+ BufferedLinearRegionFile.this.liveBytes += newDataLength - oldLive;
}
public void clear() {
+ if (this.hasData) {
+ BufferedLinearRegionFile.this.liveBytes -= this.length;
+ }
+
this.hasData = false;
}
public boolean hasData() {
return this.hasData;
}
-
- static int sizeOfSingle() {
- // offset + length hasData
- return Long.BYTES * 2 + 1;
- }
}
private class ChunkBufferHelper extends ByteArrayOutputStream {
private final ChunkPos pos;
private ChunkBufferHelper(ChunkPos pos) {
+ // chunk NBT payloads are tens to hundreds of KiB: BAOS's default 32 bytes
+ // means a dozen grow-and-copy rounds per single chunk serialization
+ super(8192);
this.pos = pos;
}
@@ -945,7 +918,7 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
}
private class MasterFileParser {
- // V3 bucketed format layout:
+ // V3 bucketed format layout (UNCHANGED, fully compatible with existing files):
// [0, 14): header — superblock(8) + version(1) + compressionLevel(1) + xxHash32Seed(4)
// [14, 142): position table — BUCKET_COUNT(16) × long(8) each; 0 = no data for that bucket
// [142, EOF): bucket records — originalLen(int) + compressedLen(int) + compressedData
@@ -957,9 +930,10 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
private final ReadWriteLock masterFileLock = new ReentrantReadWriteLock();
- // WAL(append) state, guarded by masterFileLock: null until the first sync after
- // open has fully rewritten the master file; afterwards syncs only append changed
- // buckets to the tail and update the position table in place.
+ // WAL(append) state, guarded by masterFileLock: non-null whenever a valid v3
+ // master file is open for appending — restored directly at open time by
+ // tryEnterWalMode(), or (re)established by rewriteFully(); syncs then only
+ // append changed buckets to the tail and update the position table in place.
// recordSizes mirrors positionTable (size of each live record) so the garbage
// ratio can be computed without touching the disk
private @Nullable FileChannel appendChannel;
@@ -967,16 +941,103 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
private long[] recordSizes;
private long appendOffset;
+ // single volatile read instead of a Files.exists() stat per chunk write
+ private volatile boolean fileExists;
+
// a consistent snapshot of one bucket taken from the swap file;
// payload == null means the bucket holds no chunks at all
- private record BucketRecord(long epoch, byte @Nullable [] payload) {
+ private record BucketRecord(long epoch, @Nullable ByteBuffer payload) {
+ }
+
+ public boolean masterFileExists() {
+ return this.fileExists;
+ }
+
+ // resumes WAL mode from an existing, structurally valid v3 master file so the
+ // first sync after open can append instead of rewriting the entire file.
+ // bails out silently (leaving the full-rewrite path armed) if the file is
+ // missing, not v3, or its position table doesn't validate
+ public void tryEnterWalMode(@NotNull Path mainFile) throws IOException {
+ this.masterFileLock.writeLock().lock();
+ try {
+ // legacy migration in tryParseMainFileOld() may have entered WAL already
+ if (this.appendChannel != null) {
+ return;
+ }
+
+ if (!Files.exists(mainFile)) {
+ return;
+ }
+
+ this.fileExists = true;
+
+ final FileChannel channel = FileChannel.open(mainFile, StandardOpenOption.READ, StandardOpenOption.WRITE);
+ boolean success = false;
+ try {
+ final long fileSize = channel.size();
+
+ if (fileSize < V3_DATA_AREA_OFFSET) {
+ return;
+ }
+
+ final ByteBuffer header = ByteBuffer.allocate(V3_HEADER_SIZE);
+ readFullyAt(channel, header, 0);
+ header.flip();
+
+ if (header.getLong() != MASTER_FILE_SUPER_BLOCK || header.get() != MASTER_FILE_VERSION_BUCKET) {
+ return;
+ }
+
+ final long[] table = this.parseOffsetTable(channel);
+ final long[] sizes = new long[BUCKET_COUNT];
+ long dataEnd = V3_DATA_AREA_OFFSET;
+
+ for (int i = 0; i < BUCKET_COUNT; i++) {
+ final long recordOffset = table[i];
+
+ if (recordOffset == 0) {
+ continue;
+ }
+
+ if (recordOffset < V3_DATA_AREA_OFFSET || recordOffset + V3_RECORD_HEADER_SIZE > fileSize) {
+ return; // corrupted table: stay in full-rewrite mode
+ }
+
+ final ByteBuffer lens = this.readRecordLengths(channel, recordOffset);
+ final int originalLen = lens.getInt();
+ final int compressedLen = lens.getInt();
+
+ if (originalLen < 0 || compressedLen < 0 || recordOffset + V3_RECORD_HEADER_SIZE + compressedLen > fileSize) {
+ return; // corrupted record header: stay in full-rewrite mode
+ }
+
+ sizes[i] = V3_RECORD_HEADER_SIZE + (long) compressedLen;
+ dataEnd = Math.max(dataEnd, recordOffset + sizes[i]);
+ }
+
+ // append after the last referenced record: anything past that is
+ // uncommitted garbage from a torn previous append and may be reused
+ this.appendChannel = channel;
+ this.positionTable = table;
+ this.recordSizes = sizes;
+ this.appendOffset = dataEnd;
+ success = true;
+ } finally {
+ if (!success) {
+ channel.close();
+ }
+ }
+ } finally {
+ this.masterFileLock.writeLock().unlock();
+ }
}
// must be called under syncLock (see syncToMasterFile)
public void sync(@NotNull Path mainFile, boolean forceCompact) throws IOException {
this.masterFileLock.writeLock().lock();
try {
- // full rewrite on the first sync after open, and afterwards whenever the
+ // full rewrite whenever no valid append state exists (fresh region /
+ // corrupted table / legacy migration), and afterwards whenever the
// appended garbage passed the auto-compact threshold: writes a tmp file,
// then atomically replaces the master file with it
if (this.appendChannel == null || this.shouldCompactMasterFile() || forceCompact) {
@@ -1003,22 +1064,28 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
}
private void rewriteFully(@NotNull Path mainFile) throws IOException {
- // a compacting rewrite replaces the whole file, so drop the old append channel
- // first; if anything below fails, the next sync just retries via this path
- if (this.appendChannel != null) {
- this.appendChannel.close();
- this.appendChannel = null;
- }
-
+ final boolean wal = this.appendChannel != null;
final Path tmpFilePath = Path.of(mainFile + ".tmp");
final long[] syncedBucketEpochs = new long[BUCKET_COUNT];
final long[] newPositionTable = new long[BUCKET_COUNT];
final long[] newRecordSizes = new long[BUCKET_COUNT];
final long newAppendOffset;
- // open the old file to copy the non-dirty buckets over
- try (FileChannel oldChannel = this.openV3MasterFile(mainFile)) {
- final long[] oldPositionTable = oldChannel == null ? null : this.parseOffsetTable(oldChannel);
+ FileChannel legacySource = null;
+ try {
+ final FileChannel oldChannel;
+ final long[] oldPositionTable;
+
+ if (wal) {
+ // reuse the live append channel as the copy source together with the
+ // cached table/sizes: no reopen and no per-bucket length pread needed
+ oldChannel = this.appendChannel;
+ oldPositionTable = this.positionTable;
+ } else {
+ legacySource = this.openV3MasterFile(mainFile);
+ oldChannel = legacySource;
+ oldPositionTable = oldChannel == null ? null : this.parseOffsetTable(oldChannel);
+ }
try (FileChannel outChannel = FileChannel.open(tmpFilePath,
StandardOpenOption.CREATE, StandardOpenOption.WRITE, StandardOpenOption.TRUNCATE_EXISTING)) {
@@ -1034,10 +1101,12 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
final BucketRecord record = this.buildBucketRecord(bucketIndex);
if (record.payload() != null) {
- writeFullyAt(outChannel, ByteBuffer.wrap(record.payload()), dataOffset);
+ final int recordSize = record.payload().remaining();
+
+ writeFullyAt(outChannel, record.payload(), dataOffset);
newPositionTable[bucketIndex] = dataOffset;
- newRecordSizes[bucketIndex] = record.payload().length;
- dataOffset += record.payload().length;
+ newRecordSizes[bucketIndex] = recordSize;
+ dataOffset += recordSize;
}
// else: the bucket is empty now, its table entry stays 0
@@ -1045,9 +1114,15 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
} else if (oldPositionTable != null && oldPositionTable[bucketIndex] != 0) {
// not dirty: copy the record bytes straight from the old file
final long oldOffset = oldPositionTable[bucketIndex];
- final ByteBuffer lens = this.readRecordLengths(oldChannel, oldOffset);
- lens.getInt(); // skip originalLen
- final long recordSize = V3_RECORD_HEADER_SIZE + (long) lens.getInt();
+ final long recordSize;
+
+ if (wal) {
+ recordSize = this.recordSizes[bucketIndex];
+ } else {
+ final ByteBuffer lens = this.readRecordLengths(oldChannel, oldOffset);
+ lens.getInt(); // skip originalLen
+ recordSize = V3_RECORD_HEADER_SIZE + (long) lens.getInt();
+ }
transferFully(oldChannel, oldOffset, recordSize, outChannel, dataOffset);
newPositionTable[bucketIndex] = dataOffset;
@@ -1063,15 +1138,39 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
newAppendOffset = dataOffset;
}
+ } catch (Throwable e) {
+ // don't leak the half-written tmp file; in WAL mode the append state is
+ // untouched so the next sync just retries the compact, in legacy mode
+ // the next sync retries this full-rewrite path
+ try {
+ Files.deleteIfExists(tmpFilePath);
+ } catch (Throwable e2) {
+ e.addSuppressed(e2);
+ }
+
+ throw e instanceof IOException io ? io : new IOException("Failed to rewrite master file!", e);
+ } finally {
+ if (legacySource != null) {
+ legacySource.close();
+ }
+ }
+
+ // close the append channel before the replace: some platforms (windows)
+ // refuse to replace a file that still has open handles
+ if (wal) {
+ final FileChannel toClose = this.appendChannel;
+ this.appendChannel = null; // if close() throws, fall back to full rewrite next sync
+ toClose.close();
}
atomicReplace(tmpFilePath, mainFile);
- // enter WAL mode: keep the freshly written master file open for appending syncs
+ // (re)enter WAL mode: keep the freshly written master file open for appending syncs
this.appendChannel = FileChannel.open(mainFile, StandardOpenOption.READ, StandardOpenOption.WRITE);
this.positionTable = newPositionTable;
this.recordSizes = newRecordSizes;
this.appendOffset = newAppendOffset;
+ this.fileExists = true;
this.markBucketsSynced(syncedBucketEpochs);
}
@@ -1081,7 +1180,9 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
final long[] syncedBucketEpochs = new long[BUCKET_COUNT];
final long[] newPositionTable = this.positionTable.clone();
final long[] newRecordSizes = this.recordSizes.clone();
+ final ByteBuffer[] pending = new ByteBuffer[BUCKET_COUNT];
long dataOffset = this.appendOffset;
+ int pendingCount = 0;
boolean anyDirty = false;
for (int bucketIndex = 0; bucketIndex < BUCKET_COUNT; bucketIndex++) {
@@ -1090,12 +1191,13 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
}
final BucketRecord record = this.buildBucketRecord(bucketIndex);
+ final ByteBuffer payload = record.payload();
- if (record.payload() != null) {
- writeFullyAt(channel, ByteBuffer.wrap(record.payload()), dataOffset);
+ if (payload != null) {
+ pending[pendingCount++] = payload;
newPositionTable[bucketIndex] = dataOffset;
- newRecordSizes[bucketIndex] = record.payload().length;
- dataOffset += record.payload().length;
+ newRecordSizes[bucketIndex] = payload.remaining();
+ dataOffset += payload.remaining();
} else {
// the bucket is empty now
newPositionTable[bucketIndex] = 0;
@@ -1110,8 +1212,19 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
return;
}
- // make the appended records durable before the position table may point at them
- channel.force(false);
+ if (pendingCount > 0) {
+ // all records land contiguously at the tail: one gathering write (writev)
+ // instead of one pwrite per dirty bucket
+ channel.position(this.appendOffset);
+
+ final ByteBuffer last = pending[pendingCount - 1];
+ while (last.hasRemaining()) {
+ channel.write(pending, 0, pendingCount);
+ }
+
+ // make the appended records durable before the position table may point at them
+ channel.force(false);
+ }
// commit the new tail first: even a torn position table write can then never
// cause a later append to overwrite records the on-disk table already references
@@ -1126,12 +1239,19 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
this.markBucketsSynced(syncedBucketEpochs);
}
- // snapshots one bucket under a short read lock (raw sector bytes only);
- // LZ4 decompression and zstd compression both run outside any lock so
- // writers are only blocked while the raw bytes are copied
+ // snapshots one bucket under a short read lock (raw sector bytes only, with
+ // sectors that sit back to back in the swap file coalesced into single preads);
+ // LZ4 decompression and zstd compression both run outside any lock so writers
+ // are only blocked while the raw bytes are copied
private @NotNull BucketRecord buildBucketRecord(int bucketIndex) throws IOException {
final int baseChunkIndex = bucketIndex << BUCKET_SHIFT;
- final ByteBuffer[] rawSectors = new ByteBuffer[BUCKET_SIZE];
+ final ByteBuffer[] rawSectors = new ByteBuffer[BUCKET_SIZE]; // slices into run buffers, null = no data
+
+ final long[] offsets = new long[BUCKET_SIZE];
+ final long[] lengths = new long[BUCKET_SIZE];
+ final int[] slots = new int[BUCKET_SIZE];
+ int liveCount = 0;
+
final long epoch;
BufferedLinearRegionFile.this.regionObjectLock.readLock().lock();
@@ -1143,48 +1263,119 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
for (int i = 0; i < BUCKET_SIZE; i++) {
final Sector sector = BufferedLinearRegionFile.this.sectors[baseChunkIndex + i];
- rawSectors[i] = sector.hasData() ? sector.read(BufferedLinearRegionFile.this.swapFileChannel) : null;
+ if (!sector.hasData()) {
+ continue;
+ }
+
+ offsets[liveCount] = sector.offset;
+ lengths[liveCount] = sector.length;
+ slots[liveCount] = i;
+ liveCount++;
+ }
+
+ if (liveCount == 0) {
+ return new BucketRecord(epoch, null);
+ }
+
+ sortByOffset(offsets, lengths, slots, liveCount);
+
+ int i = 0;
+ while (i < liveCount) {
+ int j = i;
+ long runEnd = offsets[i] + lengths[i];
+
+ while (j + 1 < liveCount && offsets[j + 1] == runEnd) {
+ j++;
+ runEnd += lengths[j];
+ }
+
+ final ByteBuffer run = ByteBuffer.allocate((int) (runEnd - offsets[i]));
+ readFullyAt(BufferedLinearRegionFile.this.swapFileChannel, run, offsets[i]);
+
+ for (int k = i; k <= j; k++) {
+ rawSectors[slots[k]] = run.slice((int) (offsets[k] - offsets[i]), (int) lengths[k]);
+ }
+
+ i = j + 1;
}
} finally {
BufferedLinearRegionFile.this.regionObjectLock.readLock().unlock();
}
- final ByteArrayOutputStream rawBuf = new ByteArrayOutputStream();
- final DataOutputStream rawOut = new DataOutputStream(rawBuf);
- boolean hasAny = false;
+ // exact size budget up front: 4 bytes size prefix per chunk slot plus
+ // meta + decompressed data for the live ones — one allocation, no growing
+ // ByteArrayOutputStream and no toByteArray() copy at the end
+ int sectionSize = BUCKET_SIZE * Integer.BYTES;
+ for (int i = 0; i < BUCKET_SIZE; i++) {
+ final ByteBuffer raw = rawSectors[i];
+
+ if (raw != null) {
+ sectionSize += SECTOR_META_SIZE + raw.getInt(raw.position());
+ }
+ }
+
+ final byte[] section = new byte[sectionSize];
+ final ByteBuffer sectionBuf = ByteBuffer.wrap(section);
for (int i = 0; i < BUCKET_SIZE; i++) {
- final ByteBuffer rawSector = rawSectors[i];
+ final ByteBuffer raw = rawSectors[i];
// note: null -> no data contained
- if (rawSector == null) {
- rawOut.writeInt(0);
+ if (raw == null) {
+ sectionBuf.putInt(0);
continue;
}
- final ByteBuffer chunkData = BufferedLinearRegionFile.this.compressingOps.decompress(rawSector);
- final byte[] arr = new byte[chunkData.remaining()];
- chunkData.get(arr);
+ final byte[] runArray = raw.array();
+ final int rawBase = raw.arrayOffset() + raw.position();
+ final int dataLen = raw.getInt(raw.position());
- rawOut.writeInt(arr.length);
- rawOut.write(arr);
- hasAny = true;
- }
- rawOut.flush();
+ sectionBuf.putInt(SECTOR_META_SIZE + dataLen);
+ sectionBuf.put(runArray, rawBase, SECTOR_META_SIZE); // meta bytes carried over verbatim
- if (!hasAny) {
- return new BucketRecord(epoch, null);
+ // lz4 decompresses straight into the section buffer, no intermediate arrays
+ final int destPos = sectionBuf.position();
+ LZ4_DECOMPRESSOR.decompress(runArray, rawBase + SECTOR_META_SIZE, section, destPos, dataLen);
+ sectionBuf.position(destPos + dataLen);
}
- final byte[] raw = rawBuf.toByteArray();
- final byte[] compressed = Zstd.compress(raw, BufferedLinearRegionFile.this.compressionLevel);
+ // zstd compresses straight into the final payload: skips Zstd.compress()'s
+ // internal bound-sized temp array plus its exact-size copy at the end
+ final int bound = (int) Zstd.compressBound(sectionSize);
+ final byte[] payload = new byte[V3_RECORD_HEADER_SIZE + bound];
+ final long compressedLen = Zstd.compressByteArray(payload, V3_RECORD_HEADER_SIZE, bound, section, 0, sectionSize, BufferedLinearRegionFile.this.compressionLevel);
- final ByteBuffer payload = ByteBuffer.allocate(V3_RECORD_HEADER_SIZE + compressed.length);
- payload.putInt(raw.length); // original (uncompressed) length
- payload.putInt(compressed.length); // compressed length
- payload.put(compressed);
+ if (Zstd.isError(compressedLen)) {
+ throw new IOException("Failed to zstd compress bucket " + bucketIndex + ": " + Zstd.getErrorName(compressedLen));
+ }
- return new BucketRecord(epoch, payload.array());
+ final ByteBuffer result = ByteBuffer.wrap(payload, 0, V3_RECORD_HEADER_SIZE + (int) compressedLen);
+ result.putInt(sectionSize); // original (uncompressed) length
+ result.putInt((int) compressedLen); // compressed length
+ result.position(0);
+
+ return new BucketRecord(epoch, result);
+ }
+
+ private static void sortByOffset(long[] offsets, long[] lengths, int[] slots, int count) {
+ // n <= 64, insertion sort is plenty and allocation-free
+ for (int i = 1; i < count; i++) {
+ final long offset = offsets[i];
+ final long length = lengths[i];
+ final int slot = slots[i];
+ int j = i - 1;
+
+ while (j >= 0 && offsets[j] > offset) {
+ offsets[j + 1] = offsets[j];
+ lengths[j + 1] = lengths[j];
+ slots[j + 1] = slots[j];
+ j--;
+ }
+
+ offsets[j + 1] = offset;
+ lengths[j + 1] = length;
+ slots[j + 1] = slot;
+ }
}
private void markBucketsSynced(long[] syncedBucketEpochs) {
@@ -1233,7 +1424,7 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
header.putLong(MASTER_FILE_SUPER_BLOCK);
header.put(MASTER_FILE_VERSION_BUCKET);
header.put(BufferedLinearRegionFile.this.compressionLevel);
- header.putInt(BufferedLinearRegionFile.this.xxHash32Seed);
+ header.putInt(XXHASH32_SEED);
header.flip();
writeFullyAt(channel, header, 0);
@@ -1354,10 +1545,11 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
final int chunkSectionDataSize = decompressed.getInt();
if (chunkSectionDataSize <= 0) continue;
- final byte[] chunkSectionData = new byte[chunkSectionDataSize];
- decompressed.get(chunkSectionData);
+ // slice instead of copying the section bytes out
+ final ByteBuffer section = decompressed.slice(decompressed.position(), chunkSectionDataSize);
+ decompressed.position(decompressed.position() + chunkSectionDataSize);
- BufferedLinearRegionFile.this.writeChunkDataRaw(chunkIndex, ByteBuffer.wrap(chunkSectionData), true);
+ BufferedLinearRegionFile.this.writeSection(chunkIndex, section, true);
}
}
@@ -1479,7 +1671,8 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
final ByteBuffer sectorDataNioBuffer = ByteBuffer.wrap(sectorData);
BufferedLinearRegionFile.this.markBucketLoaded(index);
- BufferedLinearRegionFile.this.writeChunkDataRaw(index, sectorDataNioBuffer, false);
+ // blinear v2 stored the exact section layout, feed it through the section path
+ BufferedLinearRegionFile.this.writeSection(index, sectorDataNioBuffer, false);
}
}
}
@@ -1598,4 +1791,4 @@ public class BufferedLinearRegionFile implements io.nanachiyo0721.shiroha.data.R
throw new IOException("Unknown or unsupported super block : " + superBlock);
}
}
-}
\ No newline at end of file
+}