mirror of
https://github.com/yacy/yacy_search_server.git
synced 2026-10-25 05:54:15 +00:00
Introduced immutable indexes to solve the problem of
dying-peers-when-growing problem: HeapReader can now open immutable indexes as SSTableHandleMap instances. Keys and offsets remain file-backed instead of occupying the Java heap. Lazy working copies avoid additional files until a mutation occurs. Negative offsets record deletions without tombstone sidecars or RAM maps. Mutable heaps retain RowHandleMap, while sealed segments save heap memory. With immutable indexes the memory consumption is not stopped, it is only moved from heap memory to system memory. The result should be that there is no need to assign more memory to YaCy when it gets bigger, it simply takes it itself from the system memory. This should also fix https://github.com/yacy/yacy_search_server/issues/731
This commit is contained in:
1 parent
a1be12f5b3
commit
d05db9e6bb
22 files changed
+4456
-586
No files matched your search
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
* ChunkedBytesTest
|
||||
* Copyright 2026 by Michael Peter Christen; mc@yacy.net, Frankfurt a. M., Germany
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*/
|
||||
|
||||
package net.yacy.cora.util;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.file.Files;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
public class ChunkedBytesTest {
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void inputStreamHonorsZeroLengthAndNegativeSkipContracts() throws Exception {
|
||||
try (ChunkedBytes empty = new ChunkedBytes();
|
||||
InputStream input = empty.openStream()) {
|
||||
assertEquals(0, input.read(new byte[1], 0, 0));
|
||||
}
|
||||
|
||||
try (ChunkedBytes bytes = new ChunkedBytes(new byte[] {1, 2, 3});
|
||||
InputStream input = bytes.openStream()) {
|
||||
assertEquals(0L, input.skip(-1L));
|
||||
assertEquals(1, input.read());
|
||||
input.close();
|
||||
assertThrows(IOException.class, input::read);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validatesRangesBeforeReadingOrWriting() {
|
||||
try (ChunkedBytes bytes = new ChunkedBytes(new byte[] {1, 2, 3})) {
|
||||
assertEquals(0, bytes.read(bytes.size(), new byte[0], 0, 0));
|
||||
assertEquals(0, bytes.write(bytes.size(), new byte[0], 0, 0));
|
||||
assertThrows(NullPointerException.class,
|
||||
() -> bytes.read(bytes.size(), null, 0, 1));
|
||||
assertThrows(IndexOutOfBoundsException.class,
|
||||
() -> bytes.read(0, new byte[1], 1, 1));
|
||||
assertThrows(IndexOutOfBoundsException.class,
|
||||
() -> bytes.write(0, new byte[1], 0, -1));
|
||||
assertThrows(IndexOutOfBoundsException.class,
|
||||
() -> bytes.append(new byte[1], Integer.MAX_VALUE, 1));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void equalContentHasEqualHashCodeAndExplicitForeignComparisons() {
|
||||
try (ChunkedBytes left = new ChunkedBytes(new byte[] {1, 2, 3});
|
||||
ChunkedBytes right = new ChunkedBytes(new byte[] {1, 2, 3})) {
|
||||
assertEquals(left, right);
|
||||
assertEquals(left.hashCode(), right.hashCode());
|
||||
assertFalse(left.equals(new byte[] {1, 2, 3}));
|
||||
assertFalse(left.equals("\u0001\u0002\u0003"));
|
||||
assertTrue(left.contentEquals(new byte[] {1, 2, 3}));
|
||||
assertTrue(left.contentEquals("\u0001\u0002\u0003"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void closeIsIdempotentAndRejectsFurtherAccess() {
|
||||
final ChunkedBytes bytes = new ChunkedBytes(new byte[] {1});
|
||||
bytes.close();
|
||||
bytes.close();
|
||||
|
||||
assertThrows(IllegalStateException.class, bytes::size);
|
||||
assertThrows(IllegalStateException.class, () -> bytes.get(0));
|
||||
assertThrows(IllegalStateException.class, () -> bytes.append(new byte[] {2}));
|
||||
assertThrows(IllegalStateException.class, bytes::openStream);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fileBackedCloneOutlivesOriginalOwner() throws Exception {
|
||||
final File file = this.temporaryFolder.newFile("clone.bin");
|
||||
Files.write(file.toPath(), new byte[] {42, 43});
|
||||
|
||||
final ChunkedBytes original = new ChunkedBytes();
|
||||
original.appendFile(file.toPath());
|
||||
final ChunkedBytes clone = (ChunkedBytes) original.clone();
|
||||
original.close();
|
||||
try {
|
||||
assertEquals(42, clone.get(0));
|
||||
assertArrayEquals(new byte[] {42, 43}, clone.toByteArray());
|
||||
} finally {
|
||||
clone.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 15000L)
|
||||
public void comparesDifferentContentBeyondTwoGiB() throws Exception {
|
||||
final long length = (long) Integer.MAX_VALUE + 1L;
|
||||
final File leftFile = this.temporaryFolder.newFile("large-left.bin");
|
||||
final File rightFile = this.temporaryFolder.newFile("large-right.bin");
|
||||
createSparseFile(leftFile, length, 1);
|
||||
createSparseFile(rightFile, length, 2);
|
||||
|
||||
try (ChunkedBytes left = new ChunkedBytes();
|
||||
ChunkedBytes right = new ChunkedBytes()) {
|
||||
left.appendFile(leftFile.toPath());
|
||||
right.appendFile(rightFile.toPath());
|
||||
assertEquals(length, left.size());
|
||||
assertEquals(length, right.size());
|
||||
assertTrue(left.compareTo(right) < 0);
|
||||
assertTrue(right.compareTo(left) > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void writableMappingPersistsCrossChunkChangesOnFlushAndClose() throws Exception {
|
||||
final File file = this.temporaryFolder.newFile("writable.bin");
|
||||
final long patchPosition = ChunkedBytes.CHUNK_SIZE - 4L;
|
||||
final byte[] patch = new byte[] {9, 8, 7, 6, 5, 4, 3, 2};
|
||||
createSparseFile(file, ChunkedBytes.CHUNK_SIZE + 4L, 0);
|
||||
|
||||
try (ChunkedBytes bytes = new ChunkedBytes()) {
|
||||
bytes.appendFile(file.toPath(), true);
|
||||
assertEquals(patch.length,
|
||||
bytes.write(patchPosition, patch, 0, patch.length));
|
||||
bytes.flush();
|
||||
}
|
||||
|
||||
final byte[] stored = new byte[patch.length];
|
||||
try (RandomAccessFile input = new RandomAccessFile(file, "r")) {
|
||||
input.seek(patchPosition);
|
||||
input.readFully(stored);
|
||||
}
|
||||
assertArrayEquals(patch, stored);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void appendFileReportsIoFailuresConsistently() {
|
||||
final File missing = new File(this.temporaryFolder.getRoot(), "missing.bin");
|
||||
try (ChunkedBytes bytes = new ChunkedBytes()) {
|
||||
assertThrows(UncheckedIOException.class,
|
||||
() -> bytes.appendFile(missing.toPath()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test(timeout = 5000L)
|
||||
public void writeFromDoesNotHoldMonitorWhileCallingInputStream() throws Exception {
|
||||
final ChunkedBytes bytes = new ChunkedBytes();
|
||||
final Object inputLock = new Object();
|
||||
final CountDownLatch inputLockHeld = new CountDownLatch(1);
|
||||
final CountDownLatch readEntered = new CountDownLatch(1);
|
||||
final AtomicReference<Throwable> failure = new AtomicReference<>();
|
||||
|
||||
final InputStream input = new InputStream() {
|
||||
@Override
|
||||
public int read() {
|
||||
throw new AssertionError("Bulk read expected");
|
||||
}
|
||||
|
||||
@Override
|
||||
public int read(final byte[] target, final int offset, final int length) {
|
||||
readEntered.countDown();
|
||||
synchronized (inputLock) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
final Thread appender = new Thread(() -> {
|
||||
synchronized (inputLock) {
|
||||
inputLockHeld.countDown();
|
||||
try {
|
||||
if (!readEntered.await(2L, TimeUnit.SECONDS)) {
|
||||
throw new AssertionError("writeFrom did not enter InputStream.read");
|
||||
}
|
||||
bytes.append(new byte[] {1});
|
||||
} catch (final Throwable e) {
|
||||
failure.compareAndSet(null, e);
|
||||
}
|
||||
}
|
||||
}, "chunked-bytes-lock-owner");
|
||||
appender.setDaemon(true);
|
||||
appender.start();
|
||||
assertTrue("Input lock was not acquired",
|
||||
inputLockHeld.await(2L, TimeUnit.SECONDS));
|
||||
|
||||
final Thread writer = new Thread(() -> {
|
||||
try {
|
||||
bytes.writeFrom(input);
|
||||
} catch (final Throwable e) {
|
||||
failure.compareAndSet(null, e);
|
||||
}
|
||||
}, "chunked-bytes-stream-writer");
|
||||
writer.setDaemon(true);
|
||||
writer.start();
|
||||
|
||||
writer.join(2000L);
|
||||
appender.join(2000L);
|
||||
assertFalse("writeFrom and append deadlocked", writer.isAlive());
|
||||
assertFalse("writeFrom and append deadlocked", appender.isAlive());
|
||||
if (failure.get() != null) throw new AssertionError(failure.get());
|
||||
assertArrayEquals(new byte[] {1}, bytes.toByteArray());
|
||||
bytes.close();
|
||||
}
|
||||
|
||||
private static void createSparseFile(final File file, final long length,
|
||||
final int firstByte) throws IOException {
|
||||
try (RandomAccessFile output = new RandomAccessFile(file, "rw")) {
|
||||
output.setLength(length);
|
||||
if (length > 0) {
|
||||
output.seek(0L);
|
||||
output.write(firstByte);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
/*
|
||||
* ArrayStackSSTableLifecycleTest
|
||||
* Copyright 2026 by Michael Peter Christen; mc@yacy.net, Frankfurt a. M., Germany
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*/
|
||||
|
||||
package net.yacy.kelondro.blob;
|
||||
|
||||
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.assertTrue;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.Field;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import net.yacy.cora.document.encoding.ASCII;
|
||||
import net.yacy.cora.order.NaturalOrder;
|
||||
import net.yacy.kelondro.data.citation.CitationReference;
|
||||
import net.yacy.kelondro.data.citation.CitationReferenceFactory;
|
||||
import net.yacy.kelondro.index.RowHandleMap;
|
||||
import net.yacy.kelondro.index.RowSet;
|
||||
import net.yacy.kelondro.index.SSTableHandleMap;
|
||||
import net.yacy.kelondro.rwi.ReferenceContainer;
|
||||
|
||||
public class ArrayStackSSTableLifecycleTest {
|
||||
|
||||
private static final int KEY_LENGTH = 12;
|
||||
private static final int WRITE_BUFFER = 4096;
|
||||
private static final String PREFIX = "segment";
|
||||
|
||||
private static final byte[] DELETE_KEY = ASCII.getBytes("AAAAAAAAAAAA");
|
||||
private static final byte[] FILLER_B_KEY = ASCII.getBytes("BBBBBBBBBBBB");
|
||||
private static final byte[] REDUCE_KEY = ASCII.getBytes("CCCCCCCCCCCC");
|
||||
private static final byte[] SHARED_KEY = ASCII.getBytes("DDDDDDDDDDDD");
|
||||
private static final byte[] FILLER_E_KEY = ASCII.getBytes("EEEEEEEEEEEE");
|
||||
private static final byte[] ACTIVE_KEY = ASCII.getBytes("FFFFFFFFFFFF");
|
||||
private static final byte[] FILLER_G_KEY = ASCII.getBytes("GGGGGGGGGGGG");
|
||||
private static final byte[] FILLER_H_KEY = ASCII.getBytes("HHHHHHHHHHHH");
|
||||
private static final byte[] FILLER_I_KEY = ASCII.getBytes("IIIIIIIIIIII");
|
||||
private static final byte[] RECOVERED_KEY = ASCII.getBytes("JJJJJJJJJJJJ");
|
||||
|
||||
private static final CitationReferenceFactory REFERENCE_FACTORY =
|
||||
new CitationReferenceFactory();
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void sealedSegmentsStaySSTablesAcrossRestartReduceDeleteAndMerge() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("array-stack-sstable");
|
||||
ArrayStack stack = newStack(directory);
|
||||
try {
|
||||
final byte[] twoReferences = container(
|
||||
REDUCE_KEY, "000000000001", "000000000002");
|
||||
final byte[] oneReference = container(REDUCE_KEY, "000000000001");
|
||||
|
||||
/*
|
||||
* The first five inserts create the initial writable Heap. Five entries
|
||||
* are intentional: Heap.close(true) then publishes an idx/gap pair which
|
||||
* sealWriter() must reopen through HeapReader.immutable().
|
||||
*/
|
||||
stack.insert(DELETE_KEY, container(DELETE_KEY, "100000000001"));
|
||||
stack.insert(FILLER_B_KEY, container(FILLER_B_KEY, "200000000001"));
|
||||
stack.insert(REDUCE_KEY, twoReferences);
|
||||
stack.insert(SHARED_KEY, container(SHARED_KEY, "300000000001"));
|
||||
stack.insert(FILLER_E_KEY, container(FILLER_E_KEY, "400000000001"));
|
||||
|
||||
/*
|
||||
* Mounting a newer full segment is the deterministic rollover path:
|
||||
* ArrayStack seals the old writer and keeps only this new segment mutable.
|
||||
*/
|
||||
final File secondSegment = stack.newBLOB(
|
||||
new Date(System.currentTimeMillis() + 60_000L));
|
||||
writeSecondSegment(secondSegment);
|
||||
stack.mountBLOB(secondSegment, true);
|
||||
|
||||
assertSegmentTypes(stack, HeapModifier.class, Heap.class);
|
||||
assertTrue(((HeapModifier) segmentBlobs(stack).get(0)).index
|
||||
instanceof SSTableHandleMap);
|
||||
assertTrue(((Heap) segmentBlobs(stack).get(1)).index
|
||||
instanceof RowHandleMap);
|
||||
assertEquals(0L, countWorkingFiles(directory.toPath()));
|
||||
assertArrayEquals(container(SHARED_KEY, "500000000001"),
|
||||
stack.get(SHARED_KEY));
|
||||
assertEquals(2, countOccurrences(stack, SHARED_KEY));
|
||||
|
||||
/* reduce() must use HeapModifier's in-place path on the sealed segment. */
|
||||
assertEquals(twoReferences.length - oneReference.length,
|
||||
stack.reduce(REDUCE_KEY, ignored -> oneReference));
|
||||
assertEquals(1L, countWorkingFiles(directory.toPath()));
|
||||
assertContainerSize(stack.get(REDUCE_KEY), 1);
|
||||
|
||||
stack.close(true);
|
||||
stack = null;
|
||||
assertEquals(0L, countWorkingFiles(directory.toPath()));
|
||||
|
||||
/* A process restart must reconstruct the same mutable/sealed boundary. */
|
||||
stack = newStack(directory);
|
||||
assertSegmentTypes(stack, HeapModifier.class, Heap.class);
|
||||
assertTrue(((HeapModifier) segmentBlobs(stack).get(0)).index
|
||||
instanceof SSTableHandleMap);
|
||||
assertTrue(((Heap) segmentBlobs(stack).get(1)).index
|
||||
instanceof RowHandleMap);
|
||||
assertContainerSize(stack.get(REDUCE_KEY), 1);
|
||||
assertEquals(0L, countWorkingFiles(directory.toPath()));
|
||||
|
||||
/*
|
||||
* Exercise the actual ArrayStack merge protocol: unmount both sources,
|
||||
* merge their ReferenceContainers, and mount the result as a sealed heap.
|
||||
*/
|
||||
final File[] mergeSources = stack.unmountSmallest(Long.MAX_VALUE);
|
||||
assertNotNull(mergeSources);
|
||||
assertEquals(0, stack.entries());
|
||||
assertEquals(0L, countWorkingFiles(directory.toPath()));
|
||||
|
||||
final File mergedFile = stack.newBLOB(
|
||||
new Date(System.currentTimeMillis() + 120_000L));
|
||||
assertEquals(mergedFile, stack.mergeMount(
|
||||
mergeSources[0], mergeSources[1], REFERENCE_FACTORY,
|
||||
mergedFile, WRITE_BUFFER));
|
||||
assertSegmentTypes(stack, HeapModifier.class);
|
||||
assertTrue(((HeapModifier) segmentBlobs(stack).get(0)).index
|
||||
instanceof SSTableHandleMap);
|
||||
assertEquals(0L, countWorkingFiles(directory.toPath()));
|
||||
assertContainerSize(stack.get(SHARED_KEY), 2);
|
||||
assertContainerSize(stack.get(REDUCE_KEY), 1);
|
||||
assertEquals(9, stack.size());
|
||||
|
||||
/* With one sealed segment delete() reaches HeapModifier without a RAM index. */
|
||||
stack.delete(DELETE_KEY);
|
||||
assertEquals(1L, countWorkingFiles(directory.toPath()));
|
||||
assertFalse(stack.containsKey(DELETE_KEY));
|
||||
assertEquals(8, stack.size());
|
||||
|
||||
stack.close(true);
|
||||
stack = null;
|
||||
assertEquals(0L, countWorkingFiles(directory.toPath()));
|
||||
|
||||
/* The newest (now sole) merged segment is writable after the next restart. */
|
||||
stack = newStack(directory);
|
||||
assertSegmentTypes(stack, Heap.class);
|
||||
assertTrue(((Heap) segmentBlobs(stack).get(0)).index
|
||||
instanceof RowHandleMap);
|
||||
assertFalse(stack.containsKey(DELETE_KEY));
|
||||
assertContainerSize(stack.get(SHARED_KEY), 2);
|
||||
assertContainerSize(stack.get(REDUCE_KEY), 1);
|
||||
assertEquals(8, stack.size());
|
||||
assertEquals(0L, countWorkingFiles(directory.toPath()));
|
||||
} finally {
|
||||
if (stack != null) stack.close(false);
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void failedFingerprintPublishRestoresWritableHeap() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("array-stack-publish-failure");
|
||||
ArrayStack stack = new ArrayStack(
|
||||
directory, PREFIX, NaturalOrder.naturalOrder,
|
||||
KEY_LENGTH, 0, false, false);
|
||||
try {
|
||||
/* More than three entries make close(true) publish a fingerprint pair. */
|
||||
stack.insert(DELETE_KEY, ASCII.getBytes("value-a"));
|
||||
stack.insert(FILLER_B_KEY, ASCII.getBytes("value-b"));
|
||||
stack.insert(REDUCE_KEY, ASCII.getBytes("value-c"));
|
||||
stack.insert(SHARED_KEY, ASCII.getBytes("value-d"));
|
||||
stack.insert(FILLER_E_KEY, ASCII.getBytes("value-e"));
|
||||
|
||||
final File activeBlob = onlyBlobFile(directory);
|
||||
final String fingerprint = HeapReader.fingerprintFileHash(activeBlob);
|
||||
final File gapTarget = HeapWriter.fingerprintGapFile(activeBlob, fingerprint);
|
||||
|
||||
/*
|
||||
* A non-empty directory cannot be replaced by Gap.dump(). This forces
|
||||
* publishing to fail after Heap.close() has already released its file
|
||||
* and in-memory index, reproducing the stale-writer failure mode.
|
||||
*/
|
||||
assertTrue(gapTarget.mkdir());
|
||||
Files.write(gapTarget.toPath().resolve("blocker"), new byte[] {1});
|
||||
|
||||
final File futureSegment = stack.newBLOB(
|
||||
new Date(System.currentTimeMillis() + 60_000L));
|
||||
UncheckedIOException publishFailure = null;
|
||||
try {
|
||||
stack.mountBLOB(futureSegment, true);
|
||||
} catch (final UncheckedIOException expected) {
|
||||
publishFailure = expected;
|
||||
}
|
||||
assertNotNull("The forced fingerprint publish must fail", publishFailure);
|
||||
|
||||
/* The original segment must already be reopened and writable. */
|
||||
final byte[] recoveredValue = ASCII.getBytes("after-failure");
|
||||
stack.insert(RECOVERED_KEY, recoveredValue);
|
||||
assertArrayEquals(recoveredValue, stack.get(RECOVERED_KEY));
|
||||
|
||||
stack.close(true);
|
||||
stack = null;
|
||||
|
||||
stack = new ArrayStack(
|
||||
directory, PREFIX, NaturalOrder.naturalOrder,
|
||||
KEY_LENGTH, 0, false, false);
|
||||
assertArrayEquals(recoveredValue, stack.get(RECOVERED_KEY));
|
||||
} finally {
|
||||
if (stack != null) stack.close(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static ArrayStack newStack(final File directory) throws Exception {
|
||||
return new ArrayStack(directory, PREFIX, NaturalOrder.naturalOrder,
|
||||
KEY_LENGTH, WRITE_BUFFER, false, false);
|
||||
}
|
||||
|
||||
private static File onlyBlobFile(final File directory) {
|
||||
final File[] blobs = directory.listFiles(
|
||||
file -> file.getName().endsWith(".blob"));
|
||||
assertNotNull(blobs);
|
||||
assertEquals(1, blobs.length);
|
||||
return blobs[0];
|
||||
}
|
||||
|
||||
private static void writeSecondSegment(final File target) throws Exception {
|
||||
final File temporary = new File(target.getParentFile(), target.getName() + ".prt");
|
||||
final HeapWriter writer = new HeapWriter(
|
||||
temporary, target, KEY_LENGTH, NaturalOrder.naturalOrder, WRITE_BUFFER);
|
||||
writer.add(SHARED_KEY, container(SHARED_KEY, "500000000001"));
|
||||
writer.add(ACTIVE_KEY, container(ACTIVE_KEY, "600000000001"));
|
||||
writer.add(FILLER_G_KEY, container(FILLER_G_KEY, "700000000001"));
|
||||
writer.add(FILLER_H_KEY, container(FILLER_H_KEY, "800000000001"));
|
||||
writer.add(FILLER_I_KEY, container(FILLER_I_KEY, "900000000001"));
|
||||
writer.close(true);
|
||||
}
|
||||
|
||||
private static byte[] container(final byte[] termHash, final String... urlHashes)
|
||||
throws Exception {
|
||||
final ReferenceContainer<CitationReference> container =
|
||||
new ReferenceContainer<>(REFERENCE_FACTORY, termHash);
|
||||
long modified = 1_700_000_000_000L;
|
||||
for (final String urlHash : urlHashes) {
|
||||
container.add(new CitationReference(ASCII.getBytes(urlHash), modified++));
|
||||
}
|
||||
return container.exportCollection();
|
||||
}
|
||||
|
||||
private static void assertContainerSize(final byte[] payload, final int expected)
|
||||
throws Exception {
|
||||
assertNotNull(payload);
|
||||
assertEquals(expected,
|
||||
RowSet.importRowSet(payload, REFERENCE_FACTORY.getRow()).size());
|
||||
}
|
||||
|
||||
private static int countOccurrences(final ArrayStack stack, final byte[] key)
|
||||
throws Exception {
|
||||
int count = 0;
|
||||
for (final byte[] payload : stack.getAll(key)) {
|
||||
assertContainerSize(payload, 1);
|
||||
count++;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
private static void assertSegmentTypes(
|
||||
final ArrayStack stack, final Class<?>... expectedTypes) throws Exception {
|
||||
final List<ImmutableBLOB> segments = segmentBlobs(stack);
|
||||
assertEquals(expectedTypes.length, segments.size());
|
||||
for (int index = 0; index < expectedTypes.length; index++) {
|
||||
assertEquals(expectedTypes[index], segments.get(index).getClass());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ArrayStack deliberately keeps blobItem private. Reflection is limited to
|
||||
* this regression assertion; production code still exposes no mutable segment list.
|
||||
*/
|
||||
private static List<ImmutableBLOB> segmentBlobs(final ArrayStack stack) throws Exception {
|
||||
final Field blobsField = ArrayStack.class.getDeclaredField("blobs");
|
||||
blobsField.setAccessible(true);
|
||||
final List<?> items = (List<?>) blobsField.get(stack);
|
||||
final List<ImmutableBLOB> result = new ArrayList<>(items.size());
|
||||
for (final Object item : items) {
|
||||
final Field blobField = item.getClass().getDeclaredField("blob");
|
||||
blobField.setAccessible(true);
|
||||
result.add((ImmutableBLOB) blobField.get(item));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static long countWorkingFiles(final Path directory) throws Exception {
|
||||
try (java.util.stream.Stream<Path> files = Files.list(directory)) {
|
||||
return files.filter(path -> path.getFileName().toString()
|
||||
.endsWith(".sstwork")).count();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* HeapCloseFailureTest
|
||||
* Copyright 2026 by Michael Peter Christen; mc@yacy.net, Frankfurt a. M., Germany
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*/
|
||||
|
||||
package net.yacy.kelondro.blob;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import net.yacy.cora.document.encoding.ASCII;
|
||||
import net.yacy.cora.order.NaturalOrder;
|
||||
import net.yacy.kelondro.io.Writer;
|
||||
|
||||
public class HeapCloseFailureTest {
|
||||
|
||||
private static final int KEY_LENGTH = 12;
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void closePropagatesFlushFailureWithoutPublishingStaleIndex() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("heap-close-failure");
|
||||
final File heapFile = new File(directory, "data.blob");
|
||||
final Heap heap = new Heap(
|
||||
heapFile, KEY_LENGTH, NaturalOrder.naturalOrder, 4096);
|
||||
|
||||
for (int index = 0; index < 5; index++) {
|
||||
heap.insert(key(index), ASCII.getBytes("buffered-value-" + index));
|
||||
}
|
||||
|
||||
/*
|
||||
* Keep length(), seek() and close() operational, but fail the bulk write.
|
||||
* At that point flushBufferInternal() has already assigned offsets to all
|
||||
* buffered keys, so close(true) must not publish this stale RAM index.
|
||||
*/
|
||||
final Writer delegate = heap.file;
|
||||
heap.file = (Writer) Proxy.newProxyInstance(
|
||||
Writer.class.getClassLoader(),
|
||||
new Class<?>[] {Writer.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if (method.getName().equals("write")) {
|
||||
throw new IOException("forced buffered write failure");
|
||||
}
|
||||
try {
|
||||
return method.invoke(delegate, arguments);
|
||||
} catch (final InvocationTargetException failure) {
|
||||
throw failure.getCause();
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
heap.close(true);
|
||||
throw new AssertionError("flush failure was swallowed");
|
||||
} catch (final UncheckedIOException expected) {
|
||||
assertTrue(expected.getMessage().contains(heapFile.getName()));
|
||||
assertEquals("forced buffered write failure",
|
||||
expected.getCause().getMessage());
|
||||
} finally {
|
||||
heap.close(false);
|
||||
}
|
||||
|
||||
assertFalse(hasFingerprintFile(directory));
|
||||
|
||||
/* No stale offsets may survive: reopening must scan the authoritative blob. */
|
||||
final Heap reopened = new Heap(
|
||||
heapFile, KEY_LENGTH, NaturalOrder.naturalOrder, 4096);
|
||||
try {
|
||||
assertEquals(0, reopened.size());
|
||||
} finally {
|
||||
reopened.close(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] key(final int index) {
|
||||
return ASCII.getBytes(String.format("%012d", Integer.valueOf(index)));
|
||||
}
|
||||
|
||||
private static boolean hasFingerprintFile(final File directory) {
|
||||
final File[] fingerprints = directory.listFiles(file -> {
|
||||
final String name = file.getName();
|
||||
return name.endsWith(".idx") || name.endsWith(".gap")
|
||||
|| name.endsWith(".idx.gz") || name.endsWith(".gap.gz");
|
||||
});
|
||||
return fingerprints != null && fingerprints.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
/*
|
||||
* HeapModifierWorkingCopyTest
|
||||
* Copyright 2026 by Michael Peter Christen; mc@yacy.net, Frankfurt a. M., Germany
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*/
|
||||
|
||||
package net.yacy.kelondro.blob;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.UncheckedIOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import net.yacy.cora.order.NaturalOrder;
|
||||
import net.yacy.kelondro.index.SSTableHandleMap;
|
||||
|
||||
public class HeapModifierWorkingCopyTest {
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void modifierUsesPrivateGenerationAndPublishesCompactedFingerprint() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("heap-working-copy");
|
||||
final File temporaryHeap = new File(directory, "data.heap.prt");
|
||||
final File heap = new File(directory, "data.heap");
|
||||
|
||||
/* HeapWriter publishes the initial immutable fingerprint generation. */
|
||||
final HeapWriter writer = new HeapWriter(
|
||||
temporaryHeap, heap, 4, NaturalOrder.naturalOrder, 4096);
|
||||
for (int index = 1; index <= 5; index++) {
|
||||
writer.add(key(index), value(index));
|
||||
}
|
||||
writer.close(true);
|
||||
|
||||
final File initialFingerprint = HeapWriter.fingerprintIndexFile(
|
||||
heap, HeapReader.fingerprintFileHash(heap));
|
||||
assertTrue(initialFingerprint.isFile());
|
||||
final byte[] publishedBytes = Files.readAllBytes(initialFingerprint.toPath());
|
||||
|
||||
final HeapModifier first = new HeapModifier(
|
||||
heap, 4, NaturalOrder.naturalOrder);
|
||||
HeapModifier second = null;
|
||||
try {
|
||||
assertTrue(first.index instanceof SSTableHandleMap);
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
assertArrayEquals(value(2), first.get(key(2)));
|
||||
assertArrayEquals(publishedBytes, Files.readAllBytes(initialFingerprint.toPath()));
|
||||
|
||||
/* Multiple readers remain on the published generation without copying. */
|
||||
second = new HeapModifier(heap, 4, NaturalOrder.naturalOrder);
|
||||
assertTrue(second.index instanceof SSTableHandleMap);
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
assertArrayEquals(value(2), second.get(key(2)));
|
||||
second.close(false);
|
||||
second = null;
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
|
||||
/* The first heap mutation creates exactly one private generation. */
|
||||
first.delete(key(2));
|
||||
assertEquals(1, countWorkingFiles(directory.toPath()));
|
||||
assertFalse(first.containsKey(key(2)));
|
||||
assertFalse(initialFingerprint.exists());
|
||||
first.close(true);
|
||||
} finally {
|
||||
if (second != null) second.close(false);
|
||||
first.close(false);
|
||||
}
|
||||
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
|
||||
/* close(true) compacted tombstones and published the next fingerprint. */
|
||||
final File compactedFingerprint = HeapWriter.fingerprintIndexFile(
|
||||
heap, HeapReader.fingerprintFileHash(heap));
|
||||
assertTrue(compactedFingerprint.isFile());
|
||||
|
||||
final HeapModifier reopened = new HeapModifier(
|
||||
heap, 4, NaturalOrder.naturalOrder);
|
||||
try {
|
||||
assertTrue(reopened.index instanceof SSTableHandleMap);
|
||||
assertEquals(4, reopened.size());
|
||||
assertFalse(reopened.containsKey(key(2)));
|
||||
assertArrayEquals(value(5), reopened.get(key(5)));
|
||||
} finally {
|
||||
reopened.close(true);
|
||||
}
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void publishFailureIsPropagatedAndRollsBackIncompletePair() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("heap-publish-failure");
|
||||
final File temporaryHeap = new File(directory, "data.heap.prt");
|
||||
final File heap = new File(directory, "data.heap");
|
||||
|
||||
final HeapWriter writer = new HeapWriter(
|
||||
temporaryHeap, heap, 4, NaturalOrder.naturalOrder, 4096);
|
||||
for (int index = 1; index <= 5; index++) {
|
||||
writer.add(key(index), value(index));
|
||||
}
|
||||
writer.close(true);
|
||||
|
||||
final HeapModifier modifier = new HeapModifier(
|
||||
heap, 4, NaturalOrder.naturalOrder);
|
||||
modifier.delete(key(2));
|
||||
final String fingerprint = HeapReader.fingerprintFileHash(heap);
|
||||
final File targetIndex = HeapWriter.fingerprintIndexFile(heap, fingerprint);
|
||||
final File targetGap = HeapWriter.fingerprintGapFile(heap, fingerprint);
|
||||
|
||||
/* A non-empty directory cannot be replaced by the final gap dump. The
|
||||
* index has already been published at that point and must be rolled back. */
|
||||
assertTrue(targetGap.mkdir());
|
||||
Files.write(targetGap.toPath().resolve("blocker"), new byte[] {1});
|
||||
|
||||
try {
|
||||
modifier.close(true);
|
||||
throw new AssertionError("publish failure was swallowed");
|
||||
} catch (final UncheckedIOException expected) {
|
||||
assertTrue(expected.getMessage().contains(heap.getName()));
|
||||
assertTrue(expected.getCause().getMessage().contains("complete fingerprint pair"));
|
||||
} finally {
|
||||
modifier.close(false);
|
||||
}
|
||||
|
||||
assertFalse(targetIndex.exists());
|
||||
assertTrue(targetGap.isDirectory());
|
||||
assertFalse(new File(targetIndex.getPath() + ".prt").exists());
|
||||
assertFalse(new File(targetGap.getPath() + ".prt").exists());
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void incompleteFingerprintPairIsRebuiltFromHeapAndRepublished() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("heap-pair-recovery");
|
||||
final File temporaryHeap = new File(directory, "data.heap.prt");
|
||||
final File heap = new File(directory, "data.heap");
|
||||
|
||||
final HeapWriter writer = new HeapWriter(
|
||||
temporaryHeap, heap, 4, NaturalOrder.naturalOrder, 4096);
|
||||
for (int index = 1; index <= 6; index++) {
|
||||
writer.add(key(index), value(index));
|
||||
}
|
||||
writer.close(true);
|
||||
|
||||
final String fingerprint = HeapReader.fingerprintFileHash(heap);
|
||||
final File indexDump = HeapWriter.fingerprintIndexFile(heap, fingerprint);
|
||||
final File gapDump = HeapWriter.fingerprintGapFile(heap, fingerprint);
|
||||
assertTrue(indexDump.isFile());
|
||||
assertTrue(gapDump.isFile());
|
||||
|
||||
/* Simulate a crash between publishing the index and installing the gap
|
||||
* commit marker. The remaining index must never be accepted alone. */
|
||||
Files.delete(gapDump.toPath());
|
||||
assertFalse(gapDump.exists());
|
||||
|
||||
final HeapModifier recovered = new HeapModifier(
|
||||
heap, 4, NaturalOrder.naturalOrder);
|
||||
try {
|
||||
assertEquals(6, recovered.size());
|
||||
assertArrayEquals(value(1), recovered.get(key(1)));
|
||||
assertArrayEquals(value(6), recovered.get(key(6)));
|
||||
assertFalse(indexDump.exists());
|
||||
} finally {
|
||||
recovered.close(true);
|
||||
}
|
||||
|
||||
assertTrue(indexDump.isFile());
|
||||
assertTrue(gapDump.isFile());
|
||||
final HeapModifier restarted = new HeapModifier(
|
||||
heap, 4, NaturalOrder.naturalOrder);
|
||||
try {
|
||||
assertEquals(6, restarted.size());
|
||||
assertArrayEquals(value(4), restarted.get(key(4)));
|
||||
} finally {
|
||||
restarted.close(false);
|
||||
}
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
}
|
||||
|
||||
private static long countWorkingFiles(final Path directory) throws Exception {
|
||||
try (java.util.stream.Stream<Path> files = Files.list(directory)) {
|
||||
return files.filter(path -> path.getFileName().toString()
|
||||
.endsWith(".sstwork")).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] key(final int value) {
|
||||
return String.format("%04d", value).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
|
||||
private static byte[] value(final int value) {
|
||||
return ("value-" + value).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* HeapReaderInitializationFailureTest
|
||||
* Copyright 2026 by Michael Peter Christen; mc@yacy.net, Frankfurt a. M., Germany
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*/
|
||||
|
||||
package net.yacy.kelondro.blob;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.lang.reflect.InvocationTargetException;
|
||||
import java.lang.reflect.Proxy;
|
||||
import java.nio.file.Files;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import net.yacy.cora.document.encoding.ASCII;
|
||||
import net.yacy.cora.order.ByteOrder;
|
||||
import net.yacy.cora.order.NaturalOrder;
|
||||
import net.yacy.cora.storage.HandleMap;
|
||||
import net.yacy.kelondro.io.CachedFileWriter;
|
||||
import net.yacy.kelondro.io.Writer;
|
||||
|
||||
public class HeapReaderInitializationFailureTest {
|
||||
|
||||
private static final int KEY_LENGTH = 4;
|
||||
private static final byte[] KEY = ASCII.getBytes("0001");
|
||||
private static final byte[] VALUE = ASCII.getBytes("payload");
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void rebuildFailureClosesBuilderAndHeapFile() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("failed-rebuild");
|
||||
final File heapFile = new File(directory, "data.blob");
|
||||
writeHeapRecord(heapFile);
|
||||
|
||||
final AtomicBoolean fileClosed = new AtomicBoolean();
|
||||
final AtomicBoolean builderClosed = new AtomicBoolean();
|
||||
final HeapReader.HeapFileFactory files = file -> {
|
||||
final Writer delegate = new CachedFileWriter(file);
|
||||
return (Writer) Proxy.newProxyInstance(
|
||||
Writer.class.getClassLoader(),
|
||||
new Class<?>[] {Writer.class},
|
||||
(proxy, method, arguments) -> {
|
||||
if (method.getName().equals("close")) fileClosed.set(true);
|
||||
try {
|
||||
return method.invoke(delegate, arguments);
|
||||
} catch (final InvocationTargetException failure) {
|
||||
throw failure.getCause();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
final HeapReader.HeapIndexFactory<HandleMap> indexes =
|
||||
new HeapReader.HeapIndexFactory<HandleMap>() {
|
||||
@Override
|
||||
public HandleMap load(final int keylength, final ByteOrder ordering,
|
||||
final File dump) {
|
||||
throw new AssertionError("No fingerprint should be loaded");
|
||||
}
|
||||
|
||||
@Override
|
||||
public HeapReader.HeapIndexBuilder<HandleMap> newBuilder(
|
||||
final File file, final int keylength,
|
||||
final ByteOrder ordering, final int expectedspace) {
|
||||
return new HeapReader.HeapIndexBuilder<HandleMap>() {
|
||||
@Override
|
||||
public void consume(final byte[] key, final long offset)
|
||||
throws IOException {
|
||||
throw new IOException("forced index rebuild failure");
|
||||
}
|
||||
|
||||
@Override
|
||||
public HandleMap finish() throws IOException {
|
||||
throw new IOException("finish must not be reached");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
builderClosed.set(true);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public void prepareForHeapMutation(final HandleMap index) {
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
new TestHeapReader(heapFile, indexes, files);
|
||||
throw new AssertionError("index rebuild failure was swallowed");
|
||||
} catch (final IOException expected) {
|
||||
assertEquals("forced index rebuild failure", expected.getMessage());
|
||||
}
|
||||
assertTrue("The failed builder must be closed", builderClosed.get());
|
||||
assertTrue("The heap file must be closed", fileClosed.get());
|
||||
|
||||
/* Cleanup must leave the authoritative heap usable for a normal retry. */
|
||||
final Heap recovered = new Heap(
|
||||
heapFile, KEY_LENGTH, NaturalOrder.naturalOrder, 0);
|
||||
try {
|
||||
assertArrayEquals(VALUE, recovered.get(KEY));
|
||||
} finally {
|
||||
recovered.close(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeHeapRecord(final File heapFile) throws IOException {
|
||||
try (DataOutputStream output = new DataOutputStream(
|
||||
new BufferedOutputStream(Files.newOutputStream(heapFile.toPath())))) {
|
||||
output.writeInt(KEY_LENGTH + VALUE.length);
|
||||
output.write(KEY);
|
||||
output.write(VALUE);
|
||||
}
|
||||
}
|
||||
|
||||
private static final class TestHeapReader extends HeapReader<HandleMap> {
|
||||
private TestHeapReader(final File heapFile,
|
||||
final HeapIndexFactory<HandleMap> indexFactory,
|
||||
final HeapFileFactory heapFileFactory) throws IOException {
|
||||
super(heapFile, KEY_LENGTH, NaturalOrder.naturalOrder,
|
||||
indexFactory, heapFileFactory);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
/*
|
||||
* SSTableHandleMapRecoveryAndLoadTest
|
||||
* Copyright 2026 by Michael Peter Christen; mc@yacy.net, Frankfurt a. M., Germany
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*/
|
||||
|
||||
package net.yacy.kelondro.index;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Random;
|
||||
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 org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import net.yacy.cora.order.NaturalOrder;
|
||||
|
||||
public class SSTableHandleMapRecoveryAndLoadTest {
|
||||
|
||||
private static final int KEY_LENGTH = Long.BYTES;
|
||||
private static final int LARGE_RECORD_COUNT = 200000;
|
||||
private static final int LOOKUPS_PER_READER = 20000;
|
||||
private static final int REMOVALS = 5000;
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
/**
|
||||
* Helper entry point used by {@link #globalRecoveryRemovesARealCrashArtifact()}.
|
||||
* The process deliberately bypasses every shutdown hook and finally block.
|
||||
*/
|
||||
public static void main(final String[] arguments) throws Exception {
|
||||
if (arguments.length != 2) return;
|
||||
if ("--crash-helper".equals(arguments[0])) {
|
||||
final File fingerprint = new File(arguments[1]);
|
||||
final SSTableHandleMap table = SSTableHandleMap.openWorkingCopy(
|
||||
KEY_LENGTH, NaturalOrder.naturalOrder, Long.BYTES, fingerprint);
|
||||
table.remove(key(1));
|
||||
signalReadyAndWait();
|
||||
/* Keep the owning table strongly reachable while the parent verifies its
|
||||
* cross-process lock. The helper is normally killed before this executes. */
|
||||
table.size();
|
||||
Runtime.getRuntime().halt(0);
|
||||
}
|
||||
if ("--builder-crash-helper".equals(arguments[0])) {
|
||||
final SSTableHandleMap.Builder builder = new SSTableHandleMap.Builder(
|
||||
KEY_LENGTH, NaturalOrder.naturalOrder, Long.BYTES,
|
||||
new File(arguments[1]), "crash-helper", 1);
|
||||
builder.consume(key(1), offset(1));
|
||||
signalReadyAndWait();
|
||||
/* Preserve the Builder and its session lock until the forced process stop. */
|
||||
builder.consume(key(2), offset(2));
|
||||
Runtime.getRuntime().halt(0);
|
||||
}
|
||||
}
|
||||
|
||||
private static void signalReadyAndWait() throws IOException {
|
||||
System.out.println("READY");
|
||||
System.out.flush();
|
||||
System.in.read();
|
||||
}
|
||||
|
||||
@Test(timeout = 30000)
|
||||
public void globalRecoveryRemovesARealCrashArtifact() throws Exception {
|
||||
final File repository = this.temporaryFolder.newFolder("crash-repository");
|
||||
final File fingerprint = new File(repository, "crashed.idx");
|
||||
writeTable(fingerprint, 32);
|
||||
final byte[] published = Files.readAllBytes(fingerprint.toPath());
|
||||
|
||||
final File java = new File(new File(System.getProperty("java.home"), "bin"),
|
||||
isWindows() ? "java.exe" : "java");
|
||||
final Process process = new ProcessBuilder(
|
||||
java.getAbsolutePath(), "-cp", System.getProperty("java.class.path"),
|
||||
SSTableHandleMapRecoveryAndLoadTest.class.getName(),
|
||||
"--crash-helper", fingerprint.getAbsolutePath())
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
final BufferedReader processOutput = new BufferedReader(new InputStreamReader(
|
||||
process.getInputStream(), StandardCharsets.UTF_8));
|
||||
try {
|
||||
/* Newer JVMs may print runtime diagnostics (for example about explicit
|
||||
* mapped-buffer cleanup) before the helper reaches its synchronization
|
||||
* marker. These messages must not be mistaken for helper failure. */
|
||||
String outputLine;
|
||||
do {
|
||||
outputLine = processOutput.readLine();
|
||||
} while (outputLine != null && !"READY".equals(outputLine));
|
||||
assertEquals("READY", outputLine);
|
||||
assertEquals(1L, countWorkingFiles(repository.toPath()));
|
||||
|
||||
final SSTableHandleMap.RecoveryReport whileRunning =
|
||||
SSTableHandleMap.recoverWorkingCopies(repository);
|
||||
assertEquals(1L, whileRunning.candidates());
|
||||
assertEquals(0L, whileRunning.deleted());
|
||||
assertEquals(1L, whileRunning.inUse());
|
||||
assertEquals(0L, whileRunning.failures());
|
||||
} finally {
|
||||
process.destroyForcibly();
|
||||
if (!process.waitFor(20, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
throw new AssertionError("crash helper did not terminate");
|
||||
}
|
||||
}
|
||||
assertEquals(1L, countWorkingFiles(repository.toPath()));
|
||||
|
||||
final SSTableHandleMap.RecoveryReport recovery =
|
||||
SSTableHandleMap.recoverWorkingCopies(repository);
|
||||
assertEquals(1L, recovery.candidates());
|
||||
assertEquals(1L, recovery.deleted());
|
||||
assertEquals(0L, recovery.inUse());
|
||||
assertEquals(0L, recovery.failures());
|
||||
assertEquals(0L, countWorkingFiles(repository.toPath()));
|
||||
assertArrayEquals(published, Files.readAllBytes(fingerprint.toPath()));
|
||||
}
|
||||
|
||||
@Test(timeout = 30000)
|
||||
public void globalRecoveryRemovesRealBuilderCrashArtifacts() throws Exception {
|
||||
final File repository = this.temporaryFolder.newFolder("builder-crash-repository");
|
||||
final File java = new File(new File(System.getProperty("java.home"), "bin"),
|
||||
isWindows() ? "java.exe" : "java");
|
||||
final Process process = new ProcessBuilder(
|
||||
java.getAbsolutePath(), "-cp", System.getProperty("java.class.path"),
|
||||
SSTableHandleMapRecoveryAndLoadTest.class.getName(),
|
||||
"--builder-crash-helper", repository.getAbsolutePath())
|
||||
.redirectErrorStream(true)
|
||||
.start();
|
||||
final BufferedReader processOutput = new BufferedReader(new InputStreamReader(
|
||||
process.getInputStream(), StandardCharsets.UTF_8));
|
||||
try {
|
||||
String outputLine;
|
||||
do {
|
||||
outputLine = processOutput.readLine();
|
||||
} while (outputLine != null && !"READY".equals(outputLine));
|
||||
assertEquals("READY", outputLine);
|
||||
assertEquals(2L, countBuilderArtifacts(repository.toPath()));
|
||||
|
||||
final SSTableHandleMap.RecoveryReport whileRunning =
|
||||
SSTableHandleMap.recoverWorkingCopies(repository);
|
||||
assertEquals(2L, whileRunning.candidates());
|
||||
assertEquals(0L, whileRunning.deleted());
|
||||
assertEquals(2L, whileRunning.inUse());
|
||||
assertEquals(0L, whileRunning.failures());
|
||||
} finally {
|
||||
process.destroyForcibly();
|
||||
if (!process.waitFor(20, TimeUnit.SECONDS)) {
|
||||
process.destroyForcibly();
|
||||
throw new AssertionError("builder crash helper did not terminate");
|
||||
}
|
||||
}
|
||||
|
||||
final SSTableHandleMap.RecoveryReport recovery =
|
||||
SSTableHandleMap.recoverWorkingCopies(repository);
|
||||
assertEquals(2L, recovery.candidates());
|
||||
assertEquals(2L, recovery.deleted());
|
||||
assertEquals(0L, recovery.inUse());
|
||||
assertEquals(0L, recovery.failures());
|
||||
assertEquals(0L, countBuilderArtifacts(repository.toPath()));
|
||||
}
|
||||
|
||||
@Test(timeout = 60000)
|
||||
public void largeTablePromotesSafelyUnderConcurrentReadLoad() throws Exception {
|
||||
final File repository = this.temporaryFolder.newFolder("load-repository");
|
||||
final File fingerprint = new File(repository, "large.idx");
|
||||
writeTable(fingerprint, LARGE_RECORD_COUNT);
|
||||
final byte[] published = Files.readAllBytes(fingerprint.toPath());
|
||||
|
||||
final SSTableHandleMap table = SSTableHandleMap.openWorkingCopy(
|
||||
KEY_LENGTH, NaturalOrder.naturalOrder, Long.BYTES, fingerprint);
|
||||
final ExecutorService readers = Executors.newFixedThreadPool(4);
|
||||
try {
|
||||
assertEquals(LARGE_RECORD_COUNT, table.size());
|
||||
assertEquals(0L, table.mem());
|
||||
assertEquals(0L, countWorkingFiles(repository.toPath()));
|
||||
|
||||
final CountDownLatch start = new CountDownLatch(1);
|
||||
final List<Future<?>> results = new ArrayList<>();
|
||||
for (int reader = 0; reader < 4; reader++) {
|
||||
final int seed = reader;
|
||||
results.add(readers.submit(() -> {
|
||||
final Random random = new Random(seed);
|
||||
start.await();
|
||||
for (int lookup = 0; lookup < LOOKUPS_PER_READER; lookup++) {
|
||||
/* Readers stay in the untouched upper half while the lower
|
||||
* half triggers and exercises the mapping transition. */
|
||||
final int record = LARGE_RECORD_COUNT / 2
|
||||
+ random.nextInt(LARGE_RECORD_COUNT / 2);
|
||||
assertEquals(offset(record), table.get(key(record)));
|
||||
}
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
|
||||
start.countDown();
|
||||
for (int record = 0; record < REMOVALS; record++) {
|
||||
assertEquals(offset(record), table.remove(key(record)));
|
||||
}
|
||||
for (final Future<?> result : results) result.get();
|
||||
|
||||
assertEquals(LARGE_RECORD_COUNT - REMOVALS, table.size());
|
||||
assertEquals(-1L, table.get(key(REMOVALS - 1)));
|
||||
assertEquals(offset(LARGE_RECORD_COUNT - 1),
|
||||
table.get(key(LARGE_RECORD_COUNT - 1)));
|
||||
assertEquals(1L, countWorkingFiles(repository.toPath()));
|
||||
assertArrayEquals(published, Files.readAllBytes(fingerprint.toPath()));
|
||||
} finally {
|
||||
readers.shutdownNow();
|
||||
try {
|
||||
assertTrue(readers.awaitTermination(10, TimeUnit.SECONDS));
|
||||
} finally {
|
||||
table.close();
|
||||
}
|
||||
}
|
||||
assertEquals(0L, countWorkingFiles(repository.toPath()));
|
||||
}
|
||||
|
||||
private static void writeTable(final File target, final int records)
|
||||
throws IOException {
|
||||
try (DataOutputStream output = new DataOutputStream(
|
||||
new BufferedOutputStream(Files.newOutputStream(target.toPath())))) {
|
||||
for (int record = 0; record < records; record++) {
|
||||
output.write(key(record));
|
||||
output.write(NaturalOrder.encodeLong(offset(record), Long.BYTES));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long countWorkingFiles(final Path repository) throws IOException {
|
||||
try (java.util.stream.Stream<Path> files = Files.walk(repository)) {
|
||||
return files.filter(path -> path.getFileName().toString()
|
||||
.endsWith(".sstwork")).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static long countBuilderArtifacts(final Path repository) throws IOException {
|
||||
try (java.util.stream.Stream<Path> files = Files.walk(repository)) {
|
||||
return files.filter(path -> {
|
||||
final String name = path.getFileName().toString();
|
||||
return name.startsWith("sst-build-")
|
||||
&& (name.endsWith(".sstlock") || name.endsWith(".run")
|
||||
|| name.endsWith(".merge") || name.endsWith(".sst"));
|
||||
}).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] key(final long record) {
|
||||
return NaturalOrder.encodeLong(record, KEY_LENGTH);
|
||||
}
|
||||
|
||||
private static long offset(final long record) {
|
||||
return record * 10L;
|
||||
}
|
||||
|
||||
private static boolean isWindows() {
|
||||
return System.getProperty("os.name", "").toLowerCase(Locale.ROOT).contains("win");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,344 @@
|
||||
/*
|
||||
* SSTableHandleMapTest
|
||||
* Copyright 2026 by Michael Peter Christen; mc@yacy.net, Frankfurt a. M., Germany
|
||||
*
|
||||
* This library is free software; you can redistribute it and/or
|
||||
* modify it under the terms of the GNU Lesser General Public
|
||||
* License as published by the Free Software Foundation; either
|
||||
* version 2.1 of the License, or (at your option) any later version.
|
||||
*/
|
||||
|
||||
package net.yacy.kelondro.index;
|
||||
|
||||
import static org.junit.Assert.assertArrayEquals;
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import java.io.BufferedOutputStream;
|
||||
import java.io.DataOutputStream;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.TemporaryFolder;
|
||||
|
||||
import net.yacy.cora.order.CloneableIterator;
|
||||
import net.yacy.cora.order.NaturalOrder;
|
||||
|
||||
public class SSTableHandleMapTest {
|
||||
|
||||
@Rule
|
||||
public final TemporaryFolder temporaryFolder = new TemporaryFolder();
|
||||
|
||||
@Test
|
||||
public void builderMergesBoundedRunsAndRemovesItsWorkingFile() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("builder");
|
||||
final SSTableHandleMap table;
|
||||
try (SSTableHandleMap.Builder builder = new SSTableHandleMap.Builder(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES,
|
||||
directory, "multipass", 1)) {
|
||||
/* One buffered record per run forces both fan-in limiting and a second merge pass. */
|
||||
for (int index = 129; index >= 0; index--) {
|
||||
builder.consume(key(index), index);
|
||||
}
|
||||
builder.consume(key(50), 999L);
|
||||
table = builder.finish();
|
||||
}
|
||||
|
||||
try {
|
||||
assertEquals(130, table.size());
|
||||
assertEquals(0L, table.get(key(0)));
|
||||
assertEquals(999L, table.get(key(50)));
|
||||
assertEquals(129L, table.get(key(129)));
|
||||
|
||||
final CloneableIterator<byte[]> keys = table.keys(true, null);
|
||||
int expected = 0;
|
||||
while (keys.hasNext()) assertArrayEquals(key(expected++), keys.next());
|
||||
assertEquals(130, expected);
|
||||
|
||||
assertEquals(1, directory.list().length);
|
||||
} finally {
|
||||
table.close();
|
||||
}
|
||||
assertEquals(0, directory.list().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void workingCopyPromotesLazilyAndHonorsOwnershipLocks() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("working-copy");
|
||||
final File fingerprint = new File(directory, "heap.fingerprint.idx");
|
||||
writeFingerprint(fingerprint);
|
||||
final byte[] publishedSnapshot = Files.readAllBytes(fingerprint.toPath());
|
||||
|
||||
/* Simulate a working file left by a crashed process: it has no live lock. */
|
||||
final String prefix = "sst-work-" + Integer.toHexString(
|
||||
fingerprint.getAbsolutePath().hashCode()) + "-";
|
||||
final Path crashedRepository = Files.createDirectories(
|
||||
directory.toPath().resolve("repository/old-generation"));
|
||||
Files.write(crashedRepository.resolve(prefix + "stale.sstwork"), new byte[] {1});
|
||||
|
||||
final SSTableHandleMap.RecoveryReport initialRecovery =
|
||||
SSTableHandleMap.recoverWorkingCopies(directory, crashedRepository.toFile());
|
||||
assertEquals(1L, initialRecovery.candidates());
|
||||
assertEquals(1L, initialRecovery.deleted());
|
||||
assertEquals(0L, initialRecovery.inUse());
|
||||
assertEquals(0L, initialRecovery.failures());
|
||||
|
||||
final SSTableHandleMap first = SSTableHandleMap.openWorkingCopy(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES, fingerprint);
|
||||
SSTableHandleMap second = null;
|
||||
try {
|
||||
/* Reads stay directly on the published read-only mapping. */
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
assertEquals(20L, first.remove(key(2)));
|
||||
assertEquals(1, countWorkingFiles(directory.toPath()));
|
||||
|
||||
/* The tombstone belongs to the private generation, never to the snapshot. */
|
||||
assertArrayEquals(publishedSnapshot, Files.readAllBytes(fingerprint.toPath()));
|
||||
|
||||
second = SSTableHandleMap.openWorkingCopy(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES, fingerprint);
|
||||
assertEquals(1, countWorkingFiles(directory.toPath()));
|
||||
assertEquals(20L, second.get(key(2)));
|
||||
assertEquals(30L, second.remove(key(3)));
|
||||
assertEquals(2, countWorkingFiles(directory.toPath()));
|
||||
|
||||
/* A global pass may run defensively without touching live generations. */
|
||||
final SSTableHandleMap.RecoveryReport liveRecovery =
|
||||
SSTableHandleMap.recoverWorkingCopies(directory);
|
||||
assertEquals(2L, liveRecovery.candidates());
|
||||
assertEquals(0L, liveRecovery.deleted());
|
||||
assertEquals(2L, liveRecovery.inUse());
|
||||
assertEquals(0L, liveRecovery.failures());
|
||||
} finally {
|
||||
if (second != null) second.close();
|
||||
first.close();
|
||||
}
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
assertArrayEquals(publishedSnapshot, Files.readAllBytes(fingerprint.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoveryCoversBuilderArtifactsAndPreservesUnrelatedFiles() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("builder-recovery");
|
||||
final String session = "sst-build-deadbeef-0123456789abcdef0123456789abcdef";
|
||||
final String[] artifacts = {
|
||||
session + ".sstlock",
|
||||
session + "-1.run",
|
||||
session + "-2.merge",
|
||||
session + "-3.sst",
|
||||
"sst-deadbeef-legacy.run",
|
||||
"sst-deadbeef-legacy.merge",
|
||||
"sst-deadbeef-legacy.sst"
|
||||
};
|
||||
for (final String artifact : artifacts) {
|
||||
Files.write(directory.toPath().resolve(artifact), new byte[] {1});
|
||||
}
|
||||
|
||||
final Path unrelatedRun = Files.write(
|
||||
directory.toPath().resolve("application.run"), new byte[] {2});
|
||||
final Path unrelatedSSTable = Files.write(
|
||||
directory.toPath().resolve("sst-user-data.sst"), new byte[] {3});
|
||||
|
||||
final SSTableHandleMap.RecoveryReport recovery =
|
||||
SSTableHandleMap.recoverWorkingCopies(directory);
|
||||
assertEquals(artifacts.length, recovery.candidates());
|
||||
assertEquals(artifacts.length, recovery.deleted());
|
||||
assertEquals(0L, recovery.inUse());
|
||||
assertEquals(0L, recovery.failures());
|
||||
for (final String artifact : artifacts) {
|
||||
assertFalse(Files.exists(directory.toPath().resolve(artifact)));
|
||||
}
|
||||
assertTrue(Files.isRegularFile(unrelatedRun));
|
||||
assertTrue(Files.isRegularFile(unrelatedSSTable));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recoveryDoesNotDeleteActiveBuilderOrFinishedTable() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("active-builder-recovery");
|
||||
final SSTableHandleMap.Builder builder = new SSTableHandleMap.Builder(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES,
|
||||
directory, "active", 1);
|
||||
SSTableHandleMap table = null;
|
||||
try {
|
||||
builder.consume(key(1), 10L);
|
||||
|
||||
final SSTableHandleMap.RecoveryReport whileBuilding =
|
||||
SSTableHandleMap.recoverWorkingCopies(directory);
|
||||
assertEquals(2L, whileBuilding.candidates());
|
||||
assertEquals(0L, whileBuilding.deleted());
|
||||
assertEquals(2L, whileBuilding.inUse());
|
||||
assertEquals(0L, whileBuilding.failures());
|
||||
|
||||
table = builder.finish();
|
||||
builder.close();
|
||||
assertEquals(10L, table.get(key(1)));
|
||||
|
||||
final SSTableHandleMap.RecoveryReport whileOpen =
|
||||
SSTableHandleMap.recoverWorkingCopies(directory);
|
||||
assertEquals(1L, whileOpen.candidates());
|
||||
assertEquals(0L, whileOpen.deleted());
|
||||
assertEquals(1L, whileOpen.inUse());
|
||||
assertEquals(0L, whileOpen.failures());
|
||||
assertEquals(10L, table.get(key(1)));
|
||||
} finally {
|
||||
try {
|
||||
builder.close();
|
||||
} finally {
|
||||
if (table != null) table.close();
|
||||
}
|
||||
}
|
||||
assertEquals(0, directory.list().length);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void workingCopyDecompressesGzipFingerprint() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("working-copy-gzip");
|
||||
final File fingerprint = new File(directory, "heap.fingerprint.idx.gz");
|
||||
writeFingerprint(fingerprint);
|
||||
|
||||
final SSTableHandleMap table = SSTableHandleMap.openWorkingCopy(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES, fingerprint);
|
||||
try {
|
||||
assertEquals(3, table.size());
|
||||
assertEquals(30L, table.get(key(3)));
|
||||
assertEquals(1, countWorkingFiles(directory.toPath()));
|
||||
final SSTableHandleMap.RecoveryReport recovery =
|
||||
SSTableHandleMap.recoverWorkingCopies(directory);
|
||||
assertEquals(1L, recovery.candidates());
|
||||
assertEquals(0L, recovery.deleted());
|
||||
assertEquals(1L, recovery.inUse());
|
||||
assertEquals(0L, recovery.failures());
|
||||
} finally {
|
||||
table.close();
|
||||
}
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void clearAndRemoveOnePromoteOnlyWhenTheyChangeTheTable() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("working-copy-mutations");
|
||||
final File fingerprint = new File(directory, "heap.fingerprint.idx");
|
||||
writeFingerprint(fingerprint);
|
||||
final byte[] publishedSnapshot = Files.readAllBytes(fingerprint.toPath());
|
||||
|
||||
final SSTableHandleMap removeOne = SSTableHandleMap.openWorkingCopy(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES, fingerprint);
|
||||
try {
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
assertEquals(30L, removeOne.removeone());
|
||||
assertEquals(1, countWorkingFiles(directory.toPath()));
|
||||
} finally {
|
||||
removeOne.close();
|
||||
}
|
||||
|
||||
final SSTableHandleMap clear = SSTableHandleMap.openWorkingCopy(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES, fingerprint);
|
||||
try {
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
clear.clear();
|
||||
assertEquals(0, clear.size());
|
||||
assertEquals(1, countWorkingFiles(directory.toPath()));
|
||||
clear.clear();
|
||||
assertEquals(1, countWorkingFiles(directory.toPath()));
|
||||
} finally {
|
||||
clear.close();
|
||||
}
|
||||
|
||||
assertEquals(0, countWorkingFiles(directory.toPath()));
|
||||
assertArrayEquals(publishedSnapshot, Files.readAllBytes(fingerprint.toPath()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsTruncatedUnsortedAndDuplicateTables() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("invalid-tables");
|
||||
|
||||
final File truncated = new File(directory, "truncated.idx");
|
||||
Files.write(truncated.toPath(), new byte[11]);
|
||||
assertInvalidTable(truncated, "not a multiple");
|
||||
|
||||
final File unsorted = new File(directory, "unsorted.idx");
|
||||
writeRawTable(unsorted, new int[] {1, 3, 2, 4}, null);
|
||||
assertInvalidTable(unsorted, "out of order");
|
||||
|
||||
final File duplicate = new File(directory, "duplicate.idx");
|
||||
writeRawTable(duplicate, new int[] {1, 2, 2, 3}, null);
|
||||
assertInvalidTable(duplicate, "duplicate key");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsUnknownNegativeOffsetsButAcceptsTombstones() throws Exception {
|
||||
final File directory = this.temporaryFolder.newFolder("invalid-offsets");
|
||||
final File invalid = new File(directory, "negative.idx");
|
||||
writeRawTable(invalid, new int[] {1, 2, 3}, new long[] {10L, -2L, 30L});
|
||||
assertInvalidTable(invalid, "negative SSTable offset -2");
|
||||
|
||||
final File tombstoned = new File(directory, "tombstoned.idx");
|
||||
writeRawTable(tombstoned, new int[] {1, 2, 3}, new long[] {10L, -1L, 30L});
|
||||
final SSTableHandleMap table = new SSTableHandleMap(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES, tombstoned);
|
||||
try {
|
||||
assertEquals(2, table.size());
|
||||
assertEquals(-1L, table.get(key(2)));
|
||||
assertEquals(30L, table.get(key(3)));
|
||||
} finally {
|
||||
table.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeFingerprint(final File target) throws Exception {
|
||||
final RowHandleMap map = new RowHandleMap(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES, 3, target.getName());
|
||||
try {
|
||||
map.putUnique(key(1), 10L);
|
||||
map.putUnique(key(2), 20L);
|
||||
map.putUnique(key(3), 30L);
|
||||
map.dump(target);
|
||||
} finally {
|
||||
map.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeRawTable(final File target, final int[] keys,
|
||||
final long[] offsets) throws IOException {
|
||||
try (DataOutputStream output = new DataOutputStream(
|
||||
new BufferedOutputStream(Files.newOutputStream(target.toPath())))) {
|
||||
for (int index = 0; index < keys.length; index++) {
|
||||
output.write(key(keys[index]));
|
||||
output.write(NaturalOrder.encodeLong(
|
||||
offsets == null ? keys[index] * 10L : offsets[index],
|
||||
Long.BYTES));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void assertInvalidTable(final File tableFile,
|
||||
final String expectedMessage) throws Exception {
|
||||
SSTableHandleMap table = null;
|
||||
try {
|
||||
table = new SSTableHandleMap(
|
||||
4, NaturalOrder.naturalOrder, Long.BYTES, tableFile);
|
||||
throw new AssertionError("Invalid SSTable was accepted: " + tableFile);
|
||||
} catch (final IOException expected) {
|
||||
assertTrue(expected.getMessage(),
|
||||
expected.getMessage().contains(expectedMessage));
|
||||
} finally {
|
||||
if (table != null) table.close();
|
||||
}
|
||||
}
|
||||
|
||||
private static long countWorkingFiles(final Path directory) throws Exception {
|
||||
try (java.util.stream.Stream<Path> files = Files.walk(directory)) {
|
||||
return files.filter(path -> path.getFileName().toString().endsWith(".sstwork")).count();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] key(final int value) {
|
||||
return String.format("%04d", value).getBytes(StandardCharsets.US_ASCII);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user