From 050ddb0dc8010e0b592caef1455f7457b9742eb5 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 11 Oct 2026 01:29:43 +0530 Subject: [PATCH 01/15] test(struct): cover BytesBuffer limits and EdgeId parsing Add struct-local tests for BytesBuffer varint rejection, unsigned limits, length-prefixed bytes, string endings, edge and index id encoding, partition and OLAP id parsing, tagged collections and buffer growth, plus EdgeId text form, parse errors and binary id generation. --- .../org/apache/hugegraph/id/EdgeIdTest.java | 170 +++++++++++ .../hugegraph/serializer/BytesBufferTest.java | 281 ++++++++++++++++++ 2 files changed, 451 insertions(+) create mode 100644 hugegraph-struct/src/test/java/org/apache/hugegraph/id/EdgeIdTest.java create mode 100644 hugegraph-struct/src/test/java/org/apache/hugegraph/serializer/BytesBufferTest.java diff --git a/hugegraph-struct/src/test/java/org/apache/hugegraph/id/EdgeIdTest.java b/hugegraph-struct/src/test/java/org/apache/hugegraph/id/EdgeIdTest.java new file mode 100644 index 0000000000..ceceec87bc --- /dev/null +++ b/hugegraph-struct/src/test/java/org/apache/hugegraph/id/EdgeIdTest.java @@ -0,0 +1,170 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.id; + +import java.util.UUID; + +import org.apache.hugegraph.backend.BinaryId; +import org.apache.hugegraph.exception.NotFoundException; +import org.apache.hugegraph.serializer.BytesBuffer; +import org.apache.hugegraph.type.HugeType; +import org.apache.hugegraph.type.define.Directions; +import org.junit.Assert; +import org.junit.Test; + +/** + * Text form and parsing rules of the struct EdgeId and SplicingIdGenerator. + * Server unit tests cover successful parsing and equality; this class covers + * the directed form, escaping and the rejection paths. + */ +public class EdgeIdTest { + + private static final Id OWNER = IdGenerator.of("alice"); + private static final Id OTHER = IdGenerator.of(42L); + private static final Id LABEL = IdGenerator.of(3L); + private static final Id SUB_LABEL = IdGenerator.of(4L); + + @Test + public void testUndirectedTextIsWrittenFromSourceToTarget() { + EdgeId in = new EdgeId(OWNER, Directions.IN, LABEL, SUB_LABEL, "s", OTHER); + // An IN edge stored under alice is the OUT edge 42 -> alice + Assert.assertEquals("L42>3>4>s>Salice", in.asString()); + + EdgeId out = in.switchDirection(); + Assert.assertEquals(OTHER, out.ownerVertexId()); + Assert.assertEquals(Directions.OUT, out.direction()); + Assert.assertEquals(OWNER, out.otherVertexId()); + Assert.assertEquals(in.asString(), out.asString()); + Assert.assertEquals(in, out); + Assert.assertEquals(in.hashCode(), out.hashCode()); + Assert.assertEquals(0, in.compareTo(out)); + + EdgeId parsed = EdgeId.parse(in.asString()); + Assert.assertEquals(Directions.OUT, parsed.direction()); + Assert.assertEquals(OTHER, parsed.ownerVertexId()); + Assert.assertEquals(in, parsed); + } + + @Test + public void testDirectedTextKeepsOwnerAndDirection() { + EdgeId directed = new EdgeId(OWNER, Directions.IN, LABEL, SUB_LABEL, "s", OTHER, true); + Assert.assertEquals("Salice>I>3>4>s>L42", directed.asString()); + // Directed identity distinguishes the two sides of the same edge + Assert.assertNotEquals(directed, directed.switchDirection()); + Assert.assertEquals(directed, directed.directed(false).directed(true)); + + EdgeId parsed = EdgeId.parse(directed.asString()); + Assert.assertEquals(OWNER, parsed.ownerVertexId()); + Assert.assertEquals(Directions.IN, parsed.direction()); + Assert.assertEquals(LABEL, parsed.edgeLabelId()); + Assert.assertEquals(SUB_LABEL, parsed.subLabelId()); + Assert.assertEquals("s", parsed.sortValues()); + Assert.assertEquals(OTHER, parsed.otherVertexId()); + Assert.assertEquals(directed.directed(false), parsed); + } + + @Test + public void testSeparatorsInsidePartsSurviveTextRoundTrip() { + Id owner = IdGenerator.of("a>b"); + EdgeId edge = new EdgeId(owner, Directions.OUT, LABEL, SUB_LABEL, "x>y`z", OTHER); + Assert.assertEquals("Sa`>b>3>4>x`>y`z>L42", edge.asString()); + Assert.assertEquals(5, EdgeId.split(edge).length); + + EdgeId parsed = EdgeId.parse(edge.asString()); + Assert.assertEquals(owner, parsed.ownerVertexId()); + Assert.assertEquals("x>y`z", parsed.sortValues()); + Assert.assertEquals(edge, parsed); + } + + @Test + public void testParseRejectsMalformedText() { + String[] invalid = { + "Salice>3>4>L42", // too few parts + "Salice>O>3>4>s>L42>extra", // too many parts + "Salice>x>4>s>L42", // non numeric label + "Salice>3>4>s>Lnan", // non numeric long vertex + "Salice>Z>3>4>s>L42", // unknown direction + "Salice>VL>3>4>s>L42" // a type that is not a direction + }; + for (String text : invalid) { + Assert.assertThrows(text, NotFoundException.class, () -> EdgeId.parse(text)); + Assert.assertNull(text, EdgeId.parse(text, true)); + } + NotFoundException e = Assert.assertThrows(NotFoundException.class, + () -> EdgeId.parse("a>b")); + Assert.assertTrue(e.getMessage().contains("5~6 parts, but got 2 parts")); + e = Assert.assertThrows(NotFoundException.class, () -> EdgeId.parse("Salice>x>4>s>L42")); + Assert.assertTrue(e.getCause() instanceof NumberFormatException); + } + + @Test + public void testStoredStringRequiresFiveParts() { + EdgeId edge = new EdgeId(OWNER, Directions.IN, LABEL, SUB_LABEL, "s", OTHER); + String stored = EdgeId.asStoredString(edge); + Assert.assertEquals(edge, EdgeId.parseStoredString(stored)); + + Assert.assertThrows(IllegalArgumentException.class, + () -> EdgeId.parseStoredString("Salice>D>E>s")); + Assert.assertThrows(IllegalArgumentException.class, + () -> EdgeId.parseStoredString(stored + ">extra")); + } + + @Test + public void testDirectionCodes() { + Assert.assertEquals(HugeType.EDGE_OUT.code(), EdgeId.directionToCode(Directions.OUT)); + Assert.assertEquals(HugeType.EDGE_IN.code(), EdgeId.directionToCode(Directions.IN)); + Assert.assertTrue(EdgeId.isOutDirectionFromCode(HugeType.EDGE_OUT.code())); + Assert.assertFalse(EdgeId.isOutDirectionFromCode(HugeType.EDGE_IN.code())); + Assert.assertThrows(IllegalArgumentException.class, + () -> EdgeId.directionFromCode(HugeType.VERTEX.code())); + Assert.assertThrows(IllegalArgumentException.class, + () -> EdgeId.directionToCode(Directions.BOTH)); + Assert.assertThrows(UnsupportedOperationException.class, + () -> new EdgeId(OWNER, Directions.OUT, LABEL, SUB_LABEL, "", + OTHER).asLong()); + } + + @Test + public void testSplicingEscapesOnlyItsOwnSeparator() { + Id spliced = SplicingIdGenerator.splicing("1", "a:b", "c>d"); + Assert.assertEquals("1:a`:b:c>d", spliced.asString()); + Assert.assertArrayEquals(new String[]{"1", "a:b", "c>d"}, + SplicingIdGenerator.parse(spliced)); + + Id raw = SplicingIdGenerator.splicingWithNoEscape("1", "a:b"); + Assert.assertArrayEquals(new String[]{"1", "a", "b"}, SplicingIdGenerator.parse(raw)); + + Assert.assertEquals("v`!1!2!", SplicingIdGenerator.concatValues("v!1", 2, "")); + Assert.assertArrayEquals(new String[]{"", ""}, SplicingIdGenerator.split(">")); + } + + @Test + public void testGenerateBinaryIdMatchesBinaryIdLayout() { + Id[] ids = {IdGenerator.of("v1"), IdGenerator.of(1L << 40), IdGenerator.of(-1L), + IdGenerator.of(UUID.fromString("00112233-4455-6677-8899-aabbccddeeff"))}; + for (Id id : ids) { + Id binary = SplicingIdGenerator.generateBinaryId(id); + Assert.assertTrue(binary instanceof BinaryId); + Assert.assertArrayEquals(BytesBuffer.allocate(32).writeId(id).bytes(), binary.asBytes()); + Assert.assertEquals(id, ((BinaryId) binary).origin()); + Assert.assertEquals(id, BytesBuffer.wrap(binary.asBytes()).readId()); + // Already binary ids are returned unchanged + Assert.assertSame(binary, SplicingIdGenerator.generateBinaryId(binary)); + } + } +} diff --git a/hugegraph-struct/src/test/java/org/apache/hugegraph/serializer/BytesBufferTest.java b/hugegraph-struct/src/test/java/org/apache/hugegraph/serializer/BytesBufferTest.java new file mode 100644 index 0000000000..588fad41db --- /dev/null +++ b/hugegraph-struct/src/test/java/org/apache/hugegraph/serializer/BytesBufferTest.java @@ -0,0 +1,281 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.serializer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.UUID; + +import org.apache.hugegraph.backend.BinaryId; +import org.apache.hugegraph.id.EdgeId; +import org.apache.hugegraph.id.Id; +import org.apache.hugegraph.id.IdGenerator; +import org.apache.hugegraph.struct.schema.PropertyKey; +import org.apache.hugegraph.type.HugeType; +import org.apache.hugegraph.type.define.Cardinality; +import org.apache.hugegraph.type.define.DataType; +import org.apache.hugegraph.type.define.Directions; +import org.apache.hugegraph.util.Bytes; +import org.junit.Assert; +import org.junit.Test; +import org.junit.function.ThrowingRunnable; + +/** + * Behaviour of the struct BytesBuffer that the legacy golden fixtures in + * compatibility/LegacyCoreCodecTest do not reach: encoding width boundaries, + * malformed input rejection and buffer capacity rules. + */ +public class BytesBufferTest { + + @Test + public void testReadVarIntRejectsMalformedEncodings() { + // Five continuation bytes never terminate within the int width + assertIllegalArgument(() -> wrap(0x81, 0x81, 0x81, 0x81, 0x81).readVInt(), + "too many bytes"); + // A five byte varint can carry only four payload bits in its lead + assertIllegalArgument(() -> wrap(0x90, 0x80, 0x80, 0x80, 0x00).readVInt(), + "leading byte"); + Assert.assertEquals(Integer.MAX_VALUE, wrap(0x87, 0xff, 0xff, 0xff, 0x7f).readVInt()); + } + + @Test + public void testReadVarLongRejectsMalformedEncodings() { + int[] neverEnding = new int[10]; + Arrays.fill(neverEnding, 0x81); + assertIllegalArgument(() -> wrap(neverEnding).readVLong(), "too many bytes"); + + byte[] minusOne = BytesBuffer.allocate(16).writeVLong(-1L).bytes(); + Assert.assertEquals(-1L, BytesBuffer.wrap(minusOne).readVLong()); + // A ten byte varlong can carry only one payload bit in its lead + byte[] overflow = minusOne.clone(); + overflow[0] = (byte) 0x83; + assertIllegalArgument(() -> BytesBuffer.wrap(overflow).readVLong(), "leading byte"); + } + + @Test + public void testUnsignedIntegersRoundTripAtTheirLimits() { + BytesBuffer buffer = BytesBuffer.allocate(16); + buffer.writeUInt8(BytesBuffer.UINT8_MAX).writeUInt8(0); + buffer.writeUInt16(BytesBuffer.UINT16_MAX).writeUInt16(0); + buffer.writeUInt32(BytesBuffer.UINT32_MAX).writeUInt32(0L); + Assert.assertEquals(14, buffer.position()); + Assert.assertEquals("ff00ffff0000ffffffff00000000", Bytes.toHex(buffer.bytes())); + + buffer.forReadWritten(); + Assert.assertEquals(255, buffer.readUInt8()); + Assert.assertEquals(0, buffer.readUInt8()); + Assert.assertEquals(65535, buffer.readUInt16()); + Assert.assertEquals(0, buffer.readUInt16()); + Assert.assertEquals(4294967295L, buffer.readUInt32()); + Assert.assertEquals(0L, buffer.readUInt32()); + } + + @Test + public void testBytesAreLengthPrefixedAndLimited() { + BytesBuffer buffer = BytesBuffer.allocate(4); + buffer.writeBytes(BytesBuffer.BYTES_EMPTY); + byte[] big = new byte[300]; + Arrays.fill(big, (byte) 0x5a); + buffer.writeBigBytes(big); + // Empty payload is a single zero length; 300 needs a two byte vint + Assert.assertEquals(1 + 2 + 300, buffer.position()); + + buffer.forReadWritten(); + Assert.assertArrayEquals(BytesBuffer.BYTES_EMPTY, buffer.readBytes()); + Assert.assertArrayEquals(big, buffer.readBigBytes()); + Assert.assertEquals(0, buffer.remaining()); + + byte[] tooLong = new byte[(int) BytesBuffer.BYTES_LEN_MAX + 1]; + BytesBuffer writer = BytesBuffer.allocate(8); + assertIllegalArgument(() -> writer.writeBytes(tooLong), "max length of bytes"); + Assert.assertEquals("Rejected bytes must not be partially written", 0, writer.position()); + } + + @Test + public void testStringsWithEndingAreSequentialAndOrderPreserving() { + BytesBuffer buffer = BytesBuffer.allocate(8); + buffer.writeStringWithEnding("ab").writeStringWithEnding("").writeStringWithEnding("顶点"); + buffer.forReadWritten(); + Assert.assertEquals("ab", buffer.readStringWithEnding()); + Assert.assertEquals("", buffer.readStringWithEnding()); + Assert.assertEquals("顶点", buffer.readStringWithEnding()); + Assert.assertEquals(0, buffer.remaining()); + + // The 0x00 ending keeps a prefix ordered before its extensions + byte[] prefix = BytesBuffer.allocate(8).writeStringWithEnding("ab").bytes(); + byte[] longer = BytesBuffer.allocate(8).writeStringWithEnding("ab!").bytes(); + Assert.assertTrue(Bytes.compare(prefix, longer) < 0); + + assertIllegalArgument(() -> wrap('a', 'b').readStringWithEnding(), "Not found ending"); + } + + @Test + public void testEdgeIdIsMarkedAndValidatedOnRead() { + EdgeId edge = new EdgeId(IdGenerator.of("alice"), Directions.IN, IdGenerator.of(3L), + IdGenerator.of(4L), "2026", IdGenerator.of(99L)); + byte[] bytes = BytesBuffer.allocate(8).writeId(edge).bytes(); + Assert.assertEquals((byte) 0x7e, bytes[0]); + BytesBuffer reader = BytesBuffer.wrap(bytes); + EdgeId actual = (EdgeId) reader.readId(); + Assert.assertEquals(edge, actual); + Assert.assertEquals(Directions.IN, actual.direction()); + Assert.assertEquals(0, reader.remaining()); + + EdgeId nulSort = new EdgeId(IdGenerator.of("alice"), Directions.OUT, IdGenerator.of(3L), + IdGenerator.of(4L), "a\u0000", IdGenerator.of(99L)); + assertIllegalArgument(() -> BytesBuffer.allocate(8).writeId(nulSort), "0x00"); + + // A column type byte that is neither EDGE_OUT nor EDGE_IN is corrupt + BytesBuffer corrupt = BytesBuffer.allocate(32); + corrupt.writeId(IdGenerator.of("alice")); + corrupt.write(HugeType.VERTEX.code()); + corrupt.writeId(IdGenerator.of(3L)).writeId(IdGenerator.of(4L)); + corrupt.writeStringWithEnding("").writeId(IdGenerator.of(99L)); + Assert.assertThrows(IllegalStateException.class, + () -> corrupt.forReadWritten().readEdgeId()); + + byte[] withoutOwner = Arrays.copyOfRange(bytes, 1 + 6, bytes.length); + assertIllegalArgument(() -> BytesBuffer.wrap(withoutOwner).readEdgeId(false, null), + "owner"); + } + + @Test + public void testIndexIdEndingDependsOnIndexType() { + byte[] label = {(byte) 150, 1, 2, 3, 4, 'v'}; + byte[] ended = BytesBuffer.allocate(8) + .writeIndexId(new BinaryId(label, null), HugeType.SECONDARY_INDEX) + .bytes(); + Assert.assertEquals(label.length + 1, ended.length); + Assert.assertEquals(BytesBuffer.STRING_ENDING_BYTE, ended[label.length]); + byte[] open = BytesBuffer.allocate(8) + .writeIndexId(new BinaryId(label, null), + HugeType.SECONDARY_INDEX, false) + .bytes(); + Assert.assertArrayEquals(label, open); + + byte[] range = {(byte) 160, 0, 0, 0, 3, (byte) 0x80, 0, 0, 7}; + byte[] written = BytesBuffer.allocate(8) + .writeIndexId(new BinaryId(range, null), + HugeType.RANGE_INT_INDEX) + .bytes(); + Assert.assertArrayEquals("Range index ids have a fixed width and no ending", + range, written); + // Range ids may legally contain 0x00 while string index ids may not + byte[] nul = {(byte) 150, 0, 'v'}; + assertIllegalArgument(() -> BytesBuffer.allocate(8).writeIndexId( + new BinaryId(nul, null), HugeType.SECONDARY_INDEX), "0x00"); + assertIllegalArgument(() -> BytesBuffer.allocate(8).writeIndexId( + new BinaryId(new byte[0], null), HugeType.RANGE_INT_INDEX), "empty id"); + } + + @Test + public void testParseIdSkipsPartitionAndOlapPrefixes() { + Id vertex = IdGenerator.of("v1"); + byte[] vertexBytes = BytesBuffer.allocate(8).writeId(vertex).bytes(); + + BytesBuffer partitioned = BytesBuffer.allocate(16); + partitioned.writeShort((short) 7).writeId(vertex).forReadWritten(); + BinaryId parsed = partitioned.parseId(HugeType.VERTEX, true); + Assert.assertArrayEquals(vertexBytes, parsed.asBytes()); + Assert.assertEquals(vertex, parsed.origin()); + Assert.assertEquals(0, partitioned.remaining()); + + Id propertyKey = IdGenerator.of(5L); + BytesBuffer olap = BytesBuffer.allocate(16); + olap.writeId(propertyKey).writeId(vertex).forReadWritten(); + BinaryId olapId = olap.parseOlapId(HugeType.VERTEX, true); + // The OLAP binary id keeps its property prefix but resolves to the vertex + byte[] keyBytes = BytesBuffer.allocate(8).writeId(propertyKey).bytes(); + Assert.assertEquals(keyBytes.length + vertexBytes.length, olapId.length()); + Assert.assertEquals(vertex, olapId.origin()); + } + + @Test + public void testTaggedCollectionsRestoreContainerType() { + PropertyKey key = new PropertyKey(null, IdGenerator.of(1L), "k"); + UUID first = UUID.fromString("00000000-0000-0000-0000-000000000002"); + UUID second = UUID.fromString("00000000-0000-0000-0000-000000000001"); + BytesBuffer buffer = BytesBuffer.allocate(8); + buffer.writeProperty(Cardinality.LIST, DataType.TEXT, Arrays.asList("b", "a", "b")); + buffer.writeProperty(Cardinality.SET, DataType.UUID, + new LinkedHashSet<>(Arrays.asList(first, second))); + buffer.writeProperty(Cardinality.LIST, DataType.LONG, new ArrayList()); + buffer.forReadWritten(); + + Object list = buffer.readProperty(key); + Assert.assertTrue(list instanceof ArrayList); + Assert.assertEquals(Arrays.asList("b", "a", "b"), list); + Assert.assertEquals(Cardinality.LIST, key.cardinality()); + Assert.assertEquals(DataType.TEXT, key.dataType()); + + Object set = buffer.readProperty(key); + Assert.assertTrue(set instanceof LinkedHashSet); + Assert.assertEquals(Arrays.asList(first, second), new ArrayList<>((LinkedHashSet) set)); + Assert.assertEquals(DataType.UUID, key.dataType()); + + Assert.assertEquals(new ArrayList<>(), buffer.readProperty(key)); + Assert.assertEquals(DataType.LONG, key.dataType()); + Assert.assertEquals(0, buffer.remaining()); + } + + @Test + public void testSkipRejectsTruncatedOrNegativeLengths() { + PropertyKey text = new PropertyKey(null, IdGenerator.of(1L), "text"); + text.dataType(DataType.TEXT); + text.cardinality(Cardinality.SINGLE); + BytesBuffer truncated = BytesBuffer.allocate(8).writeVInt(10).writeStringRaw("abc"); + int start = truncated.forReadWritten().position(); + assertIllegalArgument(() -> truncated.skipSchemaProperty(text), "skipped byte length"); + Assert.assertTrue("Rejected skip must not move past the buffer", + truncated.position() <= start + 1); + + PropertyKey list = new PropertyKey(null, IdGenerator.of(2L), "list"); + list.dataType(DataType.INT); + list.cardinality(Cardinality.LIST); + BytesBuffer negative = BytesBuffer.allocate(8).writeVInt(-1).forReadWritten(); + assertIllegalArgument(() -> negative.skipSchemaProperty(list), "collection size"); + } + + @Test + public void testAllocatedBufferGrowsButWrappedBufferDoesNot() { + BytesBuffer buffer = BytesBuffer.allocate(2); + buffer.writeShort((short) 0x0102).writeLong(0x0304050607080910L).writeString("xyz"); + Assert.assertEquals(2 + 8 + 1 + 3, buffer.position()); + Assert.assertEquals("010203040506070809100378797a", Bytes.toHex(buffer.bytes())); + + BytesBuffer wrapped = BytesBuffer.wrap(new byte[3]); + wrapped.writeShort((short) 1); + Assert.assertThrows(IllegalStateException.class, () -> wrapped.writeShort((short) 2)); + Assert.assertEquals(2, wrapped.position()); + } + + private static BytesBuffer wrap(int... values) { + byte[] bytes = new byte[values.length]; + for (int i = 0; i < values.length; i++) { + bytes[i] = (byte) values[i]; + } + return BytesBuffer.wrap(bytes); + } + + private static void assertIllegalArgument(ThrowingRunnable runnable, String message) { + IllegalArgumentException e = Assert.assertThrows(IllegalArgumentException.class, runnable); + Assert.assertTrue("Unexpected message: " + e.getMessage(), + e.getMessage().contains(message)); + } +} From 4c1470bf30e57060cbe65bca6c2a3ffc3f4b7d4a Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 11 Oct 2026 01:29:43 +0530 Subject: [PATCH 02/15] test(commons): cover option parsing, TLS host checks and RPC config Add common tests for typed option parsing and checkers, HugeConfig URL normalization and input validation, and HostNameVerifier negative paths (SAN matching, wildcard scope, IP vs DNS, no CN fallback). Add an RPC test for self URL exclusion, invalid RpcOptions, provider and consumer config, and service cleanup on destroy. Register all in UnitTestSuite. --- .../apache/hugegraph/unit/UnitTestSuite.java | 6 + .../unit/config/HugeConfigValidationTest.java | 239 +++++++++++++++ .../unit/config/TypedOptionTest.java | 278 ++++++++++++++++++ .../unit/rest/HostNameVerifierTest.java | 128 ++++++++ .../apache/hugegraph/unit/RpcConfigTest.java | 246 ++++++++++++++++ .../apache/hugegraph/unit/UnitTestSuite.java | 3 +- 6 files changed, 899 insertions(+), 1 deletion(-) create mode 100644 hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/HugeConfigValidationTest.java create mode 100644 hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/TypedOptionTest.java create mode 100644 hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/rest/HostNameVerifierTest.java create mode 100644 hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java index 9e5e39c546..f876e9e0c7 100644 --- a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -21,7 +21,9 @@ import org.apache.hugegraph.testutil.WhiteboxTest; import org.apache.hugegraph.unit.auth.TokenGeneratorTest; import org.apache.hugegraph.unit.config.HugeConfigTest; +import org.apache.hugegraph.unit.config.HugeConfigValidationTest; import org.apache.hugegraph.unit.config.OptionSpaceTest; +import org.apache.hugegraph.unit.config.TypedOptionTest; import org.apache.hugegraph.unit.event.EventHubTest; import org.apache.hugegraph.unit.rest.AbstractRestClientTest; import org.apache.hugegraph.unit.version.VersionTest; @@ -52,6 +54,7 @@ import org.apache.hugegraph.unit.license.MachineInfoTest; import org.apache.hugegraph.unit.perf.PerfUtilTest; import org.apache.hugegraph.unit.perf.StopwatchTest; +import org.apache.hugegraph.unit.rest.HostNameVerifierTest; import org.apache.hugegraph.unit.rest.RestClientTest; import org.apache.hugegraph.unit.rest.RestResultTest; import org.apache.hugegraph.unit.util.BytesTest; @@ -83,7 +86,9 @@ TokenGeneratorTest.class, HugeConfigTest.class, + HugeConfigValidationTest.class, OptionSpaceTest.class, + TypedOptionTest.class, SafeDateFormatTest.class, BarrierEventTest.class, EventHubTest.class, @@ -91,6 +96,7 @@ StopwatchTest.class, AbstractRestClientTest.class, RestClientTest.class, + HostNameVerifierTest.class, RestResultTest.class, VersionTest.class, diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/HugeConfigValidationTest.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/HugeConfigValidationTest.java new file mode 100644 index 0000000000..a9e4044f84 --- /dev/null +++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/HugeConfigValidationTest.java @@ -0,0 +1,239 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit.config; + +import static org.apache.hugegraph.config.OptionChecker.disallowEmpty; +import static org.apache.hugegraph.config.OptionChecker.rangeInt; + +import java.util.HashMap; +import java.util.Map; + +import org.apache.commons.configuration2.Configuration; +import org.apache.hugegraph.config.ConfigException; +import org.apache.hugegraph.config.ConfigListOption; +import org.apache.hugegraph.config.ConfigOption; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.config.OptionHolder; +import org.apache.hugegraph.config.OptionSpace; +import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.unit.BaseUnitTest; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +public class HugeConfigValidationTest extends BaseUnitTest { + + @BeforeClass + public static void init() { + OptionSpace.register("validation-test", ValidationOptions.class.getName()); + } + + @Test + public void testUrlOptionAddsMissingScheme() { + HugeConfig config = configOf(ValidationOptions.httpUrl.name(), "127.0.0.1:8080"); + Assert.assertEquals("http://127.0.0.1:8080", config.get(ValidationOptions.httpUrl)); + + // Surrounding whitespace is trimmed before the scheme is prefixed + config = configOf(ValidationOptions.httpUrl.name(), " graph.local:8080/api "); + Assert.assertEquals("http://graph.local:8080/api", config.get(ValidationOptions.httpUrl)); + + // A configured scheme which already ends with "://" isn't doubled + config = configOf(ValidationOptions.httpsUrl.name(), "graph.local"); + Assert.assertEquals("https://graph.local", config.get(ValidationOptions.httpsUrl)); + } + + @Test + public void testUrlOptionKeepsExistingScheme() { + HugeConfig config = configOf(ValidationOptions.httpUrl.name(), "https://graph.local:8443"); + Assert.assertEquals("https://graph.local:8443", config.get(ValidationOptions.httpUrl)); + + // Only the scheme is lower-cased, host and path keep their case + config = configOf(ValidationOptions.httpUrl.name(), "HTTPS://Graph.Local/Path"); + Assert.assertEquals("https://Graph.Local/Path", config.get(ValidationOptions.httpUrl)); + } + + @Test + public void testUrlNormalizationSkipsDefaultAndPlainOptions() { + HugeConfig config = configOf(ValidationOptions.plainUrl.name(), "127.0.0.1:8080"); + Assert.assertEquals("127.0.0.1:8080", config.get(ValidationOptions.plainUrl)); + // Default values are returned as declared and never rewritten + Assert.assertEquals("localhost:8080", config.get(ValidationOptions.httpUrl)); + Assert.assertTrue(ValidationOptions.httpUrl.needsUrlNormalization()); + Assert.assertEquals("http", ValidationOptions.httpUrl.getDefaultScheme()); + Assert.assertFalse(ValidationOptions.plainUrl.needsUrlNormalization()); + Assert.assertNull(ValidationOptions.plainUrl.getDefaultScheme()); + } + + @Test + public void testConstructWithInvalidSource() { + Assert.assertThrows(ConfigException.class, () -> { + new HugeConfig((Map) null); + }, e -> { + Assert.assertContains("The property map is null", e.getMessage()); + }); + Assert.assertThrows(ConfigException.class, () -> { + new HugeConfig((Configuration) null); + }, e -> { + Assert.assertContains("The config object is null", e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + new HugeConfig(""); + }, e -> { + Assert.assertContains("The config path can't be empty", e.getMessage()); + }); + Assert.assertThrows(IllegalArgumentException.class, () -> { + new HugeConfig("target/not-exist-" + System.nanoTime() + ".properties"); + }, e -> { + Assert.assertContains("Please specify a proper config file", e.getMessage()); + }); + // A directory is not a config file either + Assert.assertThrows(IllegalArgumentException.class, () -> { + new HugeConfig("src/test"); + }); + } + + @Test + public void testStringValuesAreParsedAndChecked() { + HugeConfig config = configOf(ValidationOptions.port.name(), "8182"); + Assert.assertEquals(8182, config.get(ValidationOptions.port)); + + Assert.assertThrows(ConfigException.class, () -> { + configOf(ValidationOptions.port.name(), "70000"); + }, e -> { + Assert.assertContains("Invalid option value for 'validation.port': 70000", + e.getMessage()); + }); + Assert.assertThrows(ConfigException.class, () -> { + configOf(ValidationOptions.port.name(), "http"); + }, e -> { + Assert.assertContains("expect 'Integer' type", e.getMessage()); + }); + Assert.assertThrows(ConfigException.class, () -> { + configOf(ValidationOptions.name.name(), " "); + }); + } + + @Test + public void testNonStringValueWithWrongType() { + Map options = new HashMap<>(); + options.put(ValidationOptions.port.name(), 8182L); + Assert.assertThrows(IllegalArgumentException.class, () -> { + new HugeConfig(options); + }, e -> { + Assert.assertContains("Invalid value for key 'validation.port': '8182'", + e.getMessage()); + }); + + options.put(ValidationOptions.port.name(), 8182); + Assert.assertEquals(8182, new HugeConfig(options).get(ValidationOptions.port)); + } + + @Test + public void testUnregisteredOptionIsKeptAsRawValue() { + HugeConfig config = configOf("validation.unregistered", "raw-value"); + Assert.assertEquals("raw-value", config.getProperty("validation.unregistered")); + Assert.assertEquals("default-name", config.get(ValidationOptions.name)); + } + + @Test + public void testGetMapWithInvalidEntry() { + HugeConfig config = configOf(ValidationOptions.pairs.name(), "[k1: v1, k2:a:b]"); + Assert.assertEquals(ImmutableMap.of("k1", "v1", "k2", "a:b"), + config.getMap(ValidationOptions.pairs)); + + HugeConfig invalid = configOf(ValidationOptions.pairs.name(), "[k1:v1, k2]"); + Assert.assertThrows(IllegalStateException.class, () -> { + invalid.getMap(ValidationOptions.pairs); + }, e -> { + Assert.assertContains("Invalid option format for 'validation.pairs': k2", + e.getMessage()); + Assert.assertContains("expect KEY:VALUE", e.getMessage()); + }); + } + + private static HugeConfig configOf(String key, Object value) { + Map options = new HashMap<>(); + options.put(key, value); + return new HugeConfig(options); + } + + public static class ValidationOptions extends OptionHolder { + + private static volatile ValidationOptions instance; + + public static synchronized ValidationOptions instance() { + if (instance == null) { + instance = new ValidationOptions(); + instance.registerOptions(); + } + return instance; + } + + public static final ConfigOption httpUrl = + new ConfigOption<>( + "validation.http_url", + "url option normalized with http scheme", + disallowEmpty(), + "localhost:8080" + ).withUrlNormalization("http"); + + public static final ConfigOption httpsUrl = + new ConfigOption<>( + "validation.https_url", + "url option normalized with https scheme", + disallowEmpty(), + "localhost:8443" + ).withUrlNormalization("https://"); + + public static final ConfigOption plainUrl = + new ConfigOption<>( + "validation.plain_url", + "url option without normalization", + disallowEmpty(), + "localhost:8080" + ); + + public static final ConfigOption port = + new ConfigOption<>( + "validation.port", + "port option", + rangeInt(1, 65535), + 8080 + ); + + public static final ConfigOption name = + new ConfigOption<>( + "validation.name", + "non-empty name option", + disallowEmpty(), + "default-name" + ); + + public static final ConfigListOption pairs = + new ConfigListOption<>( + "validation.pairs", + false, + "key value pairs", + null, + String.class, + ImmutableList.of() + ); + } +} diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/TypedOptionTest.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/TypedOptionTest.java new file mode 100644 index 0000000000..c962329b1b --- /dev/null +++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/config/TypedOptionTest.java @@ -0,0 +1,278 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit.config; + +import static org.apache.hugegraph.config.OptionChecker.allowValues; +import static org.apache.hugegraph.config.OptionChecker.disallowEmpty; +import static org.apache.hugegraph.config.OptionChecker.inValues; +import static org.apache.hugegraph.config.OptionChecker.nonNegativeInt; +import static org.apache.hugegraph.config.OptionChecker.positiveInt; +import static org.apache.hugegraph.config.OptionChecker.rangeDouble; +import static org.apache.hugegraph.config.OptionChecker.rangeInt; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.hugegraph.config.ConfigConvOption; +import org.apache.hugegraph.config.ConfigException; +import org.apache.hugegraph.config.ConfigListConvOption; +import org.apache.hugegraph.config.ConfigListOption; +import org.apache.hugegraph.config.ConfigOption; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; + +import com.google.common.base.Predicate; +import com.google.common.collect.ImmutableList; + +public class TypedOptionTest { + + @Test + public void testParseConvertNumbersAndBooleans() { + ConfigOption intOption = new ConfigOption<>( + "typed.int", "int option", rangeInt(1, 100), 10); + Assert.assertEquals(42, intOption.parseConvert("42")); + Assert.assertEquals(1, intOption.parseConvert("1")); + Assert.assertEquals(100, intOption.parseConvert("100")); + + Assert.assertThrows(ConfigException.class, () -> { + intOption.parseConvert("forty-two"); + }, e -> { + Assert.assertContains("Invalid type of value 'forty-two' for option 'typed.int'", + e.getMessage()); + Assert.assertContains("expect 'Integer' type", e.getMessage()); + }); + Assert.assertThrows(ConfigException.class, () -> { + intOption.parseConvert("101"); + }, e -> { + Assert.assertContains("Invalid option value for 'typed.int': 101", e.getMessage()); + }); + Assert.assertThrows(ConfigException.class, () -> intOption.parseConvert("0")); + + ConfigOption longOption = new ConfigOption<>( + "typed.long", "long option", null, 1L); + Assert.assertEquals(Long.MAX_VALUE, longOption.parseConvert("9223372036854775807")); + Assert.assertThrows(ConfigException.class, () -> { + longOption.parseConvert("9223372036854775808"); + }); + + ConfigOption doubleOption = new ConfigOption<>( + "typed.double", "double option", rangeDouble(0D, 1D), 0.5D); + Assert.assertEquals(0.25D, doubleOption.parseConvert("0.25")); + Assert.assertThrows(ConfigException.class, () -> doubleOption.parseConvert("1.01")); + Assert.assertThrows(ConfigException.class, () -> doubleOption.parseConvert("half")); + + ConfigOption boolOption = new ConfigOption<>( + "typed.bool", "bool option", disallowEmpty(), false); + Assert.assertEquals(true, boolOption.parseConvert("true")); + Assert.assertEquals(false, boolOption.parseConvert("false")); + Assert.assertThrows(ConfigException.class, () -> { + boolOption.parseConvert("maybe"); + }, e -> { + Assert.assertContains("expect 'Boolean' type", e.getMessage()); + }); + } + + @Test + public void testParseConvertStringWithChecker() { + ConfigOption option = new ConfigOption<>( + "typed.choice", "choice option", + allowValues("rocksdb", "hstore"), "rocksdb"); + Assert.assertEquals("hstore", option.parseConvert("hstore")); + // Values are matched exactly, no case folding or trimming + Assert.assertThrows(ConfigException.class, () -> { + option.parseConvert("RocksDB"); + }, e -> { + Assert.assertContains("Invalid option value for 'typed.choice': RocksDB", + e.getMessage()); + }); + Assert.assertThrows(ConfigException.class, () -> option.parseConvert(" hstore")); + + ConfigOption nonEmpty = new ConfigOption<>( + "typed.non_empty", "non-empty option", disallowEmpty(), "value"); + Assert.assertEquals(" value ", nonEmpty.parseConvert(" value ")); + Assert.assertThrows(ConfigException.class, () -> nonEmpty.parseConvert("")); + Assert.assertThrows(ConfigException.class, () -> nonEmpty.parseConvert(" ")); + } + + @Test + public void testParseConvertClass() { + ConfigOption option = new ConfigOption<>( + "typed.class", "class option", null, Object.class); + Assert.assertEquals(String.class, option.parseConvert("java.lang.String")); + // Accepts the format produced by Class.toString() + Assert.assertEquals(String.class, option.parseConvert(String.class.toString())); + + Assert.assertThrows(ConfigException.class, () -> { + option.parseConvert("org.apache.hugegraph.NoSuchClass"); + }, e -> { + Assert.assertContains("Failed to parse Class from String " + + "'org.apache.hugegraph.NoSuchClass'", e.getMessage()); + Assert.assertInstanceOf(ClassNotFoundException.class, e.getCause()); + }); + } + + @Test + public void testConstructWithInvalidTypeOrDefault() { + Assert.assertThrows(IllegalArgumentException.class, () -> { + new ConfigOption<>("typed.map", "map option", new HashMap()); + }, e -> { + Assert.assertContains("doesn't belong to acceptable type set", e.getMessage()); + }); + + Assert.assertThrows(NullPointerException.class, () -> { + new ConfigOption<>("typed.null", false, "null default", null, String.class, null); + }, e -> { + Assert.assertContains("'value' of 'typed.null' can't be null", e.getMessage()); + }); + + Assert.assertThrows(NullPointerException.class, () -> { + new ConfigOption<>(null, false, "no name", null, String.class, "v"); + }); + } + + @Test + public void testListOptionParse() { + ConfigListOption option = new ConfigListOption<>( + "typed.list", "int list", disallowEmpty(), 1, 2); + Assert.assertEquals(ImmutableList.of(1, 2), option.defaultValue()); + Assert.assertEquals(ImmutableList.of(3, 4, 5), option.parseConvert("[3, 4 ,5]")); + Assert.assertEquals(ImmutableList.of(7), option.parseConvert("7")); + + Assert.assertThrows(ConfigException.class, () -> { + option.parseConvert("[1, two]"); + }, e -> { + Assert.assertContains("Invalid type of value 'two'", e.getMessage()); + }); + // An empty list literal yields one blank element, which isn't a valid integer + Assert.assertThrows(ConfigException.class, () -> option.parseConvert("[]")); + } + + @Test + public void testListOptionWithInValuesChecker() { + ConfigListOption option = new ConfigListOption<>( + "typed.list_in", "string list", inValues("a", "b", "c"), "a"); + Assert.assertEquals(ImmutableList.of("c", "a"), option.parseConvert("[c, a]")); + Assert.assertThrows(ConfigException.class, () -> { + option.parseConvert("[a, d]"); + }, e -> { + Assert.assertContains("Invalid option value for 'typed.list_in'", e.getMessage()); + }); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + new ConfigListOption("typed.list_empty", false, "empty list", + null, null, Collections.emptyList()); + }, e -> { + Assert.assertContains("Element class can't be null", e.getMessage()); + }); + } + + @Test + public void testConvOptionChecksRawValueBeforeConvert() { + ConfigConvOption option = new ConfigConvOption<>( + "typed.unit", "time unit", allowValues("SECONDS", "MINUTES"), + TimeUnit::valueOf, "SECONDS"); + Assert.assertEquals(TimeUnit.SECONDS, option.defaultValue()); + Assert.assertEquals(TimeUnit.MINUTES, option.parseConvert("MINUTES")); + // HOURS is a valid TimeUnit but is rejected by the raw value checker + Assert.assertThrows(ConfigException.class, () -> option.parseConvert("HOURS")); + + ConfigConvOption unchecked = new ConfigConvOption<>( + "typed.unit_unchecked", "time unit", null, TimeUnit::valueOf, "SECONDS"); + Assert.assertThrows(IllegalArgumentException.class, () -> { + unchecked.parseConvert("WEEKS"); + }); + + Assert.assertThrows(NullPointerException.class, () -> { + new ConfigConvOption("typed.unit_null", "time unit", + null, null, "SECONDS"); + }, e -> { + Assert.assertContains("'convert' can't be null", e.getMessage()); + }); + } + + @Test + public void testListConvOptionParse() { + ConfigListConvOption option = new ConfigListConvOption<>( + "typed.units", "time units", disallowEmpty(), TimeUnit::valueOf, + "SECONDS", "DAYS"); + Assert.assertEquals(ImmutableList.of(TimeUnit.SECONDS, TimeUnit.DAYS), + option.defaultValue()); + Assert.assertEquals(ImmutableList.of(TimeUnit.HOURS, TimeUnit.MINUTES), + option.parseConvert("[HOURS, MINUTES]")); + Assert.assertThrows(IllegalArgumentException.class, () -> { + option.parseConvert("[HOURS, FORTNIGHTS]"); + }); + } + + @Test + public void testOptionCheckerBoundariesAndNulls() { + Predicate range = rangeInt(-1, 1); + Assert.assertTrue(range.apply(-1)); + Assert.assertTrue(range.apply(1)); + Assert.assertFalse(range.apply(-2)); + Assert.assertFalse(range.apply(2)); + Assert.assertFalse(range.apply(null)); + + Predicate doubles = rangeDouble(0.1D, 0.2D); + Assert.assertTrue(doubles.apply(0.1D)); + Assert.assertTrue(doubles.apply(0.2D)); + Assert.assertFalse(doubles.apply(0.20001D)); + Assert.assertFalse(doubles.apply(null)); + + Predicate positive = positiveInt(); + Assert.assertTrue(positive.apply(1L)); + Assert.assertFalse(positive.apply(0L)); + Assert.assertFalse(positive.apply(null)); + + Predicate nonNegative = nonNegativeInt(); + Assert.assertTrue(nonNegative.apply(0L)); + Assert.assertFalse(nonNegative.apply(-1L)); + Assert.assertFalse(nonNegative.apply(null)); + + Predicate allowed = allowValues("x", "y"); + Assert.assertTrue(allowed.apply("y")); + Assert.assertFalse(allowed.apply("z")); + Assert.assertFalse(allowed.apply(null)); + + Predicate> in = inValues("x", "y"); + Assert.assertTrue(in.apply(ImmutableList.of())); + Assert.assertTrue(in.apply(ImmutableList.of("x", "x", "y"))); + Assert.assertFalse(in.apply(ImmutableList.of("x", "z"))); + Assert.assertFalse(in.apply(null)); + } + + @Test + public void testDisallowEmptyChecker() { + Predicate nonEmpty = disallowEmpty(); + Assert.assertFalse(nonEmpty.apply(null)); + Assert.assertFalse(nonEmpty.apply("")); + Assert.assertFalse(nonEmpty.apply(" \t")); + Assert.assertFalse(nonEmpty.apply(new String[0])); + Assert.assertFalse(nonEmpty.apply(new int[0])); + Assert.assertFalse(nonEmpty.apply(Collections.emptyList())); + Assert.assertFalse(nonEmpty.apply(Collections.emptySet())); + + Assert.assertTrue(nonEmpty.apply("a")); + Assert.assertTrue(nonEmpty.apply(new String[]{""})); + Assert.assertTrue(nonEmpty.apply(ImmutableList.of(""))); + Assert.assertTrue(nonEmpty.apply(0)); + Assert.assertTrue(nonEmpty.apply(false)); + } +} diff --git a/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/rest/HostNameVerifierTest.java b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/rest/HostNameVerifierTest.java new file mode 100644 index 0000000000..58af9f9325 --- /dev/null +++ b/hugegraph-commons/hugegraph-common/src/test/java/org/apache/hugegraph/unit/rest/HostNameVerifierTest.java @@ -0,0 +1,128 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit.rest; + +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +import javax.net.ssl.SSLPeerUnverifiedException; +import javax.net.ssl.SSLSession; + +import org.apache.hugegraph.rest.AbstractRestClient; +import org.apache.hugegraph.testutil.Assert; +import org.junit.Test; +import org.mockito.Mockito; + +/** + * Checks the certificate identity rules of {@link AbstractRestClient.HostNameVerifier} + * with in-memory certificates, no TLS handshake involved. + */ +public class HostNameVerifierTest { + + private static final int SAN_DNS = 2; + private static final int SAN_IP = 7; + + @Test + public void testConfiguredUrlDoesNotGrantTrust() throws Exception { + SSLSession session = sessionWithSans(san(SAN_DNS, "other.example")); + // The URL host equals the requested host, the certificate still has to match + Assert.assertFalse(verifier("https://graph.example:8080").verify("graph.example", session)); + Assert.assertFalse(verifier("graph.example").verify("graph.example", session)); + // A URL host ending with the requested host isn't a match either + Assert.assertFalse(verifier("https://evil-graph.example").verify("graph.example", session)); + Assert.assertTrue(verifier("https://graph.example").verify("other.example", session)); + } + + @Test + public void testDnsSubjectAlternativeNames() throws Exception { + SSLSession session = sessionWithSans(san(SAN_DNS, "graph.example"), + san(SAN_DNS, "*.cluster.example")); + AbstractRestClient.HostNameVerifier verifier = verifier("https://graph.example"); + Assert.assertTrue(verifier.verify("graph.example", session)); + Assert.assertTrue(verifier.verify("GRAPH.Example", session)); + Assert.assertTrue(verifier.verify("node1.cluster.example", session)); + + Assert.assertFalse(verifier.verify("sub.graph.example", session)); + Assert.assertFalse(verifier.verify("raph.example", session)); + // A wildcard only covers exactly one left-most label + Assert.assertFalse(verifier.verify("cluster.example", session)); + Assert.assertFalse(verifier.verify("a.node1.cluster.example", session)); + } + + @Test + public void testIpSubjectAlternativeNames() throws Exception { + SSLSession session = sessionWithSans(san(SAN_IP, "127.0.0.1"), + san(SAN_DNS, "localhost")); + AbstractRestClient.HostNameVerifier verifier = verifier("https://127.0.0.1"); + Assert.assertTrue(verifier.verify("127.0.0.1", session)); + Assert.assertTrue(verifier.verify("localhost", session)); + Assert.assertFalse(verifier.verify("127.0.0.2", session)); + + // An IP host never matches a DNS entry with the same text + SSLSession dnsOnly = sessionWithSans(san(SAN_DNS, "127.0.0.1")); + Assert.assertFalse(verifier.verify("127.0.0.1", dnsOnly)); + // A DNS host never matches an IP entry + SSLSession ipOnly = sessionWithSans(san(SAN_IP, "127.0.0.1")); + Assert.assertFalse(verifier.verify("localhost", ipOnly)); + } + + @Test + public void testCertificateWithoutSubjectAlternativeNames() throws Exception { + X509Certificate cert = Mockito.mock(X509Certificate.class); + Mockito.when(cert.getSubjectAlternativeNames()).thenReturn(null); + SSLSession session = sessionWith(cert); + // The common name is not used as an identity fallback + Assert.assertFalse(verifier("https://localhost").verify("localhost", session)); + } + + @Test + public void testUnverifiedPeerOrInvalidHost() throws Exception { + SSLSession unverified = Mockito.mock(SSLSession.class); + Mockito.when(unverified.getPeerCertificates()) + .thenThrow(new SSLPeerUnverifiedException("no peer certificate")); + Assert.assertFalse(verifier("https://localhost").verify("localhost", unverified)); + + SSLSession session = sessionWithSans(san(SAN_DNS, "localhost")); + Assert.assertFalse(verifier("https://localhost").verify("", session)); + Assert.assertFalse(verifier("https://localhost").verify("l\u00f3calhost", session)); + } + + private static AbstractRestClient.HostNameVerifier verifier(String url) { + return new AbstractRestClient.HostNameVerifier(url); + } + + private static List san(int type, String value) { + return Arrays.asList(type, value); + } + + private static SSLSession sessionWithSans(List... sans) throws Exception { + X509Certificate cert = Mockito.mock(X509Certificate.class); + List> names = new ArrayList<>(Arrays.asList(sans)); + Mockito.when(cert.getSubjectAlternativeNames()).thenReturn(names); + return sessionWith(cert); + } + + private static SSLSession sessionWith(X509Certificate cert) throws Exception { + SSLSession session = Mockito.mock(SSLSession.class); + Mockito.when(session.getPeerCertificates()).thenReturn(new Certificate[]{cert}); + return session; + } +} diff --git a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java new file mode 100644 index 0000000000..aa6fd19ff0 --- /dev/null +++ b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java @@ -0,0 +1,246 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with this + * work for additional information regarding copyright ownership. The ASF + * licenses this file to You under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT + * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations + * under the License. + */ + +package org.apache.hugegraph.unit; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import org.apache.hugegraph.config.ConfigException; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.config.RpcOptions; +import org.apache.hugegraph.rpc.RpcClientProvider; +import org.apache.hugegraph.rpc.RpcConsumerConfig; +import org.apache.hugegraph.rpc.RpcProviderConfig; +import org.apache.hugegraph.rpc.RpcServer; +import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.testutil.Whitebox; +import org.apache.hugegraph.unit.ServerClientTest.HelloService; +import org.apache.hugegraph.unit.ServerClientTest.HelloServiceImpl; +import org.junit.Test; + +import com.alipay.sofa.rpc.config.ConsumerConfig; +import com.alipay.sofa.rpc.config.ProviderConfig; + +public class RpcConfigTest extends BaseUnitTest { + + private static final String HELLO = HelloService.class.getName(); + + @Test + public void testExcludeSelfUrl() { + Assert.assertEquals("127.0.0.1:8092,127.0.0.1:8093", + excludeSelfUrl("127.0.0.1:8091,127.0.0.1:8092;127.0.0.1:8093", + "127.0.0.1:8091")); + // Order is kept and duplicates are collapsed + Assert.assertEquals("b:2,a:1", + excludeSelfUrl("b:2,a:1,b:2,self:0", "self:0")); + Assert.assertEquals("a:1", excludeSelfUrl("a:1", "self:0")); + Assert.assertEquals("", excludeSelfUrl("self:0,self:0", "self:0")); + Assert.assertEquals("", excludeSelfUrl("", "self:0")); + } + + @Test + public void testClientDisabledWhenOnlySelfIsRemote() { + Map options = new HashMap<>(); + options.put(RpcOptions.RPC_SERVER_HOST.name(), "127.0.0.1"); + options.put(RpcOptions.RPC_SERVER_PORT.name(), "8091"); + options.put(RpcOptions.RPC_REMOTE_URL.name(), "127.0.0.1:8091"); + RpcClientProvider selfOnly = new RpcClientProvider(new HugeConfig(options)); + Assert.assertFalse(selfOnly.enabled()); + Assert.assertThrows(IllegalArgumentException.class, selfOnly::config, e -> { + Assert.assertContains("add an address other than self service", e.getMessage()); + }); + // Both are no-ops when the client is disabled + selfOnly.unreferAll(); + selfOnly.destroy(); + + options.put(RpcOptions.RPC_REMOTE_URL.name(), "127.0.0.1:8091,127.0.0.1:8092"); + RpcClientProvider withPeer = new RpcClientProvider(new HugeConfig(options)); + try { + Assert.assertTrue(withPeer.enabled()); + Assert.assertEquals("127.0.0.1:8092", + Whitebox.getInternalState(withPeer.config(), "remoteUrls")); + } finally { + withPeer.destroy(); + } + } + + @Test + public void testRpcOptionsRejectInvalidValues() { + assertInvalidOption(RpcOptions.RPC_PROTOCOL.name(), "grpc"); + assertInvalidOption(RpcOptions.RPC_SERIALIZATION.name(), "json"); + assertInvalidOption(RpcOptions.RPC_CLIENT_LOAD_BALANCER.name(), "leastActive"); + assertInvalidOption(RpcOptions.RPC_SERVER_PORT.name(), "-1"); + assertInvalidOption(RpcOptions.RPC_SERVER_TIMEOUT.name(), "0"); + assertInvalidOption(RpcOptions.RPC_CLIENT_RETRIES.name(), "-1"); + assertInvalidOption(RpcOptions.RPC_CLIENT_READ_TIMEOUT.name(), "ten"); + assertInvalidOption(RpcOptions.RPC_LOGGER_IMPL.name(), " "); + + Map options = new HashMap<>(); + options.put(RpcOptions.RPC_SERVER_PORT.name(), "0"); + options.put(RpcOptions.RPC_CLIENT_RETRIES.name(), "0"); + options.put(RpcOptions.RPC_CLIENT_LOAD_BALANCER.name(), "roundRobin"); + HugeConfig config = new HugeConfig(options); + Assert.assertEquals(0, config.get(RpcOptions.RPC_SERVER_PORT)); + Assert.assertEquals(0, config.get(RpcOptions.RPC_CLIENT_RETRIES)); + Assert.assertEquals("roundRobin", config.get(RpcOptions.RPC_CLIENT_LOAD_BALANCER)); + } + + @Test + public void testProviderServiceIds() { + RpcProviderConfig provider = new RpcProviderConfig(); + String plain = provider.addService(HelloService.class, new HelloServiceImpl()); + String graph1 = provider.addService("g1", HelloService.class, new HelloServiceImpl()); + String graph2 = provider.addService("g2", HelloService.class, new HelloServiceImpl()); + Assert.assertEquals(HELLO, plain); + Assert.assertEquals(HELLO + ":g1", graph1); + Assert.assertEquals(HELLO + ":g2", graph2); + Assert.assertEquals(3, provider.configs().size()); + + ProviderConfig config = provider.configs().get(graph1); + Assert.assertEquals(graph1, config.getId()); + Assert.assertEquals("g1", config.getUniqueId()); + Assert.assertEquals(HELLO, config.getInterfaceId()); + Assert.assertEquals("", provider.configs().get(plain).getUniqueId()); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + provider.addService("g1", HelloService.class, new HelloServiceImpl()); + }, e -> { + Assert.assertContains("Not allowed to add service already exist: '" + + graph1 + "'", e.getMessage()); + }); + + provider.removeService(graph1); + Assert.assertFalse(provider.configs().containsKey(graph1)); + Assert.assertThrows(IllegalArgumentException.class, () -> { + provider.removeService(graph1); + }, e -> { + Assert.assertContains("doesn't exist", e.getMessage()); + }); + // A removed id can be registered again + Assert.assertEquals(graph1, provider.addService("g1", HelloService.class, + new HelloServiceImpl())); + + provider.removeAllService(); + Assert.assertTrue(provider.configs().isEmpty()); + } + + @Test + public void testConsumerConfigFromOptions() { + Map options = new HashMap<>(); + options.put(RpcOptions.RPC_CLIENT_READ_TIMEOUT.name(), "7"); + options.put(RpcOptions.RPC_CLIENT_CONNECT_TIMEOUT.name(), "3"); + options.put(RpcOptions.RPC_CLIENT_RECONNECT_PERIOD.name(), "5"); + options.put(RpcOptions.RPC_CLIENT_RETRIES.name(), "2"); + options.put(RpcOptions.RPC_CLIENT_LOAD_BALANCER.name(), "random"); + String remote = "127.0.0.1:8092,127.0.0.1:8093"; + RpcConsumerConfig consumer = new RpcConsumerConfig(new HugeConfig(options), remote); + + ConsumerConfig plain = consumerConfig(consumer, null, HELLO); + Assert.assertEquals(HELLO, plain.getInterfaceId()); + Assert.assertEquals("bolt", plain.getProtocol()); + Assert.assertEquals("hessian2", plain.getSerialization()); + Assert.assertEquals(remote, plain.getDirectUrl()); + Assert.assertEquals(7000, plain.getTimeout()); + Assert.assertEquals(3000, plain.getConnectTimeout()); + Assert.assertEquals(5000, plain.getReconnectPeriod()); + Assert.assertEquals(2, plain.getRetries()); + Assert.assertEquals("random", plain.getLoadBalancer()); + Assert.assertEquals("", plain.getUniqueId()); + Assert.assertNotEquals("fanout", plain.getCluster()); + + // Graph scoped consumers broadcast to every provider of that graph + ConsumerConfig graph = consumerConfig(consumer, "g1", HELLO); + Assert.assertEquals(HELLO + ":g1", graph.getId()); + Assert.assertEquals("g1", graph.getUniqueId()); + Assert.assertEquals("fanout", graph.getCluster()); + + // Configs are cached per service id + Assert.assertSame(plain, consumerConfig(consumer, null, HELLO)); + Assert.assertSame(graph, consumerConfig(consumer, "g1", HELLO)); + Assert.assertNotSame(graph, consumerConfig(consumer, "g2", HELLO)); + consumer.destroy(); + } + + @Test + public void testServerDestroyClosesServices() { + RpcServer server = new RpcServer(config("server-random")); + Assert.assertTrue(server.enabled()); + // Host and port are only known once the server is bound + Assert.assertNull(server.host()); + Assert.assertEquals(0, server.port()); + + CloseableHelloService closeable = new CloseableHelloService(false); + CloseableHelloService failing = new CloseableHelloService(true); + server.config().addService(HelloService.class, closeable); + server.config().addService("g1", HelloService.class, failing); + try { + server.exportAll(); + Assert.assertEquals("127.0.0.1", server.host()); + Assert.assertGt(0, server.port()); + } finally { + server.destroy(); + } + + Assert.assertEquals(1, closeable.closed.get()); + // A failure while closing one service doesn't stop the shutdown + Assert.assertEquals(1, failing.closed.get()); + Assert.assertTrue(server.config().configs().isEmpty()); + } + + private static String excludeSelfUrl(String rpcUrl, String selfUrl) { + return Whitebox.invokeStatic(RpcClientProvider.class, "excludeSelfUrl", + rpcUrl, selfUrl); + } + + private static ConsumerConfig consumerConfig(RpcConsumerConfig consumer, + String graph, String interfaceId) { + return Whitebox.invoke(RpcConsumerConfig.class, + new Class[]{String.class, String.class}, + "consumerConfig", consumer, graph, interfaceId); + } + + private static void assertInvalidOption(String key, String value) { + Map options = new HashMap<>(); + options.put(key, value); + Assert.assertThrows(ConfigException.class, () -> { + new HugeConfig(options); + }, e -> { + Assert.assertContains(key, e.getMessage()); + }); + } + + public static class CloseableHelloService extends HelloServiceImpl + implements AutoCloseable { + + private final boolean failOnClose; + private final AtomicInteger closed = new AtomicInteger(); + + public CloseableHelloService(boolean failOnClose) { + this.failOnClose = failOnClose; + } + + @Override + public void close() { + this.closed.incrementAndGet(); + if (this.failOnClose) { + throw new IllegalStateException("close failure for test"); + } + } + } +} diff --git a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java index a19b5522c8..c006ab13bf 100644 --- a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -24,7 +24,8 @@ @Suite.SuiteClasses({ VersionTest.class, ExceptionTest.class, - ServerClientTest.class + ServerClientTest.class, + RpcConfigTest.class }) public class UnitTestSuite { } From 1ebef29bec19bf5ec4bf01fa7fee29614548588e Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 11 Oct 2026 01:29:43 +0530 Subject: [PATCH 03/15] test(server): cover RocksDB session lifecycle and HStore routing Add unit tests for RocksDBStdSessions lifecycle and error paths (unopened tables, rollback, reopen, lock release on force close and bad options, iterator double close, snapshot path), the RocksDB iterator pool, and HStore table partition routing. Register them in UnitTestSuite together with the existing ConsumersTest, MemoryAllocateTest and MemoryManageTest, which compiled but never ran. --- .../rocksdb/RocksDBIteratorPoolTest.java | 140 ++++++++ .../apache/hugegraph/unit/UnitTestSuite.java | 14 + .../unit/core/HstoreTableRoutingTest.java | 187 ++++++++++ .../RocksDBStdSessionsLifecycleTest.java | 336 ++++++++++++++++++ 4 files changed, 677 insertions(+) create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/HstoreTableRoutingTest.java create mode 100644 hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java new file mode 100644 index 0000000000..722bccbc76 --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java @@ -0,0 +1,140 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.backend.store.rocksdb; + +import java.nio.charset.StandardCharsets; + +import org.apache.hugegraph.backend.store.rocksdb.RocksDBIteratorPool.ReusedRocksIterator; +import org.apache.hugegraph.testutil.Assert; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.rocksdb.ColumnFamilyDescriptor; +import org.rocksdb.ColumnFamilyHandle; +import org.rocksdb.Options; +import org.rocksdb.RocksDB; +import org.rocksdb.RocksIterator; + +public class RocksDBIteratorPoolTest { + + private static final String CF = "iter-pool-cf"; + + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + private Options options; + private RocksDB rocksdb; + private ColumnFamilyHandle cfh; + private RocksDBIteratorPool pool; + + @Before + public void setup() throws Exception { + this.options = new Options().setCreateIfMissing(true); + this.rocksdb = RocksDB.open(this.options, this.temporary.newFolder().getAbsolutePath()); + this.cfh = this.rocksdb.createColumnFamily(new ColumnFamilyDescriptor(bytes(CF))); + this.rocksdb.put(this.cfh, bytes("k1"), bytes("v1")); + this.rocksdb.put(this.cfh, bytes("k2"), bytes("v2")); + this.pool = new RocksDBIteratorPool(this.rocksdb, this.cfh); + } + + @After + public void teardown() { + // Native objects must be released child-first: pool, CF, DB, options + try { + this.pool.close(); + } finally { + this.cfh.close(); + this.rocksdb.close(); + this.options.close(); + } + } + + @Test + public void testIteratorCloseReleasesNativeHandleAndIsIdempotent() { + ReusedRocksIterator reused = this.pool.newIterator(); + RocksIterator iter = reused.iterator(); + Assert.assertTrue(iter.isOwningHandle()); + + iter.seekToFirst(); + Assert.assertTrue(iter.isValid()); + Assert.assertEquals("k1", string(iter.key())); + Assert.assertEquals("v1", string(iter.value())); + + reused.close(); + Assert.assertFalse(iter.isOwningHandle()); + // A second close must not double-free the native iterator + reused.close(); + Assert.assertFalse(iter.isOwningHandle()); + } + + @Test + public void testIteratorsAreNotSharedBetweenCallers() { + ReusedRocksIterator first = this.pool.newIterator(); + ReusedRocksIterator second = this.pool.newIterator(); + try { + Assert.assertNotSame(first.iterator(), second.iterator()); + + first.iterator().seekToFirst(); + second.iterator().seekToLast(); + Assert.assertEquals("k1", string(first.iterator().key())); + Assert.assertEquals("k2", string(second.iterator().key())); + + // Closing one caller's iterator leaves the other usable + first.close(); + second.iterator().prev(); + Assert.assertTrue(second.iterator().isValid()); + Assert.assertEquals("k1", string(second.iterator().key())); + } finally { + first.close(); + second.close(); + } + } + + @Test + public void testPoolCloseDoesNotInvalidateOutstandingIterators() throws Exception { + Assert.assertEquals("IteratorPool-" + CF, this.pool.toString()); + + ReusedRocksIterator reused = this.pool.newIterator(); + try { + // Outstanding iterators are owned by their callers, not the pool + this.pool.close(); + this.pool.close(); + reused.iterator().seek(bytes("k2")); + Assert.assertTrue(reused.iterator().isValid()); + Assert.assertEquals("v2", string(reused.iterator().value())); + + // The pool can still hand out new iterators after close() + ReusedRocksIterator another = this.pool.newIterator(); + another.iterator().seekToFirst(); + Assert.assertEquals("k1", string(another.iterator().key())); + another.close(); + } finally { + reused.close(); + } + } + + private static byte[] bytes(String str) { + return str.getBytes(StandardCharsets.UTF_8); + } + + private static String string(byte[] bytes) { + return new String(bytes, StandardCharsets.UTF_8); + } +} diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index dd456d0e58..a8820822f7 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -27,9 +27,12 @@ import org.apache.hugegraph.auth.StandardAuthManagerV2Test; import org.apache.hugegraph.auth.WsAndHttpBasicAuthHandlerTest; import org.apache.hugegraph.backend.page.QueryListTest; +import org.apache.hugegraph.backend.store.rocksdb.RocksDBIteratorPoolTest; import org.apache.hugegraph.backend.store.rocksdb.RocksDBSnapshotRestoreTest; import org.apache.hugegraph.backend.tx.GraphIndexTransactionTest; import org.apache.hugegraph.backend.tx.GraphTransactionTest; +import org.apache.hugegraph.core.memory.MemoryAllocateTest; +import org.apache.hugegraph.core.memory.MemoryManageTest; import org.apache.hugegraph.meta.EtcdMetaDriverTest; import org.apache.hugegraph.meta.MetaManagerSchemaCacheClearEventTest; import org.apache.hugegraph.meta.managers.AuthMetaManagerTest; @@ -73,6 +76,7 @@ import org.apache.hugegraph.unit.core.GroovyScriptEngineCompatibilityTest; import org.apache.hugegraph.unit.core.HugeFeaturesTest; import org.apache.hugegraph.unit.core.HstoreSessionsTest; +import org.apache.hugegraph.unit.core.HstoreTableRoutingTest; import org.apache.hugegraph.unit.core.HugeElementViewTest; import org.apache.hugegraph.unit.core.HugePrimaryKeyTest; import org.apache.hugegraph.unit.core.IdHolderTest; @@ -99,6 +103,7 @@ import org.apache.hugegraph.unit.rocksdb.RocksDBCountersTest; import org.apache.hugegraph.unit.rocksdb.RocksDBSessionTest; import org.apache.hugegraph.unit.rocksdb.RocksDBSessionsTest; +import org.apache.hugegraph.unit.rocksdb.RocksDBStdSessionsLifecycleTest; import org.apache.hugegraph.unit.rocksdb.RocksDBTableQueryByIdsTest; import org.apache.hugegraph.unit.serializer.BinaryBackendEntryTest; import org.apache.hugegraph.unit.serializer.BinaryScatterSerializerTest; @@ -113,6 +118,7 @@ import org.apache.hugegraph.unit.store.RamIntObjectMapTest; import org.apache.hugegraph.unit.traversal.ShortestPathTraverserTest; import org.apache.hugegraph.unit.util.CompressUtilTest; +import org.apache.hugegraph.unit.util.ConsumersTest; import org.apache.hugegraph.unit.util.JsonUtilTest; import org.apache.hugegraph.unit.util.RateLimiterTest; import org.apache.hugegraph.unit.util.StringEncodingTest; @@ -204,6 +210,7 @@ GraphManagerAdminInitTest.class, GraphManagerConfigTest.class, HstoreSessionsTest.class, + HstoreTableRoutingTest.class, BackendStoreInfoTest.class, TraversalUtilTest.class, TraversalUtilOptimizeTest.class, @@ -244,12 +251,19 @@ RocksDBSessionTest.class, RocksDBCountersTest.class, RocksDBTableQueryByIdsTest.class, + RocksDBStdSessionsLifecycleTest.class, + RocksDBIteratorPoolTest.class, + + /* memory */ + MemoryAllocateTest.class, + MemoryManageTest.class, /* utils */ VersionTest.class, JsonUtilTest.class, StringEncodingTest.class, CompressUtilTest.class, + ConsumersTest.class, RateLimiterTest.FixedTimerWindowRateLimiterTest.class, RateLimiterTest.FixedWatchWindowRateLimiterTest.class, diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/HstoreTableRoutingTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/HstoreTableRoutingTest.java new file mode 100644 index 0000000000..c2fc108638 --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/core/HstoreTableRoutingTest.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.unit.core; + +import org.apache.hugegraph.backend.BinaryId; +import org.apache.hugegraph.backend.serializer.BinaryBackendEntry; +import org.apache.hugegraph.backend.store.hstore.HstoreTable; +import org.apache.hugegraph.exception.NotSupportException; +import org.apache.hugegraph.id.EdgeId; +import org.apache.hugegraph.id.Id; +import org.apache.hugegraph.id.IdGenerator; +import org.apache.hugegraph.query.Condition; +import org.apache.hugegraph.query.ConditionQuery; +import org.apache.hugegraph.store.client.util.HgStoreClientConst; +import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.type.HugeType; +import org.apache.hugegraph.type.define.Directions; +import org.apache.hugegraph.type.define.HugeKeys; +import org.junit.Test; + +/** + * Owner routing and argument validation of {@link HstoreTable} that can be + * exercised without a PD/Store cluster. The store client compares the + * all-partition owner by reference, so those cases use assertSame. + */ +public class HstoreTableRoutingTest { + + private static final Id MARKO = IdGenerator.of("1:marko"); + private static final Id JOSH = IdGenerator.of("1:josh"); + + private final RoutingTable table = new RoutingTable("Hugegraph", "G+V"); + + @Test + public void testTableNameCombinesDatabaseAndTableInLowerCase() { + Assert.assertEquals("hugegraph+g+v", this.table.table()); + } + + @Test + public void testOwnerOfVertexAndEdgeIds() { + Assert.assertArrayEquals(MARKO.asBytes(), this.table.ownerOf(MARKO)); + + // Both edge directions are routed to the vertex that owns the edge + Assert.assertArrayEquals(MARKO.asBytes(), this.table.ownerOf(edge(MARKO, JOSH, + Directions.OUT))); + Assert.assertArrayEquals(JOSH.asBytes(), this.table.ownerOf(edge(JOSH, MARKO, + Directions.IN))); + + // Serialized ids are unwrapped to their origin before routing + BinaryId binaryEdge = new BinaryId(new byte[]{1, 2, 3}, + edge(MARKO, JOSH, Directions.OUT)); + Assert.assertArrayEquals(MARKO.asBytes(), this.table.ownerOf(binaryEdge)); + + Assert.assertSame(HgStoreClientConst.ALL_PARTITION_OWNER, this.table.ownerOf(null)); + Assert.assertSame(HgStoreClientConst.ALL_PARTITION_OWNER, + this.table.ownerOf(new BinaryId(new byte[]{1}, null))); + } + + @Test + public void testOwnerByTypeRoutesOnlyGraphDataToSinglePartition() { + Id edgeId = edge(MARKO, JOSH, Directions.OUT); + Assert.assertArrayEquals(MARKO.asBytes(), this.table.ownerOf(HugeType.VERTEX, MARKO)); + Assert.assertArrayEquals(MARKO.asBytes(), this.table.ownerOf(HugeType.EDGE, edgeId)); + Assert.assertArrayEquals(MARKO.asBytes(), this.table.ownerOf(HugeType.EDGE_OUT, edgeId)); + Assert.assertArrayEquals(MARKO.asBytes(), this.table.ownerOf(HugeType.EDGE_IN, edgeId)); + Assert.assertArrayEquals(MARKO.asBytes(), this.table.ownerOf(HugeType.COUNTER, MARKO)); + + // Schema, index and system types must fan out to every partition + HugeType[] fanOut = {HugeType.VERTEX_LABEL, HugeType.PROPERTY_KEY, + HugeType.SECONDARY_INDEX, HugeType.VERTEX_LABEL_INDEX, + HugeType.RANGE_INT_INDEX, HugeType.TASK, HugeType.OLAP}; + for (HugeType type : fanOut) { + Assert.assertSame(type.name(), HgStoreClientConst.ALL_PARTITION_OWNER, + this.table.ownerOf(type, MARKO)); + } + } + + @Test + public void testInsertOwnerOfEntries() { + BinaryBackendEntry vertex = new BinaryBackendEntry(HugeType.VERTEX, + new BinaryId(new byte[]{9}, MARKO)); + Assert.assertArrayEquals(MARKO.asBytes(), this.table.getInsertOwner(vertex)); + + BinaryId edgeId = new BinaryId(new byte[]{8}, edge(JOSH, MARKO, Directions.IN)); + BinaryBackendEntry edge = new BinaryBackendEntry(HugeType.EDGE_IN, edgeId); + Assert.assertArrayEquals(JOSH.asBytes(), this.table.getInsertOwner(edge)); + Assert.assertArrayEquals(JOSH.asBytes(), this.table.getInsertEdgeOwner(edge)); + + // A label index entry with exactly one column is hashed by that column + Id indexId = IdGenerator.of("label-index"); + BinaryBackendEntry labelIndex = new BinaryBackendEntry(HugeType.VERTEX_LABEL_INDEX, + new BinaryId(new byte[]{7}, + indexId)); + labelIndex.column(new byte[]{1, 1}, new byte[0]); + Assert.assertArrayEquals(new byte[]{1, 1}, this.table.getInsertOwner(labelIndex)); + + // With more columns it falls back to the index id itself + labelIndex.column(new byte[]{2, 2}, new byte[0]); + Assert.assertArrayEquals(indexId.asBytes(), this.table.getInsertOwner(labelIndex)); + } + + @Test + public void testRemoveDirectionCondition() { + ConditionQuery onlyDirection = new ConditionQuery(HugeType.EDGE); + onlyDirection.eq(HugeKeys.DIRECTION, Directions.OUT); + Assert.assertNull(HstoreTable.removeDirectionCondition(onlyDirection)); + + ConditionQuery mixed = new ConditionQuery(HugeType.EDGE); + mixed.eq(HugeKeys.OWNER_VERTEX, MARKO); + mixed.eq(HugeKeys.DIRECTION, Directions.OUT); + ConditionQuery result = HstoreTable.removeDirectionCondition(mixed); + Assert.assertSame(mixed, result); + Assert.assertEquals(1, result.conditionsSize()); + Assert.assertEquals(MARKO, result.condition(HugeKeys.OWNER_VERTEX)); + Assert.assertNull(result.condition(HugeKeys.DIRECTION)); + + // A compound condition that also constrains another key is kept whole + ConditionQuery compound = new ConditionQuery(HugeType.EDGE); + compound.query(Condition.and(Condition.eq(HugeKeys.DIRECTION, Directions.IN), + Condition.eq(HugeKeys.LABEL, IdGenerator.of(1)))); + Assert.assertSame(compound, HstoreTable.removeDirectionCondition(compound)); + Assert.assertEquals(1, compound.conditionsSize()); + } + + @Test + public void testBytes2StringIsZeroPaddedLowerCaseHex() { + Assert.assertEquals("", HstoreTable.bytes2String(new byte[0])); + Assert.assertEquals("000f7f80ff", + HstoreTable.bytes2String(new byte[]{0x00, 0x0f, 0x7f, + (byte) 0x80, (byte) 0xff})); + } + + @Test + public void testSplitsMetadataValidatesArgumentsBeforeContactingPd() { + Assert.assertThrows(IllegalArgumentException.class, () -> { + this.table.metaDispatcher().dispatchMetaHandler(null, "splits", new Object[0]); + }, e -> { + Assert.assertContains("args count of splits must be 1", e.getMessage()); + }); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + this.table.metaDispatcher().dispatchMetaHandler(null, "splits", + new Object[]{1024L}); + }, e -> { + Assert.assertContains("split-size must be >= 1048576 bytes, but got 1024", + e.getMessage()); + }); + + Assert.assertThrows(NotSupportException.class, () -> { + this.table.metaDispatcher().dispatchMetaHandler(null, "unknown-meta", + new Object[0]); + }); + } + + private static EdgeId edge(Id owner, Id other, Directions direction) { + return new EdgeId(owner, direction, IdGenerator.of(1), IdGenerator.of(1), "", other); + } + + private static class RoutingTable extends HstoreTable { + + RoutingTable(String database, String table) { + super(database, table); + } + + byte[] ownerOf(Id id) { + return this.getOwnerId(id); + } + + byte[] ownerOf(HugeType type, Id id) { + return this.getOwnerId(type, id); + } + } +} diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java new file mode 100644 index 0000000000..a852924e0b --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java @@ -0,0 +1,336 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.unit.rocksdb; + +import java.io.File; +import java.util.ArrayList; +import java.util.List; +import java.util.NoSuchElementException; + +import org.apache.commons.configuration2.PropertiesConfiguration; +import org.apache.hugegraph.backend.store.BackendEntry.BackendColumnIterator; +import org.apache.hugegraph.backend.store.rocksdb.RocksDBOptions; +import org.apache.hugegraph.backend.store.rocksdb.RocksDBSessions; +import org.apache.hugegraph.backend.store.rocksdb.RocksDBSessions.Session; +import org.apache.hugegraph.backend.store.rocksdb.RocksDBStdSessions; +import org.apache.hugegraph.config.HugeConfig; +import org.apache.hugegraph.config.OptionSpace; +import org.apache.hugegraph.exception.BackendException; +import org.apache.hugegraph.testutil.Assert; +import org.apache.hugegraph.unit.BaseUnitTest; +import org.apache.hugegraph.unit.FakeObjects; +import org.junit.After; +import org.junit.BeforeClass; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.rocksdb.RocksDBException; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; + +/** + * Lifecycle and negative-path tests for {@link RocksDBStdSessions}. Every test + * uses its own temporary directory so it never shares the fixed path used by + * {@link BaseRocksDBUnitTest}. + */ +public class RocksDBStdSessionsLifecycleTest extends BaseUnitTest { + + private static final String TABLE = "lifecycle-table"; + + @Rule + public TemporaryFolder temporary = new TemporaryFolder(); + + private final List opened = new ArrayList<>(); + + @BeforeClass + public static void registerRocksDBOptions() { + // Typed values in HugeConfig require the RocksDB options to be known + if (!OptionSpace.containKey(RocksDBOptions.NUM_LEVELS.name())) { + OptionSpace.register("rocksdb", RocksDBOptions.class.getName()); + } + } + + @After + public void closeOpenedSessions() { + for (RocksDBSessions sessions : this.opened) { + try { + // Closes this thread's session and, when it is the last one, the DB + sessions.close(); + } finally { + if (sessions.databaseOpened()) { + sessions.forceCloseRocksDB(); + } + } + } + this.opened.clear(); + } + + @Test + public void testOperationsOnUnopenedTableAreRejected() throws Exception { + RocksDBSessions sessions = this.open(this.newPath("unopened")); + Session session = sessions.session(); + byte[] key = getBytes("k"); + + Assert.assertThrows(BackendException.class, () -> { + session.put("missing", key, getBytes("v")); + }, e -> { + Assert.assertContains("Table 'missing' is not opened", e.getMessage()); + }); + Assert.assertFalse(session.hasChanges()); + + Assert.assertThrows(BackendException.class, () -> { + session.get("missing", key); + }, e -> { + Assert.assertContains("Table 'missing' is not opened", e.getMessage()); + }); + Assert.assertThrows(BackendException.class, () -> { + session.scan("missing"); + }, e -> { + Assert.assertContains("Table 'missing' is not opened", e.getMessage()); + }); + Assert.assertThrows(BackendException.class, () -> { + session.delete("missing", key); + }, e -> { + Assert.assertContains("Table 'missing' is not opened", e.getMessage()); + }); + Assert.assertThrows(BackendException.class, () -> { + session.keyRange("missing"); + }, e -> { + Assert.assertContains("Table 'missing' is not opened", e.getMessage()); + }); + + // The opened table is still fully usable after the rejected calls + session.put(TABLE, key, getBytes("v")); + Assert.assertEquals(1, (int) session.commit()); + Assert.assertEquals("v", getString(session.get(TABLE, key))); + } + + @Test + public void testCreateTableTwiceKeepsDataAndDropMissingTableIsNoop() throws Exception { + RocksDBSessions sessions = this.open(this.newPath("idempotent")); + Session session = sessions.session(); + session.put(TABLE, getBytes("k"), getBytes("v")); + session.commit(); + + // Re-creating an existing table must not truncate or replace it + sessions.createTable(TABLE); + Assert.assertEquals("v", getString(session.get(TABLE, getBytes("k")))); + + // Dropping a table that never existed leaves the opened tables intact + sessions.dropTable("never-created"); + Assert.assertTrue(sessions.existsTable(TABLE)); + Assert.assertFalse(sessions.existsTable("never-created")); + + sessions.dropTable(TABLE); + Assert.assertFalse(sessions.existsTable(TABLE)); + // Dropping again after the table is gone is still a no-op + sessions.dropTable(TABLE); + Assert.assertFalse(sessions.openedTables().contains(TABLE)); + } + + @Test + public void testRollbackDiscardsPendingWrites() throws Exception { + RocksDBSessions sessions = this.open(this.newPath("rollback")); + Session session = sessions.session(); + + Assert.assertEquals(0, (int) session.commit()); + session.put(TABLE, getBytes("k2"), getBytes("v2")); + Assert.assertEquals(1, (int) session.commit()); + + session.put(TABLE, getBytes("k1"), getBytes("v1")); + session.delete(TABLE, getBytes("k2")); + Assert.assertTrue(session.hasChanges()); + + session.rollback(); + Assert.assertFalse(session.hasChanges()); + Assert.assertEquals(0, (int) session.commit()); + // Neither the pending put nor the pending delete reached the DB + Assert.assertNull(session.get(TABLE, getBytes("k1"))); + Assert.assertEquals("v2", getString(session.get(TABLE, getBytes("k2")))); + } + + @Test + public void testCommittedDataSurvivesReopenButPendingWritesDoNot() throws Exception { + String path = this.newPath("reopen"); + RocksDBSessions sessions = this.open(path); + Session session = sessions.session(); + session.put(TABLE, getBytes("committed"), getBytes("v1")); + session.commit(); + session.put(TABLE, getBytes("pending"), getBytes("v2")); + Assert.assertTrue(session.hasChanges()); + + Assert.assertTrue(sessions.close()); + Assert.assertFalse(sessions.databaseOpened()); + + // Reopen without createTable(): existing column families are reloaded + RocksDBSessions reopened = this.openWithoutTable(path); + Assert.assertTrue(reopened.existsTable(TABLE)); + Session session2 = reopened.session(); + Assert.assertFalse(session2.hasChanges()); + Assert.assertEquals("v1", getString(session2.get(TABLE, getBytes("committed")))); + Assert.assertNull(session2.get(TABLE, getBytes("pending"))); + } + + @Test + public void testLiveDatabaseCannotBeOpenedTwiceUntilClosed() throws Exception { + String path = this.newPath("exclusive"); + RocksDBSessions sessions = this.open(path); + sessions.session().put(TABLE, getBytes("k"), getBytes("v")); + sessions.session().commit(); + + Assert.assertThrows(RocksDBException.class, () -> { + this.openWithoutTable(path); + }, e -> { + Assert.assertContains("lock", e.getMessage()); + }); + // The failed open must not disturb the live instance + Assert.assertTrue(sessions.databaseOpened()); + Assert.assertEquals("v", getString(sessions.session().get(TABLE, getBytes("k")))); + + Assert.assertTrue(sessions.close()); + RocksDBSessions reopened = this.openWithoutTable(path); + Assert.assertEquals("v", getString(reopened.session().get(TABLE, getBytes("k")))); + } + + @Test + public void testClosedDatabaseRejectsTableAndSessionAccess() throws Exception { + String path = this.newPath("closed"); + RocksDBSessions sessions = this.open(path); + sessions.forceCloseRocksDB(); + Assert.assertFalse(sessions.databaseOpened()); + + Assert.assertThrows(IllegalStateException.class, () -> { + sessions.createTable("another-table"); + }, e -> { + Assert.assertContains("RocksDB has been closed", e.getMessage()); + }); + Assert.assertThrows(IllegalStateException.class, () -> { + sessions.dropTable(TABLE); + }, e -> { + Assert.assertContains("RocksDB has been closed", e.getMessage()); + }); + Assert.assertThrows(IllegalStateException.class, sessions::compactRange, e -> { + Assert.assertContains("RocksDB has been closed", e.getMessage()); + }); + Assert.assertThrows(IllegalStateException.class, sessions::session, e -> { + Assert.assertContains("session pool is closed", e.getMessage()); + }); + + // Force close must release the database lock for a later owner + RocksDBSessions reopened = this.openWithoutTable(path); + Assert.assertTrue(reopened.databaseOpened()); + Assert.assertEquals(ImmutableSet.of("default", TABLE), reopened.openedTables()); + } + + @Test + public void testScanIteratorCloseIsIdempotentAndStopsIteration() throws Exception { + RocksDBSessions sessions = this.open(this.newPath("scan-close")); + Session session = sessions.session(); + session.put(TABLE, getBytes("a1"), getBytes("1")); + session.put(TABLE, getBytes("a2"), getBytes("2")); + session.put(TABLE, getBytes("b1"), getBytes("3")); + session.commit(); + + BackendColumnIterator iter = session.scan(TABLE, getBytes("a")); + Assert.assertTrue(iter.hasNext()); + Assert.assertEquals("a1", getString(iter.next().name)); + Assert.assertEquals("a1", getString(iter.position())); + + // Closing early releases the native iterator; further reads end cleanly + iter.close(); + iter.close(); + Assert.assertFalse(iter.hasNext()); + Assert.assertThrows(NoSuchElementException.class, iter::next); + + // A fully consumed iterator clears its paging position + BackendColumnIterator all = session.scan(TABLE, getBytes("a")); + Assert.assertEquals("a1", getString(all.next().name)); + Assert.assertEquals("a2", getString(all.next().name)); + Assert.assertFalse(all.hasNext()); + Assert.assertNull(all.position()); + all.close(); + } + + @Test + public void testMismatchedLevelCompressionsRejectedAndLockReleased() throws Exception { + String path = this.newPath("bad-options"); + PropertiesConfiguration conf = new PropertiesConfiguration(); + conf.setProperty(RocksDBOptions.NUM_LEVELS.name(), "3"); + conf.setProperty(RocksDBOptions.LEVELS_COMPRESSIONS.name(), "[none, snappy]"); + HugeConfig badConfig = new HugeConfig(conf); + + Assert.assertThrows(IllegalArgumentException.class, () -> { + new RocksDBStdSessions(badConfig, "db", "store", path, path); + }, e -> { + Assert.assertContains(RocksDBOptions.LEVELS_COMPRESSIONS.name(), e.getMessage()); + Assert.assertContains("but got 2 != 3", e.getMessage()); + }); + + // The failed open must not leave the database lock behind + RocksDBSessions sessions = this.open(path); + Assert.assertTrue(sessions.databaseOpened()); + } + + @Test + public void testBuildSnapshotPathRequiresExistingSnapshot() throws Exception { + File parent = this.temporary.newFolder("graph"); + File dataDir = new File(parent, "rocksdb-data/g"); + Assert.assertTrue(dataDir.getParentFile().mkdirs()); + String dataPath = dataDir.getAbsolutePath(); + RocksDBSessions sessions = this.open(dataPath); + + File expected = new File(parent, "snapshot_rocksdb-data/g"); + Assert.assertThrows(IllegalArgumentException.class, () -> { + sessions.buildSnapshotPath("snapshot"); + }, e -> { + Assert.assertContains("doesn't exist", e.getMessage()); + Assert.assertContains(expected.getPath(), e.getMessage()); + }); + + Assert.assertTrue(expected.mkdirs()); + Assert.assertEquals(expected.getPath(), sessions.buildSnapshotPath("snapshot")); + } + + private String newPath(String name) throws Exception { + return new File(this.temporary.newFolder(name), "db").getAbsolutePath(); + } + + private RocksDBSessions open(String path) throws RocksDBException { + RocksDBSessions sessions = this.openWithoutTable(path); + sessions.createTable(TABLE); + return sessions; + } + + private RocksDBSessions openWithoutTable(String path) throws RocksDBException { + // Passing no CF names still reopens every column family found on disk + RocksDBSessions sessions = new RocksDBStdSessions(FakeObjects.newConfig(), "db", + "store", path, path, + ImmutableList.of()); + this.opened.add(sessions); + return sessions; + } + + private static byte[] getBytes(String str) { + return str.getBytes(); + } + + private static String getString(byte[] bytes) { + return bytes == null ? null : new String(bytes); + } +} From fb28f9d083386375fee359e3d889dd226a935b07 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 11 Oct 2026 01:29:43 +0530 Subject: [PATCH 04/15] test(pd): cover KVOperation, RaftKVStore and raft utilities Add PD core tests for KVOperation encoding and whitelist decoding, RaftKVStore write routing through raft (leader check, raft failure, state machine replay), PeerUtil config parsing and ZipUtils snapshot checksums and zip-slip handling. Register them, plus the existing RaftReflectionUtilTest, in PDCoreSuiteTest. --- .../hugegraph/pd/core/PDCoreSuiteTest.java | 10 + .../pd/core/store/RaftKVStoreTest.java | 239 ++++++++++++++++++ .../hugegraph/pd/raft/KVOperationTest.java | 206 +++++++++++++++ .../hugegraph/pd/raft/PeerUtilTest.java | 96 +++++++ .../hugegraph/pd/raft/ZipUtilsTest.java | 141 +++++++++++ 5 files changed, 692 insertions(+) create mode 100644 hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java create mode 100644 hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/KVOperationTest.java create mode 100644 hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/PeerUtilTest.java create mode 100644 hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/ZipUtilsTest.java diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java index 1c73176703..23ca938c51 100644 --- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/PDCoreSuiteTest.java @@ -19,10 +19,15 @@ import org.apache.hugegraph.pd.core.meta.MetadataKeyHelperTest; import org.apache.hugegraph.pd.core.store.HgKVStoreImplTest; +import org.apache.hugegraph.pd.core.store.RaftKVStoreTest; import org.apache.hugegraph.pd.raft.IpAuthHandlerTest; +import org.apache.hugegraph.pd.raft.KVOperationTest; +import org.apache.hugegraph.pd.raft.PeerUtilTest; import org.apache.hugegraph.pd.raft.RaftEngineIpAuthIntegrationTest; import org.apache.hugegraph.pd.raft.RaftEngineLeaderAddressTest; import org.apache.hugegraph.pd.raft.RaftEngineReadinessTest; +import org.apache.hugegraph.pd.raft.RaftReflectionUtilTest; +import org.apache.hugegraph.pd.raft.ZipUtilsTest; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -47,6 +52,11 @@ RaftEngineIpAuthIntegrationTest.class, RaftEngineLeaderAddressTest.class, RaftEngineReadinessTest.class, + RaftReflectionUtilTest.class, + KVOperationTest.class, + PeerUtilTest.class, + ZipUtilsTest.class, + RaftKVStoreTest.class, // StoreNodeServiceTest.class, }) @Slf4j diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java new file mode 100644 index 0000000000..4280951715 --- /dev/null +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java @@ -0,0 +1,239 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.pd.core.store; + +import java.nio.ByteBuffer; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import org.apache.hugegraph.pd.common.PDException; +import org.apache.hugegraph.pd.config.PDConfig; +import org.apache.hugegraph.pd.grpc.Pdpb; +import org.apache.hugegraph.pd.raft.KVOperation; +import org.apache.hugegraph.pd.raft.RaftEngine; +import org.apache.hugegraph.pd.raft.RaftStateMachine; +import org.apache.hugegraph.pd.store.HgKVStore; +import org.apache.hugegraph.pd.store.KV; +import org.apache.hugegraph.pd.store.RaftKVStore; +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mockito; + +import com.alipay.sofa.jraft.Iterator; +import com.alipay.sofa.jraft.Status; +import com.alipay.sofa.jraft.entity.Task; +import com.alipay.sofa.jraft.error.RaftError; + +/** + * Covers RaftKVStore as both sides of a raft round trip: writes become serialized raft tasks, + * and the decoded operations are dispatched back onto the local store by invoke(). + */ +public class RaftKVStoreTest { + + private static final byte[] KEY = "key".getBytes(); + private static final byte[] VALUE = "value".getBytes(); + + private HgKVStore store; + private RaftEngine engine; + private RaftKVStore raftStore; + + @Before + public void setUp() { + this.store = Mockito.mock(HgKVStore.class); + this.engine = Mockito.mock(RaftEngine.class); + this.raftStore = new RaftKVStore(this.engine, this.store); + } + + /** + * Makes the mocked engine behave like a single-node leader: the task payload is decoded + * exactly as a follower would decode the log entry, applied, then completed with status. + */ + private void applyTasksAs(Status status) { + Mockito.doAnswer(invocation -> { + Task task = invocation.getArgument(0); + if (status.isOk()) { + ByteBuffer data = task.getData(); + byte[] bytes = new byte[data.remaining()]; + data.get(bytes); + this.raftStore.invoke(KVOperation.fromByteArray(bytes), null); + } + task.getDone().run(status); + return null; + }).when(this.engine).addTask(Mockito.any(Task.class)); + } + + @Test + public void testInitRegistersAsRaftTaskHandler() { + PDConfig config = new PDConfig(); + + this.raftStore.init(config); + + Mockito.verify(this.store).init(config); + Mockito.verify(this.engine).addTaskHandler(this.raftStore); + } + + @Test + public void testReadsAreServedLocallyWithoutRaft() throws PDException { + List kvs = Collections.singletonList(new KV(KEY, VALUE)); + Mockito.when(this.store.get(KEY)).thenReturn(VALUE); + Mockito.when(this.store.getWithTTL(KEY)).thenReturn(VALUE); + Mockito.when(this.store.getListWithTTL(KEY)).thenReturn(Collections.singletonList(VALUE)); + Mockito.when(this.store.scanPrefix(KEY)).thenReturn(kvs); + Mockito.when(this.store.scanRange(KEY, VALUE)).thenReturn(kvs); + + Assert.assertArrayEquals(VALUE, this.raftStore.get(KEY)); + Assert.assertArrayEquals(VALUE, this.raftStore.getWithTTL(KEY)); + Assert.assertEquals(1, this.raftStore.getListWithTTL(KEY).size()); + Assert.assertSame(kvs, this.raftStore.scanPrefix(KEY)); + Assert.assertSame(kvs, this.raftStore.scanRange(KEY, VALUE)); + + Mockito.verify(this.engine, Mockito.never()).addTask(Mockito.any()); + } + + @Test + public void testWritesReachStoreOnlyThroughRaft() throws PDException { + applyTasksAs(Status.OK()); + + this.raftStore.put(KEY, VALUE); + this.raftStore.putWithTTL(KEY, VALUE, 60L); + this.raftStore.putWithTTL(KEY, VALUE, 5L, TimeUnit.MINUTES); + this.raftStore.remove(KEY); + this.raftStore.removeByPrefix(KEY); + this.raftStore.removeWithTTL(KEY); + this.raftStore.clear(); + + Mockito.verify(this.engine, Mockito.times(7)).addTask(Mockito.any(Task.class)); + Mockito.verify(this.store).put(KEY, VALUE); + Mockito.verify(this.store).putWithTTL(KEY, VALUE, 60L); + Mockito.verify(this.store).putWithTTL(KEY, VALUE, 5L, TimeUnit.MINUTES); + Mockito.verify(this.store).remove(KEY); + Mockito.verify(this.store).removeByPrefix(KEY); + Mockito.verify(this.store).removeWithTTL(KEY); + Mockito.verify(this.store).clear(); + Mockito.verifyNoMoreInteractions(this.store); + } + + @Test + public void testWriteOnNonLeaderIsRejectedAndNotApplied() throws PDException { + // A RaftEngine that has not joined a group is never leader + RaftKVStore notLeader = new RaftKVStore(new RaftEngine(), this.store); + + PDException e = Assert.assertThrows(PDException.class, () -> notLeader.put(KEY, VALUE)); + + Assert.assertTrue(e.getMessage(), + e.getMessage().contains("Error code = " + + Pdpb.ErrorType.NOT_LEADER_VALUE)); + Mockito.verifyNoInteractions(this.store); + } + + @Test + public void testFailedRaftStatusFailsEveryWrite() { + applyTasksAs(new Status(RaftError.EINTERNAL, "injected raft failure")); + + assertWriteFails(() -> this.raftStore.put(KEY, VALUE)); + assertWriteFails(() -> this.raftStore.putWithTTL(KEY, VALUE, 1L)); + assertWriteFails(() -> this.raftStore.putWithTTL(KEY, VALUE, 1L, TimeUnit.SECONDS)); + assertWriteFails(() -> this.raftStore.remove(KEY)); + assertWriteFails(() -> this.raftStore.removeByPrefix(KEY)); + assertWriteFails(() -> this.raftStore.removeWithTTL(KEY)); + assertWriteFails(() -> this.raftStore.clear()); + Mockito.verifyNoInteractions(this.store); + } + + @Test + public void testEngineExceptionFailsWrite() { + Mockito.doThrow(new IllegalStateException("engine closed")) + .when(this.engine).addTask(Mockito.any(Task.class)); + + PDException e = Assert.assertThrows(PDException.class, + () -> this.raftStore.put(KEY, VALUE)); + Assert.assertTrue(e.getMessage(), e.getMessage().contains("engine closed")); + Mockito.verifyNoInteractions(this.store); + } + + @Test + public void testInvokeDispatchesSnapshotPathFromAttach() throws PDException { + this.raftStore.invoke(KVOperation.createSaveSnapshot("/snap/save"), null); + this.raftStore.invoke(KVOperation.createLoadSnapshot("/snap/load"), null); + + Mockito.verify(this.store).saveSnapshot("/snap/save"); + Mockito.verify(this.store).loadSnapshot("/snap/load"); + } + + @Test + public void testInvokeIgnoresGetAndUnknownOperations() throws PDException { + Assert.assertFalse(this.raftStore.invoke(KVOperation.createGet(KEY), null)); + Assert.assertFalse(this.raftStore.invoke(new KVOperation(KEY, VALUE, null, (byte) 0x7F), + null)); + + Mockito.verifyNoInteractions(this.store); + } + + @Test + public void testFollowerReplayAppliesTtlWithUnit() throws Exception { + RaftStateMachine stateMachine = new RaftStateMachine(); + stateMachine.addTaskHandler(this.raftStore); + byte[] entry = KVOperation.createPutWithTTL(KEY, VALUE, 3L, TimeUnit.HOURS) + .toByteArray(); + Iterator iter = Mockito.mock(Iterator.class); + Mockito.when(iter.hasNext()).thenReturn(true, false); + // A follower has no closure for the entry, so it must decode the log data + Mockito.when(iter.done()).thenReturn(null); + Mockito.when(iter.getData()).thenReturn(ByteBuffer.wrap(entry)); + + stateMachine.onApply(iter); + + Mockito.verify(this.store).putWithTTL(KEY, VALUE, 3L, TimeUnit.HOURS); + Mockito.verify(iter).next(); + Mockito.verify(iter, Mockito.never()).setErrorAndRollback(Mockito.anyLong(), + Mockito.any()); + } + + @Test + public void testFollowerReplayStopsOnStoreFailure() throws Exception { + RaftStateMachine stateMachine = new RaftStateMachine(); + stateMachine.addTaskHandler(this.raftStore); + Mockito.doThrow(new PDException(Pdpb.ErrorType.ROCKSDB_WRITE_ERROR_VALUE, "disk full")) + .when(this.store).put(KEY, VALUE); + Iterator iter = Mockito.mock(Iterator.class); + Mockito.when(iter.hasNext()).thenReturn(true); + Mockito.when(iter.getData()) + .thenReturn(ByteBuffer.wrap(KVOperation.createPut(KEY, VALUE).toByteArray())); + + stateMachine.onApply(iter); + + Mockito.verify(iter).setErrorAndRollback(Mockito.eq(1L), Mockito.argThat( + status -> status.getRaftError() == RaftError.ESTATEMACHINE && + "disk full".equals(status.getErrorMsg()))); + Mockito.verify(iter, Mockito.never()).next(); + } + + private interface Write { + + void run() throws PDException; + } + + private static void assertWriteFails(Write write) { + PDException e = Assert.assertThrows(PDException.class, write::run); + Assert.assertEquals(Pdpb.ErrorType.UNKNOWN_VALUE, e.getErrorCode()); + Assert.assertTrue(e.getMessage(), + e.getMessage().contains("Error code = " + Pdpb.ErrorType.UNKNOWN_VALUE)); + } +} diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/KVOperationTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/KVOperationTest.java new file mode 100644 index 0000000000..8556a969b0 --- /dev/null +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/KVOperationTest.java @@ -0,0 +1,206 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.pd.raft; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.concurrent.TimeUnit; + +import org.junit.Assert; +import org.junit.Test; + +import com.caucho.hessian.io.Hessian2Output; + +/** + * Pins the wire format that a follower replays from the raft log: the op byte followed by + * Hessian-encoded key, value and arg, decoded through the class whitelist. + */ +public class KVOperationTest { + + private static final byte[] KEY = {1, 2, 3}; + private static final byte[] VALUE = {4, 5, 6, 7}; + + private static KVOperation roundTrip(KVOperation op) throws IOException { + byte[] bytes = op.toByteArray(); + Assert.assertEquals(op.getOp(), bytes[0]); + return KVOperation.fromByteArray(bytes); + } + + @Test + public void testPutRoundTrip() throws IOException { + KVOperation decoded = roundTrip(KVOperation.createPut(KEY, VALUE)); + + Assert.assertEquals(KVOperation.PUT, decoded.getOp()); + Assert.assertArrayEquals(KEY, decoded.getKey()); + Assert.assertArrayEquals(VALUE, decoded.getValue()); + Assert.assertNull(decoded.getArg()); + } + + @Test + public void testGetRoundTripKeepsEmptyValue() throws IOException { + KVOperation decoded = roundTrip(KVOperation.createGet(KEY)); + + Assert.assertEquals(KVOperation.GET, decoded.getOp()); + Assert.assertArrayEquals(KEY, decoded.getKey()); + Assert.assertArrayEquals(new byte[0], decoded.getValue()); + } + + @Test + public void testRemoveOperationsCarryKeyAsValue() throws IOException { + KVOperation[] ops = { + KVOperation.createRemove(KEY), + KVOperation.createRemoveByPrefix(KEY), + KVOperation.createRemoveWithTTL(KEY) + }; + byte[] expectedOps = {KVOperation.REMOVE, KVOperation.REMOVE_BY_PREFIX, + KVOperation.REMOVE_WITH_TTL}; + for (int i = 0; i < ops.length; i++) { + KVOperation decoded = roundTrip(ops[i]); + Assert.assertEquals(expectedOps[i], decoded.getOp()); + Assert.assertArrayEquals(KEY, decoded.getKey()); + Assert.assertArrayEquals(KEY, decoded.getValue()); + } + } + + @Test + public void testPutWithTtlRoundTripKeepsLongArg() throws IOException { + long ttl = Integer.MAX_VALUE + 10L; + KVOperation decoded = roundTrip(KVOperation.createPutWithTTL(KEY, VALUE, ttl)); + + Assert.assertEquals(KVOperation.PUT_WITH_TTL, decoded.getOp()); + Assert.assertArrayEquals(VALUE, decoded.getValue()); + // RaftKVStore.invoke unboxes the arg with a (long) cast, so it must stay a Long + Assert.assertEquals(Long.valueOf(ttl), decoded.getArg()); + } + + @Test + public void testPutWithSmallTtlStillDecodesAsLong() throws IOException { + KVOperation decoded = roundTrip(KVOperation.createPutWithTTL(KEY, VALUE, 1L)); + + Assert.assertTrue(decoded.getArg() instanceof Long); + Assert.assertEquals(1L, (long) decoded.getArg()); + } + + @Test + public void testPutWithTtlUnitRoundTripKeepsTtlAndUnit() throws IOException { + KVOperation decoded = roundTrip(KVOperation.createPutWithTTL(KEY, VALUE, 30L, + TimeUnit.SECONDS)); + + Assert.assertEquals(KVOperation.PUT_WITH_TTL_UNIT, decoded.getOp()); + Object[] arg = (Object[]) decoded.getArg(); + Assert.assertEquals(2, arg.length); + Assert.assertEquals(30L, (long) arg[0]); + Assert.assertSame(TimeUnit.SECONDS, arg[1]); + } + + @Test + public void testClearRoundTripHasNullKeyAndValue() throws IOException { + KVOperation decoded = roundTrip(KVOperation.createClear()); + + Assert.assertEquals(KVOperation.CLEAR, decoded.getOp()); + Assert.assertNull(decoded.getKey()); + Assert.assertNull(decoded.getValue()); + } + + @Test + public void testSnapshotPathTravelsOnlyInAttach() throws IOException { + KVOperation save = KVOperation.createSaveSnapshot("/data/snapshot"); + Assert.assertEquals("/data/snapshot", save.getAttach()); + + // Snapshot operations are created locally by the state machine and never replicated: + // the path is held in the transient attach field and is not part of the encoding. + KVOperation decoded = roundTrip(save); + Assert.assertEquals(KVOperation.SAVE_SNAPSHOT, decoded.getOp()); + Assert.assertNull(decoded.getAttach()); + Assert.assertNull(decoded.getArg()); + + Assert.assertEquals(KVOperation.LOAD_SNAPSHOT, + KVOperation.createLoadSnapshot("/data/snapshot").getOp()); + } + + @Test + public void testFactoriesRejectNullArguments() { + assertNpe(() -> KVOperation.createPut(null, VALUE)); + assertNpe(() -> KVOperation.createPut(KEY, null)); + assertNpe(() -> KVOperation.createGet(null)); + assertNpe(() -> KVOperation.createPutWithTTL(null, VALUE, 1L)); + assertNpe(() -> KVOperation.createPutWithTTL(KEY, null, 1L, TimeUnit.SECONDS)); + assertNpe(() -> KVOperation.createRemove(null)); + assertNpe(() -> KVOperation.createRemoveByPrefix(null)); + assertNpe(() -> KVOperation.createRemoveWithTTL(null)); + assertNpe(() -> KVOperation.createSaveSnapshot(null)); + assertNpe(() -> KVOperation.createLoadSnapshot(null)); + } + + @Test + public void testTruncatedEntryFailsToDecode() throws IOException { + byte[] bytes = KVOperation.createPut(KEY, VALUE).toByteArray(); + byte[] truncated = Arrays.copyOf(bytes, bytes.length - 2); + + Assert.assertThrows(IOException.class, () -> KVOperation.fromByteArray(truncated)); + } + + @Test + public void testDecodeNeverInstantiatesClassOutsideWhitelist() throws IOException { + byte[] bytes = encode(KVOperation.PUT, KEY, VALUE, new NotWhitelisted("payload")); + + KVOperation decoded = KVOperation.fromByteArray(bytes); + + // The whitelist must keep the class from being instantiated + Assert.assertFalse(decoded.getArg() instanceof NotWhitelisted); + Assert.assertArrayEquals(KEY, decoded.getKey()); + } + + @Test + public void testDecodeAllowsWhitelistedCollections() throws IOException { + byte[] bytes = encode(KVOperation.PUT, KEY, VALUE, + new ArrayList<>(Arrays.asList("a", "b"))); + + KVOperation decoded = KVOperation.fromByteArray(bytes); + Assert.assertEquals(Arrays.asList("a", "b"), decoded.getArg()); + } + + private static byte[] encode(byte op, byte[] key, byte[] value, Object arg) + throws IOException { + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + bos.write(op); + Hessian2Output output = new Hessian2Output(bos); + output.writeObject(key); + output.writeObject(value); + output.writeObject(arg); + output.flush(); + return bos.toByteArray(); + } + + private static void assertNpe(Runnable action) { + Assert.assertThrows(NullPointerException.class, action::run); + } + + public static class NotWhitelisted implements Serializable { + + private static final long serialVersionUID = 1L; + public String data; + + public NotWhitelisted(String data) { + this.data = data; + } + } +} diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/PeerUtilTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/PeerUtilTest.java new file mode 100644 index 0000000000..067f8d523e --- /dev/null +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/PeerUtilTest.java @@ -0,0 +1,96 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.pd.raft; + +import java.util.List; + +import org.apache.hugegraph.pd.common.KVPair; +import org.junit.Assert; +import org.junit.Test; + +import com.alipay.sofa.jraft.entity.PeerId; + +public class PeerUtilTest { + + @Test + public void testIsPeerEqualsComparesOnlyIpAndPort() { + PeerId peer = new PeerId("10.0.0.1", 8610); + // Same endpoint with a different replica index and priority still names the same peer + PeerId sameEndpoint = new PeerId("10.0.0.1", 8610, 3, 100); + + Assert.assertTrue(PeerUtil.isPeerEquals(peer, sameEndpoint)); + Assert.assertFalse(PeerUtil.isPeerEquals(peer, new PeerId("10.0.0.1", 8611))); + Assert.assertFalse(PeerUtil.isPeerEquals(peer, new PeerId("10.0.0.2", 8610))); + } + + @Test + public void testIsPeerEqualsHandlesNull() { + PeerId peer = new PeerId("10.0.0.1", 8610); + + Assert.assertTrue(PeerUtil.isPeerEquals(null, null)); + Assert.assertFalse(PeerUtil.isPeerEquals(peer, null)); + Assert.assertFalse(PeerUtil.isPeerEquals(null, peer)); + } + + @Test + public void testParseConfigAssignsRolesFromSuffix() { + List> peers = PeerUtil.parseConfig( + "10.0.0.1:8610/leader,10.0.0.2:8610/learner,10.0.0.3:8610/follower," + + "10.0.0.4:8610"); + + Assert.assertEquals(4, peers.size()); + assertPeer(peers.get(0), "leader", "10.0.0.1", 8610); + assertPeer(peers.get(1), "learner", "10.0.0.2", 8610); + assertPeer(peers.get(2), "follower", "10.0.0.3", 8610); + // A peer without a role suffix defaults to follower + assertPeer(peers.get(3), "follower", "10.0.0.4", 8610); + } + + @Test + public void testParseConfigKeepsReplicaIndex() { + List> peers = PeerUtil.parseConfig("10.0.0.1:8610:2/learner"); + + Assert.assertEquals(1, peers.size()); + Assert.assertEquals("learner", peers.get(0).getKey()); + Assert.assertEquals(2, peers.get(0).getValue().getIdx()); + } + + @Test + public void testParseConfigOfEmptyInputIsEmpty() { + Assert.assertTrue(PeerUtil.parseConfig(null).isEmpty()); + Assert.assertTrue(PeerUtil.parseConfig("").isEmpty()); + } + + @Test + public void testParseConfigRejectsMalformedPeer() { + Assert.assertThrows(IllegalArgumentException.class, + () -> PeerUtil.parseConfig("10.0.0.1:8610,not-a-peer")); + Assert.assertThrows(IllegalArgumentException.class, + () -> PeerUtil.parseConfig("10.0.0.1:port/leader")); + // An unknown role is not stripped, so the peer string itself becomes invalid + Assert.assertThrows(IllegalArgumentException.class, + () -> PeerUtil.parseConfig("10.0.0.1:8610/witness")); + } + + private static void assertPeer(KVPair pair, String role, String ip, + int port) { + Assert.assertEquals(role, pair.getKey()); + Assert.assertEquals(ip, pair.getValue().getIp()); + Assert.assertEquals(port, pair.getValue().getPort()); + } +} diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/ZipUtilsTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/ZipUtilsTest.java new file mode 100644 index 0000000000..c6a5f22a1f --- /dev/null +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/raft/ZipUtilsTest.java @@ -0,0 +1,141 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.pd.raft; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +import com.alipay.sofa.jraft.util.CRC64; + +/** + * Covers the archive step of PD raft snapshots: RaftStateMachine compares the checksum + * computed while compressing with the one computed while decompressing. + */ +public class ZipUtilsTest { + + @Rule + public TemporaryFolder tmp = new TemporaryFolder(); + + @Test + public void testCompressDecompressRoundTripWithNestedDirectories() throws IOException { + File root = tmp.newFolder("writer"); + File snapshot = new File(root, "snapshot"); + write(new File(snapshot, "CURRENT"), "MANIFEST-000001"); + write(new File(snapshot, "sst/000001.sst"), "data-1"); + write(new File(snapshot, "sst/deep/000002.sst"), "data-2"); + File archive = new File(root, "snapshot.zip"); + + CRC64 compressSum = new CRC64(); + ZipUtils.compress(root.getPath(), "snapshot", archive.getPath(), compressSum); + Assert.assertTrue(archive.length() > 0); + + File reader = tmp.newFolder("reader"); + CRC64 decompressSum = new CRC64(); + ZipUtils.decompress(archive.getPath(), reader.getPath(), decompressSum); + + Assert.assertEquals("MANIFEST-000001", read(new File(reader, "snapshot/CURRENT"))); + Assert.assertEquals("data-1", read(new File(reader, "snapshot/sst/000001.sst"))); + Assert.assertEquals("data-2", read(new File(reader, "snapshot/sst/deep/000002.sst"))); + // The follower verifies the archive with the checksum the leader recorded + Assert.assertEquals(compressSum.getValue(), decompressSum.getValue()); + } + + @Test + public void testChecksumDiffersForDifferentContent() throws IOException { + Assert.assertNotEquals(checksumOf("data-1"), checksumOf("data-2")); + } + + @Test + public void testCompressEmptyDirectoryProducesEmptyArchive() throws IOException { + File root = tmp.newFolder("empty-writer"); + Assert.assertTrue(new File(root, "snapshot").mkdir()); + File archive = new File(root, "snapshot.zip"); + + ZipUtils.compress(root.getPath(), "snapshot", archive.getPath(), new CRC64()); + + File reader = tmp.newFolder("empty-reader"); + ZipUtils.decompress(archive.getPath(), reader.getPath(), new CRC64()); + String[] extracted = reader.list(); + Assert.assertNotNull(extracted); + Assert.assertEquals(0, extracted.length); + } + + @Test + public void testDecompressSkipsEntriesEscapingOutputDir() throws IOException { + File base = tmp.newFolder("slip"); + File output = new File(base, "out"); + Assert.assertTrue(output.mkdir()); + File archive = new File(base, "evil.zip"); + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(archive))) { + putEntry(zos, "../escaped.txt", "evil"); + // A sibling whose name shares the output dir prefix is outside it as well + putEntry(zos, "../out-sibling/escaped.txt", "evil"); + putEntry(zos, "snapshot/kept.txt", "good"); + } + + ZipUtils.decompress(archive.getPath(), output.getPath(), new CRC64()); + + Assert.assertFalse(new File(base, "escaped.txt").exists()); + Assert.assertFalse(new File(base, "out-sibling").exists()); + Assert.assertEquals("good", read(new File(output, "snapshot/kept.txt"))); + } + + @Test + public void testDecompressMissingArchiveFails() { + File missing = new File(tmp.getRoot(), "missing.zip"); + + Assert.assertThrows(IOException.class, + () -> ZipUtils.decompress(missing.getPath(), tmp.getRoot().getPath(), + new CRC64())); + } + + private long checksumOf(String content) throws IOException { + File root = tmp.newFolder(); + write(new File(root, "snapshot/file"), content); + CRC64 checksum = new CRC64(); + ZipUtils.compress(root.getPath(), "snapshot", new File(root, "a.zip").getPath(), + checksum); + return checksum.getValue(); + } + + private static void putEntry(ZipOutputStream zos, String name, String content) + throws IOException { + zos.putNextEntry(new ZipEntry(name)); + zos.write(content.getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + + private static void write(File file, String content) throws IOException { + Files.createDirectories(file.getParentFile().toPath()); + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + } + + private static String read(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } +} From c170b4f9f7cae7eec853deb60ffeb43f3a920b35 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 11 Oct 2026 01:29:43 +0530 Subject: [PATCH 05/15] test(store): cover codecs, RocksDB sessions and zip path safety Add Store tests for Base58 encoding, edge owner keys, CRC64, both ZipUtils copies (round trip and zip-slip entries), SessionOperator writes, ranges, iterator release and snapshots, partition and shard group metadata, and HgBufferProxy flow control. Register them, plus the existing HgStoreStateMachineTest, FutureClosureTest, HgAssertTest and HgPairTest, in the matching suites. --- .../store/client/ClientSuiteTest.java | 4 +- .../store/client/HgBufferProxyTest.java | 124 ++++++++ .../store/common/Base58EncoderTest.java | 93 ++++++ .../store/common/CommonSuiteTest.java | 6 +- .../hugegraph/store/common/KeyUtilTest.java | 102 +++++++ .../hugegraph/store/core/CoreSuiteTest.java | 4 +- .../store/meta/PartitionMetaCodecTest.java | 171 +++++++++++ .../store/raftcore/RaftSuiteTest.java | 6 +- .../hugegraph/store/rocksdb/CRC64Test.java | 95 +++++++ .../store/rocksdb/RocksDbSuiteTest.java | 5 +- .../store/rocksdb/SessionOperatorTest.java | 268 ++++++++++++++++++ .../store/rocksdb/ZipUtilsPathSafetyTest.java | 156 ++++++++++ 12 files changed, 1029 insertions(+), 5 deletions(-) create mode 100644 hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/HgBufferProxyTest.java create mode 100644 hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/Base58EncoderTest.java create mode 100644 hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/KeyUtilTest.java create mode 100644 hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/core/store/meta/PartitionMetaCodecTest.java create mode 100644 hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/CRC64Test.java create mode 100644 hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java create mode 100644 hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/ClientSuiteTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/ClientSuiteTest.java index f47b571f2d..affda30112 100644 --- a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/ClientSuiteTest.java +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/ClientSuiteTest.java @@ -36,7 +36,9 @@ NodeTxSessionProxyTest.class, OrderedKvIteratorTest.class, CommonKvStreamObserverTest.class, - MultiStreamIteratorTest.class + MultiStreamIteratorTest.class, + HgAssertTest.class, + HgBufferProxyTest.class }) public class ClientSuiteTest { } diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/HgBufferProxyTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/HgBufferProxyTest.java new file mode 100644 index 0000000000..2f93bb712e --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/client/HgBufferProxyTest.java @@ -0,0 +1,124 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.store.client; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; + +import org.apache.hugegraph.store.client.type.HgStoreClientException; +import org.apache.hugegraph.store.client.util.HgBufferProxy; +import org.junit.Test; + +/** + * HgBufferProxy is the page buffer behind KvPageScanner: receive() asks the producer task for + * more data when the buffer runs low, and close()/setError() end the stream. + */ +public class HgBufferProxyTest { + + @SuppressWarnings("unchecked") + private static HgBufferProxy proxy(Runnable task) { + return HgBufferProxy.of(task); + } + + @Test + public void testRejectsNullTaskAndNullItems() { + assertThrows(IllegalArgumentException.class, () -> HgBufferProxy.of(null)); + HgBufferProxy buffer = proxy(() -> { }); + assertThrows(IllegalArgumentException.class, () -> buffer.send(null)); + } + + @Test + public void testReceiveRunsTheProducerOnlyWhenTheBufferIsLow() { + AtomicInteger runs = new AtomicInteger(); + AtomicReference> ref = new AtomicReference<>(); + HgBufferProxy buffer = proxy(() -> ref.get().send("page-" + runs.incrementAndGet())); + ref.set(buffer); + + assertEquals("page-1", buffer.receive(1, null)); + assertEquals(1, runs.get()); + + buffer.send("x"); + buffer.send("y"); + assertEquals("x", buffer.receive(1, null)); + assertEquals("a buffered backlog must not trigger the producer", 1, runs.get()); + assertEquals("y", buffer.receive(1, null)); + assertEquals(2, runs.get()); + assertEquals("page-2", buffer.receive(1, null)); + } + + @Test + public void testTimeoutThrowsWithoutCallbackAndNotifiesTheCallback() { + HgBufferProxy buffer = proxy(() -> { }); + + RuntimeException e = assertThrows(RuntimeException.class, () -> buffer.receive(0, null)); + assertEquals("timeout, max time: 0 seconds", e.getMessage()); + + List timeouts = new ArrayList<>(); + assertNull(buffer.receive(0, timeouts::add)); + assertEquals(List.of(0), timeouts); + } + + @Test + public void testCloseDrainsBufferedItemsThenSignalsEnd() { + AtomicInteger runs = new AtomicInteger(); + HgBufferProxy buffer = proxy(runs::incrementAndGet); + buffer.send("a"); + buffer.send("b"); + + buffer.close(); + buffer.close(); + buffer.send("ignored"); + + assertTrue(buffer.isClosed()); + assertEquals("a", buffer.receive(0, null)); + assertEquals("b", buffer.receive(0, null)); + assertNull(buffer.receive(0, null)); + assertNull(buffer.receive(0, null)); + assertEquals("a closed buffer must not ask the producer for more", 0, runs.get()); + } + + @Test + public void testStreamErrorIsRethrownWithItsCause() { + IllegalStateException cause = new IllegalStateException("stream broken"); + + AtomicReference> ref = new AtomicReference<>(); + HgBufferProxy open = proxy(() -> { + ref.get().send("partial"); + ref.get().setError(cause); + }); + ref.set(open); + HgStoreClientException e = assertThrows(HgStoreClientException.class, + () -> open.receive(1, null)); + assertSame(cause, e.getCause()); + + HgBufferProxy closed = proxy(() -> { }); + closed.send("buffered"); + closed.setError(cause); + closed.close(); + e = assertThrows(HgStoreClientException.class, () -> closed.receive(0, null)); + assertSame(cause, e.getCause()); + } +} diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/Base58EncoderTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/Base58EncoderTest.java new file mode 100644 index 0000000000..b5f7af35a3 --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/Base58EncoderTest.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.store.common; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.util.Random; + +import org.apache.hugegraph.store.client.util.Base58; +import org.apache.hugegraph.store.util.Base58Encoder; +import org.junit.Test; + +public class Base58EncoderTest { + + @Test + public void testKnownVectors() { + assertEquals("", Base58Encoder.convertToBase58(new byte[0])); + assertEquals("2NEpo7TZRRrLZSi2U", + Base58Encoder.convertToBase58("Hello World!".getBytes(StandardCharsets.US_ASCII))); + assertEquals("5Q", Base58Encoder.convertToBase58(new byte[]{(byte) 0xff})); + assertArrayEquals("Hello World!".getBytes(StandardCharsets.US_ASCII), + Base58Encoder.convertFromBase58("2NEpo7TZRRrLZSi2U")); + assertArrayEquals(new byte[0], Base58Encoder.convertFromBase58("")); + } + + @Test + public void testLeadingZeroBytesBecomeLeadingOnes() { + assertEquals("1", Base58Encoder.convertToBase58(new byte[]{0})); + assertEquals("111", Base58Encoder.convertToBase58(new byte[]{0, 0, 0})); + assertEquals("112", Base58Encoder.convertToBase58(new byte[]{0, 0, 1})); + + assertArrayEquals(new byte[]{0}, Base58Encoder.convertFromBase58("1")); + assertArrayEquals(new byte[]{0, 0, 1}, Base58Encoder.convertFromBase58("112")); + } + + @Test + public void testRoundTripDoesNotMutateInput() { + Random random = new Random(20261011L); + for (int i = 0; i < 200; i++) { + byte[] input = new byte[random.nextInt(40)]; + random.nextBytes(input); + if (input.length > 0 && random.nextBoolean()) { + input[0] = 0; + } + byte[] snapshot = input.clone(); + + String encoded = Base58Encoder.convertToBase58(input); + + assertArrayEquals("encoding must not modify its argument", snapshot, input); + assertArrayEquals(input, Base58Encoder.convertFromBase58(encoded)); + // Store-side and client-side encoders must agree on the wire representation + assertEquals(Base58.encode(input), encoded); + } + } + + @Test + public void testConvertToBigIntIgnoresLeadingZeros() { + assertEquals(BigInteger.ZERO, Base58Encoder.convertToBigInt("1")); + assertEquals(BigInteger.valueOf(57), Base58Encoder.convertToBigInt("z")); + assertEquals(BigInteger.valueOf(58), Base58Encoder.convertToBigInt("121")); + } + + @Test + public void testRejectsCharactersOutsideTheAlphabet() { + // '0', 'O', 'I' and 'l' are excluded from the Base58 alphabet to avoid ambiguity + for (String invalid : new String[]{"0", "O", "I", "l", "2N+", "abc def", "é"}) { + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, + () -> Base58Encoder.convertFromBase58(invalid)); + assertTrue(e.getMessage(), e.getMessage().startsWith("Invalid character")); + } + assertThrows(IllegalArgumentException.class, () -> Base58Encoder.convertToBigInt("0")); + } +} diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/CommonSuiteTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/CommonSuiteTest.java index 2ef65cf3b0..350d812166 100644 --- a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/CommonSuiteTest.java +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/CommonSuiteTest.java @@ -17,6 +17,7 @@ package org.apache.hugegraph.store.common; +import org.apache.hugegraph.store.client.HgPairTest; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -25,7 +26,10 @@ @RunWith(Suite.class) @Suite.SuiteClasses({ ByteBufferAllocatorTest.class, - KVByteBufferTest.class + KVByteBufferTest.class, + Base58EncoderTest.class, + KeyUtilTest.class, + HgPairTest.class }) @Slf4j diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/KeyUtilTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/KeyUtilTest.java new file mode 100644 index 0000000000..7eef58889b --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/common/KeyUtilTest.java @@ -0,0 +1,102 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.store.common; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; + +import org.apache.hugegraph.backend.BinaryId; +import org.apache.hugegraph.id.EdgeId; +import org.apache.hugegraph.id.Id; +import org.apache.hugegraph.id.IdGenerator; +import org.apache.hugegraph.id.IdUtil; +import org.apache.hugegraph.store.constant.HugeServerTables; +import org.apache.hugegraph.store.query.util.KeyUtil; +import org.apache.hugegraph.type.define.Directions; +import org.junit.Test; + +public class KeyUtilTest { + + private static final Id OWNER = IdGenerator.of("owner-vertex"); + private static final Id OTHER = IdGenerator.of(987654321L); + + private static EdgeId edge(Directions direction) { + return new EdgeId(OWNER, direction, IdGenerator.of(3L), IdGenerator.of(3L), "", OTHER); + } + + @Test + public void testEdgeKeysOfBothDirectionsRouteToTheOwnerVertex() { + byte[] expected = KeyUtil.idToBytes(OWNER); + for (Directions direction : new Directions[]{Directions.OUT, Directions.IN}) { + byte[] edgeKey = IdUtil.asBytes(edge(direction)); + String table = direction == Directions.OUT ? HugeServerTables.OUT_EDGE_TABLE : + HugeServerTables.IN_EDGE_TABLE; + + byte[] ownerKey = KeyUtil.getOwnerKey(table, edgeKey); + + assertArrayEquals(expected, ownerKey); + // The owner key is a serialized id that decodes back to the owner vertex + assertEquals(OWNER, IdUtil.fromBytes(ownerKey)); + } + } + + @Test + public void testOwnerKeyMatchesVertexKeySoEdgesColocateWithTheirVertex() { + byte[] vertexKey = IdUtil.asBytes(OWNER); + byte[] edgeOwnerKey = KeyUtil.getOwnerKey(HugeServerTables.OUT_EDGE_TABLE, + IdUtil.asBytes(edge(Directions.OUT))); + assertArrayEquals(KeyUtil.getOwnerKey(HugeServerTables.VERTEX_TABLE, vertexKey), + edgeOwnerKey); + } + + @Test + public void testNonEdgeTablesReturnTheKeyItself() { + byte[] key = IdUtil.asBytes(edge(Directions.OUT)); + for (String table : new String[]{HugeServerTables.VERTEX_TABLE, + HugeServerTables.INDEX_TABLE, + HugeServerTables.UNKNOWN_TABLE, null}) { + assertSame(key, KeyUtil.getOwnerKey(table, key)); + } + assertFalse(HugeServerTables.isEdgeTable(null)); + assertFalse(HugeServerTables.isEdgeTable("g+oe ")); + assertTrue(HugeServerTables.isEdgeTable("g+ie")); + } + + @Test + public void testNullOrEmptyKeyYieldsEmptyOwnerKey() { + assertEquals(0, KeyUtil.getOwnerKey(HugeServerTables.OUT_EDGE_TABLE, null).length); + assertEquals(0, KeyUtil.getOwnerKey(HugeServerTables.IN_EDGE_TABLE, new byte[0]).length); + assertEquals(0, KeyUtil.getOwnerKey(HugeServerTables.VERTEX_TABLE, null).length); + } + + @Test + public void testGetOwnerIdUnwrapsBinaryAndEdgeIds() { + assertArrayEquals(OWNER.asBytes(), KeyUtil.getOwnerId(OWNER)); + assertArrayEquals(OWNER.asBytes(), KeyUtil.getOwnerId(edge(Directions.IN))); + + EdgeId edgeId = edge(Directions.OUT); + BinaryId wrapped = new BinaryId(IdUtil.asBytes(edgeId), edgeId); + assertArrayEquals(OWNER.asBytes(), KeyUtil.getOwnerId(wrapped)); + + assertEquals(0, KeyUtil.getOwnerId(null).length); + assertEquals(0, KeyUtil.getOwnerId(new BinaryId(new byte[]{1}, null)).length); + } +} diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/core/CoreSuiteTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/core/CoreSuiteTest.java index c2b3d68e69..e9b5be8c48 100644 --- a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/core/CoreSuiteTest.java +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/core/CoreSuiteTest.java @@ -20,6 +20,7 @@ import org.apache.hugegraph.store.HeartbeatShutdownTest; import org.apache.hugegraph.store.StoreIdChangeTest; import org.apache.hugegraph.store.core.snapshot.HgSnapshotHandlerTest; +import org.apache.hugegraph.store.core.store.meta.PartitionMetaCodecTest; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -49,7 +50,8 @@ HeartbeatShutdownTest.class, MetricSessionTest.class, HgSnapshotHandlerTest.class, - StoreIdChangeTest.class + StoreIdChangeTest.class, + PartitionMetaCodecTest.class }) @Slf4j public class CoreSuiteTest { diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/core/store/meta/PartitionMetaCodecTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/core/store/meta/PartitionMetaCodecTest.java new file mode 100644 index 0000000000..383306c4f8 --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/core/store/meta/PartitionMetaCodecTest.java @@ -0,0 +1,171 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.store.core.store.meta; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotSame; +import static org.junit.Assert.assertNull; + +import java.util.List; + +import org.apache.hugegraph.pd.grpc.Metapb; +import org.apache.hugegraph.store.meta.Partition; +import org.apache.hugegraph.store.meta.Shard; +import org.apache.hugegraph.store.meta.ShardGroup; +import org.junit.Test; + +/** + * Partition and shard-group metadata is persisted and exchanged with PD as protobuf, so the + * conversion in both directions must be lossless for the fields the store keeps. + */ +public class PartitionMetaCodecTest { + + private static Metapb.Partition proto(Metapb.PartitionState state) { + return Metapb.Partition.newBuilder() + .setId(7) + .setGraphName("hugegraph/g") + .setStartKey(0x1000) + .setEndKey(0x2000) + .setVersion(42) + .setState(state) + .build(); + } + + private static Metapb.Shard shard(long storeId, Metapb.ShardRole role) { + return Metapb.Shard.newBuilder().setStoreId(storeId).setRole(role).build(); + } + + @Test + public void testPartitionProtoRoundTrip() { + for (Metapb.PartitionState state : new Metapb.PartitionState[]{ + Metapb.PartitionState.PState_Normal, Metapb.PartitionState.PState_Warn, + Metapb.PartitionState.PState_Offline}) { + Metapb.Partition original = proto(state); + Partition partition = new Partition(original); + + assertEquals(7, partition.getId()); + assertEquals("hugegraph/g", partition.getGraphName()); + assertEquals(0x1000, partition.getStartKey()); + assertEquals(0x2000, partition.getEndKey()); + assertEquals(42, partition.getVersion()); + assertEquals(state, partition.getWorkState()); + assertEquals(original, partition.getProtoObj()); + } + } + + @Test + public void testMissingOrUnknownPartitionStateFallsBackToNormal() { + Partition none = new Partition(proto(Metapb.PartitionState.PState_None)); + assertEquals(Metapb.PartitionState.PState_Normal, none.getWorkState()); + + Metapb.Partition future = proto(Metapb.PartitionState.PState_Normal).toBuilder() + .setStateValue(99) + .build(); + assertEquals(Metapb.PartitionState.UNRECOGNIZED, future.getState()); + assertEquals(Metapb.PartitionState.PState_Normal, new Partition(future).getWorkState()); + + Partition fromEmpty = new Partition(Metapb.Partition.getDefaultInstance()); + assertEquals(Metapb.PartitionState.PState_Normal, fromEmpty.getWorkState()); + assertEquals("", fromEmpty.getGraphName()); + assertEquals(Metapb.PartitionState.PState_Normal, new Partition().getWorkState()); + } + + @Test + public void testPartitionCloneIsIndependent() { + Partition partition = new Partition(proto(Metapb.PartitionState.PState_Normal)); + Partition copy = partition.clone(); + + assertNotSame(partition, copy); + assertEquals(partition, copy); + copy.setEndKey(0x3000); + copy.setWorkState(Metapb.PartitionState.PState_Offline); + assertEquals(0x2000, partition.getEndKey()); + assertEquals(Metapb.PartitionState.PState_Normal, partition.getWorkState()); + } + + @Test + public void testShardGroupProtoRoundTrip() { + Metapb.ShardGroup original = Metapb.ShardGroup.newBuilder() + .setId(3) + .setVersion(5) + .setConfVer(9) + .addShards(shard(11, Metapb.ShardRole.Leader)) + .addShards(shard(12, Metapb.ShardRole.Follower)) + .addShards(shard(13, Metapb.ShardRole.Learner)) + .build(); + ShardGroup group = ShardGroup.from(original); + + assertEquals(3, group.getId()); + assertEquals(5, group.getVersion()); + assertEquals(9, group.getConfVersion()); + assertEquals(3, group.getShards().size()); + assertEquals(original, group.getProtoObj()); + assertEquals("{ id:11,role:Leader },{ id:12,role:Follower },{ id:13,role:Learner }", + group.toString()); + + assertNull(ShardGroup.from(null)); + assertEquals("", new ShardGroup().toString()); + } + + @Test + public void testChangeShardListAssignsRolesAndBumpsConfVersion() { + ShardGroup group = ShardGroup.from(Metapb.ShardGroup.newBuilder() + .setId(1) + .setConfVer(4) + .addShards(shard(1, Metapb.ShardRole.Leader)) + .build()); + + group.changeShardList(List.of(21L, 22L), List.of(23L), 22L); + + assertEquals(5, group.getConfVersion()); + assertEquals(List.of(shard(21, Metapb.ShardRole.Follower), + shard(22, Metapb.ShardRole.Leader), + shard(23, Metapb.ShardRole.Learner)), + group.getMetaPbShard()); + } + + @Test + public void testEmptyPeerListLeavesTheGroupUnchanged() { + ShardGroup group = new ShardGroup(); + group.setConfVersion(4); + Shard leader = new Shard(); + leader.setStoreId(1); + leader.setRole(Metapb.ShardRole.Leader); + group.addShard(leader); + + // Learners alone must not wipe the voters + group.changeShardList(List.of(), List.of(9L), 9L); + + assertEquals(4, group.getConfVersion()); + assertEquals(List.of(shard(1, Metapb.ShardRole.Leader)), group.getMetaPbShard()); + } + + @Test + public void testChangeLeaderDemotesEveryOtherShard() { + ShardGroup group = ShardGroup.from(Metapb.ShardGroup.newBuilder() + .addShards(shard(1, Metapb.ShardRole.Leader)) + .addShards(shard(2, Metapb.ShardRole.Follower)) + .addShards(shard(3, Metapb.ShardRole.Follower)) + .build()); + group.changeLeader(3); + assertEquals(List.of(shard(1, Metapb.ShardRole.Follower), + shard(2, Metapb.ShardRole.Follower), + shard(3, Metapb.ShardRole.Leader)), + group.getMetaPbShard()); + } +} diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/raftcore/RaftSuiteTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/raftcore/RaftSuiteTest.java index be24270ea7..ff6d441765 100644 --- a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/raftcore/RaftSuiteTest.java +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/raftcore/RaftSuiteTest.java @@ -17,8 +17,10 @@ package org.apache.hugegraph.store.raftcore; +import org.apache.hugegraph.store.core.raft.HgStoreStateMachineTest; import org.apache.hugegraph.store.core.raft.PartitionStateMachineTest; import org.apache.hugegraph.store.core.snapshot.SnapshotHandlerTest; +import org.apache.hugegraph.store.util.FutureClosureTest; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -27,7 +29,9 @@ BytesCarrierTest.class, ZeroByteStringHelperTest.class, SnapshotHandlerTest.class, - PartitionStateMachineTest.class + PartitionStateMachineTest.class, + HgStoreStateMachineTest.class, + FutureClosureTest.class }) public class RaftSuiteTest { diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/CRC64Test.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/CRC64Test.java new file mode 100644 index 0000000000..51fa24374b --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/CRC64Test.java @@ -0,0 +1,95 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.store.rocksdb; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotEquals; + +import java.nio.charset.StandardCharsets; + +import org.apache.hugegraph.rocksdb.access.util.CRC64; +import org.junit.Test; + +public class CRC64Test { + + private static final byte[] CHECK_INPUT = "123456789".getBytes(StandardCharsets.US_ASCII); + + @Test + public void testMatchesCrc64Ecma182CheckValue() { + CRC64 crc = new CRC64(); + assertEquals(0L, crc.getValue()); + crc.update(CHECK_INPUT); + // Standard check value of CRC-64/ECMA-182 (poly 0x42F0E1EBA9EA3693, init 0, no reflection) + assertEquals(0x6C40DF5F0B497347L, crc.getValue()); + } + + @Test + public void testAllUpdateOverloadsAreEquivalent() { + CRC64 whole = new CRC64(); + whole.update(CHECK_INPUT); + + CRC64 perInt = new CRC64(); + for (byte b : CHECK_INPUT) { + // update(int) must only consider the low eight bits + perInt.update(b | 0xABCD00); + } + + CRC64 split = new CRC64(); + split.update(CHECK_INPUT, 0, 4); + split.update(CHECK_INPUT, 4, CHECK_INPUT.length - 4); + + assertEquals(whole.getValue(), perInt.getValue()); + assertEquals(whole.getValue(), split.getValue()); + } + + @Test + public void testOffsetAndLengthSelectOnlyTheRequestedSlice() { + byte[] padded = new byte[CHECK_INPUT.length + 6]; + padded[0] = 'x'; + padded[1] = 'y'; + padded[2] = 'z'; + System.arraycopy(CHECK_INPUT, 0, padded, 3, CHECK_INPUT.length); + padded[padded.length - 1] = 'q'; + + CRC64 crc = new CRC64(); + crc.update(padded, 3, CHECK_INPUT.length); + assertEquals(0x6C40DF5F0B497347L, crc.getValue()); + + CRC64 empty = new CRC64(); + empty.update(padded, 5, 0); + assertEquals(0L, empty.getValue()); + } + + @Test + public void testResetAndSensitivityToSingleBitChanges() { + CRC64 crc = new CRC64(); + crc.update(CHECK_INPUT); + long expected = crc.getValue(); + + byte[] flipped = CHECK_INPUT.clone(); + flipped[4] ^= 0x01; + crc.reset(); + assertEquals(0L, crc.getValue()); + crc.update(flipped); + assertNotEquals(expected, crc.getValue()); + + crc.reset(); + crc.update(CHECK_INPUT); + assertEquals(expected, crc.getValue()); + } +} diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/RocksDbSuiteTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/RocksDbSuiteTest.java index 59c1cd9986..16e0c2c495 100644 --- a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/RocksDbSuiteTest.java +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/RocksDbSuiteTest.java @@ -24,7 +24,10 @@ @RunWith(Suite.class) @Suite.SuiteClasses({ - RocksDBFactoryTest.class + RocksDBFactoryTest.class, + CRC64Test.class, + ZipUtilsPathSafetyTest.class, + SessionOperatorTest.class }) @Slf4j diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java new file mode 100644 index 0000000000..6dfd5a7e31 --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java @@ -0,0 +1,268 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.store.rocksdb; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.apache.hugegraph.rocksdb.access.DBStoreException; +import org.apache.hugegraph.rocksdb.access.RocksDBSession; +import org.apache.hugegraph.rocksdb.access.RocksDBSession.BackendColumn; +import org.apache.hugegraph.rocksdb.access.ScanIterator; +import org.apache.hugegraph.rocksdb.access.SessionOperator; +import org.apache.hugegraph.rocksdb.access.SessionOperatorImpl; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +public class SessionOperatorTest extends BaseRocksDbTest { + + private static final String TABLE = "tbl"; + private static final String GRAPH = "op-graph"; + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private ExecutorService prober; + private String dataPath; + private RocksDBSession session; + private int probes; + + private static byte[] b(String s) { + return s.getBytes(StandardCharsets.UTF_8); + } + + private static List keys(ScanIterator it) { + List keys = new ArrayList<>(); + try { + while (it.hasNext()) { + BackendColumn col = it.next(); + keys.add(new String(col.name, StandardCharsets.UTF_8)); + } + } finally { + it.close(); + } + return keys; + } + + @Before + public void setUp() throws Exception { + this.prober = Executors.newSingleThreadExecutor(r -> { + Thread t = new Thread(r, "cf-lock-prober"); + t.setDaemon(true); + return t; + }); + this.dataPath = this.folder.newFolder("data").getAbsolutePath(); + this.session = new RocksDBSession(hConfig, this.dataPath, GRAPH, 0); + this.session.checkTable(TABLE); + } + + @After + public void tearDown() { + if (this.session != null) { + this.session.close(); + } + this.prober.shutdownNow(); + } + + private void putAll(String... keys) { + SessionOperator op = this.session.sessionOp(); + op.prepare(); + for (String key : keys) { + op.put(TABLE, b(key), b("v-" + key)); + } + op.commit(); + } + + private List allKeys() { + return keys(this.session.sessionOp().scan(TABLE)); + } + + /** + * Creating a column family takes the CF write lock, so another thread can only do it when + * the caller does not leak the read lock taken by prepare(). + */ + private void assertCfLockReleased() throws Exception { + String probe = "probe-" + this.probes++; + Future future = this.prober.submit(() -> this.session.checkTable(probe)); + future.get(10, TimeUnit.SECONDS); + assertTrue(this.session.tableIsExist(probe)); + } + + @Test + public void testIncreaseOneCarriesAndRejectsOverflow() { + assertArrayEquals(new byte[]{1, 3}, SessionOperatorImpl.increaseOne(new byte[]{1, 2})); + assertArrayEquals(new byte[]{2, 0, 0}, + SessionOperatorImpl.increaseOne(new byte[]{1, (byte) 0xff, (byte) 0xff})); + assertThrows(DBStoreException.class, + () -> SessionOperatorImpl.increaseOne(new byte[]{(byte) 0xff, (byte) 0xff})); + } + + @Test + public void testCommitAppliesTheBatchAndReturnsItsSize() throws Exception { + SessionOperator op = this.session.sessionOp(); + op.prepare(); + op.put(TABLE, b("k1"), b("v1")); + op.put(TABLE, b("k2"), b("v2")); + op.delete(TABLE, b("k1")); + assertNull("batched writes are invisible before commit", op.get(TABLE, b("k2"))); + assertEquals(Integer.valueOf(3), op.commit()); + + assertNull(op.get(TABLE, b("k1"))); + assertArrayEquals(b("v2"), op.get(TABLE, b("k2"))); + + op.prepare(); + assertEquals(Integer.valueOf(0), op.commit()); + assertCfLockReleased(); + } + + @Test + public void testRollbackDiscardsPendingWritesAndReleasesTheLock() throws Exception { + putAll("keep"); + SessionOperator op = this.session.sessionOp(); + op.prepare(); + op.put(TABLE, b("lost"), b("v")); + op.delete(TABLE, b("keep")); + op.rollback(); + + assertNull(op.get(TABLE, b("lost"))); + assertArrayEquals(b("v-keep"), op.get(TABLE, b("keep"))); + assertCfLockReleased(); + + // The operator stays usable after a rollback + op.prepare(); + op.put(TABLE, b("after"), b("v")); + assertEquals(Integer.valueOf(1), op.commit()); + assertArrayEquals(b("v"), op.get(TABLE, b("after"))); + } + + @Test + public void testDeletePrefixRemovesOnlyPrefixedKeys() throws Exception { + putAll("a", "ab", "ab0", "ab~", "ac", "b"); + SessionOperator op = this.session.sessionOp(); + op.prepare(); + op.deletePrefix(TABLE, b("ab")); + op.commit(); + + assertEquals(List.of("a", "ac", "b"), allKeys()); + + byte[] unbounded = new byte[]{(byte) 0xff, (byte) 0xff}; + op.prepare(); + try { + assertThrows(DBStoreException.class, () -> op.deletePrefix(TABLE, unbounded)); + } finally { + op.rollback(); + } + assertCfLockReleased(); + } + + @Test + public void testDeleteRangeIsHalfOpenAndValidatesBounds() throws Exception { + putAll("k1", "k2", "k3", "k4", "k5"); + SessionOperator op = this.session.sessionOp(); + + op.deleteRange(TABLE, b("k2"), b("k4")); + assertEquals(List.of("k1", "k4", "k5"), allKeys()); + + DBStoreException inverted = assertThrows(DBStoreException.class, + () -> op.deleteRange(TABLE, b("k5"), b("k1"))); + assertTrue(inverted.getMessage(), inverted.getMessage().contains("is lower than")); + assertThrows(IllegalArgumentException.class, () -> op.deleteRange(TABLE, null, b("k5"))); + assertThrows(IllegalArgumentException.class, () -> op.deleteRange(TABLE, b("k1"), null)); + + assertEquals("rejected ranges must not delete anything", List.of("k1", "k4", "k5"), + allKeys()); + assertCfLockReleased(); + } + + @Test + public void testScanIteratorHoldsASessionReferenceUntilClosed() { + putAll("p1", "p2", "q1"); + assertEquals(1, this.session.getRefCount()); + + ScanIterator it = this.session.sessionOp().scan(TABLE, b("p")); + assertEquals(2, this.session.getRefCount()); + assertEquals(1, this.session.getIteratorMap().size()); + assertTrue(it.hasNext()); + it.close(); + it.close(); + assertEquals(1, this.session.getRefCount()); + assertTrue(this.session.getIteratorMap().isEmpty()); + + // Exhausting an iterator releases it without an explicit close + assertEquals(2L, this.session.sessionOp().keyCount(b("p"), b("q"), TABLE)); + assertEquals(1, this.session.getRefCount()); + assertTrue(this.session.getIteratorMap().isEmpty()); + } + + @Test + public void testPrefixAndRangeScansHonourTheirBounds() { + putAll("p1", "p2", "pz", "q1", "r1"); + SessionOperator op = this.session.sessionOp(); + + assertEquals(List.of("p1", "p2", "pz"), keys(op.scan(TABLE, b("p")))); + assertEquals(List.of("p2", "pz", "q1"), + keys(op.scan(TABLE, b("p2"), b("r1"), ScanIterator.Trait.SCAN_LT_END))); + assertEquals(List.of("p2", "pz", "q1", "r1"), + keys(op.scan(TABLE, b("p2"), b("r1"), ScanIterator.Trait.SCAN_LTE_END))); + assertTrue(keys(op.scan(TABLE, b("x"))).isEmpty()); + assertEquals(1, this.session.getRefCount()); + } + + @Test + public void testSnapshotRoundTripAndMissingSnapshot() throws Exception { + putAll("s1", "s2"); + String snapshot = new File(this.folder.getRoot(), "snapshot").getAbsolutePath(); + this.session.saveSnapshot(snapshot); + assertTrue(new File(snapshot, "CURRENT").exists()); + assertFalse("temporary checkpoint dir must be renamed away", + new File(snapshot + "_temp").exists()); + + DBStoreException missing = assertThrows(DBStoreException.class, () -> + this.session.loadSnapshot(snapshot + "-missing", 7)); + assertTrue(missing.getMessage(), missing.getMessage().contains("not exists")); + // The CF write lock taken by loadSnapshot must be released on failure + putAll("s3"); + assertCfLockReleased(); + + this.session.loadSnapshot(snapshot, 7); + assertTrue(new File(this.dataPath, GRAPH + "_7").isDirectory()); + this.session.close(); + this.session = null; + + // Reopening picks the newest versioned path, i.e. the loaded snapshot without "s3" + this.session = new RocksDBSession(hConfig, this.dataPath, GRAPH, 7); + assertTrue(this.session.getDbPath().endsWith(GRAPH + "_7")); + assertEquals(List.of("s1", "s2"), allKeys()); + } +} diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java new file mode 100644 index 0000000000..41120832a0 --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.apache.hugegraph.store.rocksdb; + +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.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.zip.CRC32; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; + +import org.apache.hugegraph.rocksdb.access.util.CRC64; +import org.apache.hugegraph.rocksdb.access.util.ZipUtils; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Snapshot archives are produced and unpacked by two ZipUtils copies: the RocksDB one used for + * partition snapshots and the core one. Both must keep extracted files inside the target dir. + */ +public class ZipUtilsPathSafetyTest { + + @Rule + public TemporaryFolder folder = new TemporaryFolder(); + + private static void write(File file, String content) throws IOException { + Files.createDirectories(file.getParentFile().toPath()); + Files.write(file.toPath(), content.getBytes(StandardCharsets.UTF_8)); + } + + private static String read(File file) throws IOException { + return new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); + } + + private File maliciousZip(String... entryNames) throws IOException { + File zip = this.folder.newFile("malicious.zip"); + try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zip))) { + zos.putNextEntry(new ZipEntry("snapshot/ok.sst")); + zos.write("ok".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + for (String name : entryNames) { + zos.putNextEntry(new ZipEntry(name)); + zos.write("pwned".getBytes(StandardCharsets.UTF_8)); + zos.closeEntry(); + } + } + return zip; + } + + @Test + public void testRoundTripKeepsNestedLayoutAndChecksumsAgree() throws IOException { + File root = this.folder.newFolder("root"); + write(new File(root, "snapshot/CURRENT"), "MANIFEST-000001"); + write(new File(root, "snapshot/sub/dir/000004.sst"), "sst-bytes"); + Files.createDirectories(new File(root, "snapshot/empty").toPath()); + File zip = new File(this.folder.getRoot(), "snapshot.zip"); + File out = this.folder.newFolder("out"); + + CRC64 written = new CRC64(); + ZipUtils.compress(root.getAbsolutePath(), "snapshot", zip.getAbsolutePath(), written); + CRC64 read = new CRC64(); + ZipUtils.decompress(zip.getAbsolutePath(), out.getAbsolutePath(), read); + + assertEquals("MANIFEST-000001", read(new File(out, "snapshot/CURRENT"))); + assertEquals("sst-bytes", read(new File(out, "snapshot/sub/dir/000004.sst"))); + // The checksum covers the whole archive on both sides, so it detects transfer damage + assertTrue(written.getValue() != 0L); + assertEquals(written.getValue(), read.getValue()); + + File coreOut = this.folder.newFolder("core-out"); + CRC32 coreRead = new CRC32(); + org.apache.hugegraph.store.util.ZipUtils.decompress(zip.getAbsolutePath(), + coreOut.getAbsolutePath(), coreRead); + assertArrayEquals(Files.readAllBytes(new File(out, "snapshot/sub/dir/000004.sst").toPath()), + Files.readAllBytes(new File(coreOut, "snapshot/sub/dir/000004.sst").toPath())); + } + + @Test + public void testRocksDbDecompressSkipsEntriesEscapingTheTargetDir() throws IOException { + File out = this.folder.newFolder("out"); + File zip = maliciousZip("../escaped.txt", "snapshot/../../escaped2.txt", "../out-evil/x.txt", + new File(this.folder.getRoot(), "absolute.txt").getAbsolutePath()); + + ZipUtils.decompress(zip.getAbsolutePath(), out.getAbsolutePath(), new CRC32()); + + assertEquals("ok", read(new File(out, "snapshot/ok.sst"))); + assertFalse(new File(this.folder.getRoot(), "escaped.txt").exists()); + assertFalse(new File(this.folder.getRoot(), "escaped2.txt").exists()); + assertFalse(new File(this.folder.getRoot(), "out-evil").exists()); + assertFalse(new File(this.folder.getRoot(), "absolute.txt").exists()); + } + + @Test + public void testCoreDecompressRejectsEntriesEscapingTheTargetDir() throws IOException { + File out = this.folder.newFolder("out"); + // "../out-evil" shares the textual prefix of the target dir and must still be rejected + for (String evil : new String[]{"../escaped.txt", "snapshot/../../escaped.txt", + "../out-evil/x.txt"}) { + File zip = maliciousZip(evil); + IOException e = assertThrows(IOException.class, () -> + org.apache.hugegraph.store.util.ZipUtils.decompress( + zip.getAbsolutePath(), out.getAbsolutePath(), new CRC32())); + assertTrue(e.getMessage(), e.getMessage().contains("outside of the target dir")); + assertFalse(new File(this.folder.getRoot(), "escaped.txt").exists()); + assertFalse(new File(this.folder.getRoot(), "out-evil").exists()); + assertTrue(zip.delete()); + } + } + + @Test + public void testCoreZipUtilsValidatesArguments() throws IOException { + String root = this.folder.newFolder("root").getAbsolutePath(); + String zip = new File(this.folder.getRoot(), "x.zip").getAbsolutePath(); + String out = this.folder.newFolder("out").getAbsolutePath(); + + assertThrows(IllegalArgumentException.class, () -> + org.apache.hugegraph.store.util.ZipUtils.compress(null, "s", zip, new CRC32())); + assertThrows(IllegalArgumentException.class, () -> + org.apache.hugegraph.store.util.ZipUtils.compress(root, "s", zip, null)); + assertThrows(IllegalArgumentException.class, () -> + org.apache.hugegraph.store.util.ZipUtils.decompress(zip, null, new CRC32())); + + IOException missingDir = assertThrows(IOException.class, () -> + org.apache.hugegraph.store.util.ZipUtils.compress(root, "missing", zip, new CRC32())); + assertTrue(missingDir.getMessage().contains("Source directory does not exist")); + assertFalse("no archive may be created for a missing source", new File(zip).exists()); + + IOException missingZip = assertThrows(IOException.class, () -> + org.apache.hugegraph.store.util.ZipUtils.decompress(zip, out, new CRC32())); + assertTrue(missingZip.getMessage().contains("Source file does not exist")); + } +} From a46e8226c2902ed298477935d55d435a23008c2c Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 11 Oct 2026 01:29:43 +0530 Subject: [PATCH 06/15] test(cluster): round-trip graph data through the simple cluster Add SimpleClusterGraphTest, which creates schema, vertices and an edge through the server, reads them back by id and by adjacency through the HStore backend, and checks that invalid vertex requests and unknown ids are rejected. Register it in SimpleClusterSuiteTest. --- .../SimpleClusterGraphTest.java | 163 ++++++++++++++++++ .../SimpleClusterSuiteTest.java | 1 + 2 files changed, 164 insertions(+) create mode 100644 hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterGraphTest.java diff --git a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterGraphTest.java b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterGraphTest.java new file mode 100644 index 0000000000..97ac4ed4da --- /dev/null +++ b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterGraphTest.java @@ -0,0 +1,163 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + + +package org.apache.hugegraph.SimpleClusterTest; + +import java.io.IOException; +import java.util.List; +import java.util.Map; + +import org.junit.Assert; +import org.junit.BeforeClass; +import org.junit.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableMap; + +import jakarta.ws.rs.core.Response; + +/** + * Writes graph data through the server and reads it back, so the request path + * crosses Server, the HStore backend and the Store node of the simple cluster. + */ +public class SimpleClusterGraphTest extends BaseSimpleTest { + + private static final String SCHEMA = URL_PREFIX + "/schema"; + private static final String VERTICES = URL_PREFIX + "/graph/vertices"; + private static final String EDGES = URL_PREFIX + "/graph/edges"; + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @BeforeClass + public static void initSchema() { + createPropertyKey("ct_name", "TEXT"); + createPropertyKey("ct_age", "INT"); + createPropertyKey("ct_weight", "DOUBLE"); + createAndAssert(SCHEMA + "/vertexlabels", "{" + + "\"name\": \"ct_person\"," + + "\"id_strategy\": \"PRIMARY_KEY\"," + + "\"properties\": [\"ct_name\", \"ct_age\"]," + + "\"primary_keys\": [\"ct_name\"]," + + "\"check_exist\": false" + + "}", 201); + createAndAssert(SCHEMA + "/edgelabels", "{" + + "\"name\": \"ct_knows\"," + + "\"source_label\": \"ct_person\"," + + "\"target_label\": \"ct_person\"," + + "\"properties\": [\"ct_weight\"]," + + "\"check_exist\": false" + + "}", 201); + } + + @Test + public void testVertexAndEdgeRoundTripThroughStore() throws IOException { + String alice = createPerson("ct-alice", 30); + String bob = createPerson("ct-bob", 31); + + Map vertex = read(assertResponseStatus(200, client.get(VERTICES, json(alice)))); + Assert.assertEquals(alice, vertex.get("id")); + Assert.assertEquals("ct_person", vertex.get("label")); + Map props = (Map) vertex.get("properties"); + Assert.assertEquals("ct-alice", props.get("ct_name")); + Assert.assertEquals(30, props.get("ct_age")); + + Response r = client.post(EDGES, "{" + + "\"label\": \"ct_knows\"," + + "\"outV\": " + json(alice) + "," + + "\"inV\": " + json(bob) + "," + + "\"outVLabel\": \"ct_person\"," + + "\"inVLabel\": \"ct_person\"," + + "\"properties\": {\"ct_weight\": 0.5}" + + "}"); + String edgeId = (String) read(assertResponseStatus(201, r)).get("id"); + + Map edge = read(assertResponseStatus(200, client.get(EDGES, edgeId))); + Assert.assertEquals(alice, edge.get("outV")); + Assert.assertEquals(bob, edge.get("inV")); + Assert.assertEquals(0.5, ((Map) edge.get("properties")).get("ct_weight")); + + // Adjacency is served from the Store side, so query it by direction + List out = edges(alice, "OUT"); + Assert.assertEquals(1, out.size()); + Assert.assertEquals(edgeId, ((Map) out.get(0)).get("id")); + Assert.assertEquals(1, edges(bob, "IN").size()); + Assert.assertEquals(0, edges(bob, "OUT").size()); + } + + @Test + public void testInvalidGraphRequestsAreRejected() { + Response r = client.post(VERTICES, "{" + + "\"label\": \"ct_undefined_label\"," + + "\"properties\": {\"ct_name\": \"x\"}" + + "}"); + assertResponseStatus(400, r); + + r = client.post(VERTICES, "{" + + "\"label\": \"ct_person\"," + + "\"properties\": {\"ct_name\": \"x\", \"ct_age\": \"old\"}" + + "}"); + assertResponseStatus(400, r); + + // A primary key vertex can't be created without its primary key value + r = client.post(VERTICES, "{" + + "\"label\": \"ct_person\"," + + "\"properties\": {\"ct_age\": 1}" + + "}"); + assertResponseStatus(400, r); + + r = client.get(VERTICES, json("ct-person-never-written")); + assertResponseStatus(404, r); + } + + private static void createPropertyKey(String name, String dataType) { + createAndAssert(SCHEMA + "/propertykeys", "{" + + "\"name\": \"" + name + "\"," + + "\"data_type\": \"" + dataType + "\"," + + "\"cardinality\": \"SINGLE\"," + + "\"check_exist\": false," + + "\"properties\": []" + + "}", 202); + } + + private static String createPerson(String name, int age) throws IOException { + Response r = client.post(VERTICES, "{" + + "\"label\": \"ct_person\"," + + "\"properties\": {" + + "\"ct_name\": \"" + name + "\"," + + "\"ct_age\": " + age + + "}}"); + return (String) read(assertResponseStatus(201, r)).get("id"); + } + + private static List edges(String vertexId, String direction) { + Response r = client.get(EDGES, ImmutableMap.of("vertex_id", json(vertexId), + "direction", direction)); + try { + return (List) read(assertResponseStatus(200, r)).get("edges"); + } catch (IOException e) { + throw new AssertionError(e); + } + } + + private static Map read(String content) throws IOException { + return MAPPER.readValue(content, Map.class); + } + + private static String json(String id) { + return "\"" + id + "\""; + } +} diff --git a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterSuiteTest.java b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterSuiteTest.java index 7f24d8b46c..cc007a0802 100644 --- a/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterSuiteTest.java +++ b/hugegraph-cluster-test/hugegraph-clustertest-test/src/main/java/org/apache/hugegraph/SimpleClusterTest/SimpleClusterSuiteTest.java @@ -26,6 +26,7 @@ @Suite.SuiteClasses({ SimpleClusterDeployTest.class, SimpleClusterFileTest.class, + SimpleClusterGraphTest.class, }) @Slf4j public class SimpleClusterSuiteTest { From 03ffdf0bc20abcb1b29cf80871ecd605b8691b46 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Sun, 11 Oct 2026 13:31:40 +0530 Subject: [PATCH 07/15] test: bound PD replay mock and keep 1 GiB allocator out of unit suite Stop the mocked raft iterator after one entry in testFollowerReplayStopsOnStoreFailure, so a missing early return fails the never() check instead of looping forever. Unregister MemoryAllocateTest from UnitTestSuite: two of its tests force an unreleased 1 GiB heap or direct allocation, which is a stress test rather than a unit test for the default suite. --- .../org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java | 2 +- .../src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java | 2 -- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java index 4280951715..0ba3dd0923 100644 --- a/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java +++ b/hugegraph-pd/hg-pd-test/src/main/java/org/apache/hugegraph/pd/core/store/RaftKVStoreTest.java @@ -213,7 +213,7 @@ public void testFollowerReplayStopsOnStoreFailure() throws Exception { Mockito.doThrow(new PDException(Pdpb.ErrorType.ROCKSDB_WRITE_ERROR_VALUE, "disk full")) .when(this.store).put(KEY, VALUE); Iterator iter = Mockito.mock(Iterator.class); - Mockito.when(iter.hasNext()).thenReturn(true); + Mockito.when(iter.hasNext()).thenReturn(true, false); Mockito.when(iter.getData()) .thenReturn(ByteBuffer.wrap(KVOperation.createPut(KEY, VALUE).toByteArray())); diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index a8820822f7..13e18bb46d 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -31,7 +31,6 @@ import org.apache.hugegraph.backend.store.rocksdb.RocksDBSnapshotRestoreTest; import org.apache.hugegraph.backend.tx.GraphIndexTransactionTest; import org.apache.hugegraph.backend.tx.GraphTransactionTest; -import org.apache.hugegraph.core.memory.MemoryAllocateTest; import org.apache.hugegraph.core.memory.MemoryManageTest; import org.apache.hugegraph.meta.EtcdMetaDriverTest; import org.apache.hugegraph.meta.MetaManagerSchemaCacheClearEventTest; @@ -255,7 +254,6 @@ RocksDBIteratorPoolTest.class, /* memory */ - MemoryAllocateTest.class, MemoryManageTest.class, /* utils */ From 1c0a9a0ee0aec5e607b04066f4245e030610ec29 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Mon, 12 Oct 2026 13:17:27 +0530 Subject: [PATCH 08/15] test(store): keep SessionOperatorTest teardown from deadlocking A test that fails between prepare() and commit() leaves the CF read lock held by the test thread, and RocksDBSession.close() then parks forever on the CF write lock. Release the thread's remaining read holds before closing, and add a Timeout rule so a stuck lock fails the test instead of hanging the store job. --- .../store/rocksdb/SessionOperatorTest.java | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java index 6dfd5a7e31..70de5aee32 100644 --- a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java @@ -32,6 +32,7 @@ import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantReadWriteLock; import org.apache.hugegraph.rocksdb.access.DBStoreException; import org.apache.hugegraph.rocksdb.access.RocksDBSession; @@ -44,6 +45,7 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; +import org.junit.rules.Timeout; public class SessionOperatorTest extends BaseRocksDbTest { @@ -53,6 +55,10 @@ public class SessionOperatorTest extends BaseRocksDbTest { @Rule public TemporaryFolder folder = new TemporaryFolder(); + // Runs @Before, the test and @After on one thread, so a stuck CF lock fails the test + @Rule + public Timeout timeout = Timeout.seconds(60); + private ExecutorService prober; private String dataPath; private RocksDBSession session; @@ -89,10 +95,29 @@ public void setUp() throws Exception { @After public void tearDown() { - if (this.session != null) { - this.session.close(); + try { + if (this.session != null) { + releaseCfReadLockHolds(); + this.session.close(); + } + } finally { + this.prober.shutdownNow(); + } + } + + /** + * A test that fails between prepare() and commit() still holds the CF read lock on this + * thread, and close() would then wait forever for the CF write lock. + */ + private void releaseCfReadLockHolds() { + ReentrantReadWriteLock.ReadLock readLock = this.session.getCfHandleReadLock(); + while (true) { + try { + readLock.unlock(); + } catch (IllegalMonitorStateException e) { + return; + } } - this.prober.shutdownNow(); } private void putAll(String... keys) { From 4d989c05d550fdf4ef40d416cb98b4f113645e51 Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Mon, 12 Oct 2026 15:26:47 +0530 Subject: [PATCH 09/15] test(server): let RocksDBIteratorPoolTest teardown survive setup errors Null-check each native resource before closing it, so a failure in setup() is reported as itself instead of a NullPointerException from teardown(). --- .../rocksdb/RocksDBIteratorPoolTest.java | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java index 722bccbc76..3f65597852 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java @@ -56,13 +56,22 @@ public void setup() throws Exception { @After public void teardown() { - // Native objects must be released child-first: pool, CF, DB, options + // Native objects must be released child-first: pool, CF, DB, options. Any of them may + // be missing when setup() failed, and closing it must not hide that failure. try { - this.pool.close(); + if (this.pool != null) { + this.pool.close(); + } } finally { - this.cfh.close(); - this.rocksdb.close(); - this.options.close(); + if (this.cfh != null) { + this.cfh.close(); + } + if (this.rocksdb != null) { + this.rocksdb.close(); + } + if (this.options != null) { + this.options.close(); + } } } From 7aa5b789ef01bb39f516d5e9306b3d578300100a Mon Sep 17 00:00:00 2001 From: Himanshu Verma Date: Mon, 12 Oct 2026 17:39:50 +0530 Subject: [PATCH 10/15] test(commons): assert RPC self exclusion on the built consumer config Check the direct URL the SOFA consumer is created with instead of the private remoteUrls field, so the test follows what the client uses. --- .../src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java index aa6fd19ff0..5cdcecdfa6 100644 --- a/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java +++ b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java @@ -73,8 +73,9 @@ public void testClientDisabledWhenOnlySelfIsRemote() { RpcClientProvider withPeer = new RpcClientProvider(new HugeConfig(options)); try { Assert.assertTrue(withPeer.enabled()); + // Self is dropped from the direct URL the SOFA consumer is built with Assert.assertEquals("127.0.0.1:8092", - Whitebox.getInternalState(withPeer.config(), "remoteUrls")); + consumerConfig(withPeer.config(), null, HELLO).getDirectUrl()); } finally { withPeer.destroy(); } From a6273a90e350e4de50e9d0653993ea7ffecd98ed Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 13 Oct 2026 02:16:29 +0800 Subject: [PATCH 11/15] fix: stabilize native coverage fixtures - encode lifecycle fixture keys and values as UTF-8 - assert native lock status without localized messages - normalize malicious ZIP entries and isolate archives - preserve containment and live-owner assertions --- .../RocksDBStdSessionsLifecycleTest.java | 22 ++++++++++++++++--- .../store/rocksdb/ZipUtilsPathSafetyTest.java | 9 +++++--- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java index a852924e0b..7f9e8a49bd 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java @@ -18,6 +18,7 @@ package org.apache.hugegraph.unit.rocksdb; import java.io.File; +import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.NoSuchElementException; @@ -40,6 +41,7 @@ import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.rocksdb.RocksDBException; +import org.rocksdb.Status; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; @@ -197,7 +199,7 @@ public void testLiveDatabaseCannotBeOpenedTwiceUntilClosed() throws Exception { Assert.assertThrows(RocksDBException.class, () -> { this.openWithoutTable(path); }, e -> { - Assert.assertContains("lock", e.getMessage()); + Assert.assertEquals(Status.Code.IOError, ((RocksDBException) e).getStatus().getCode()); }); // The failed open must not disturb the live instance Assert.assertTrue(sessions.databaseOpened()); @@ -307,6 +309,20 @@ public void testBuildSnapshotPathRequiresExistingSnapshot() throws Exception { Assert.assertEquals(expected.getPath(), sessions.buildSnapshotPath("snapshot")); } + @Test + public void testNonAsciiDataSurvivesReopen() throws Exception { + String path = this.newPath("utf8"); + RocksDBSessions sessions = this.open(path); + sessions.session().put(TABLE, getBytes("图-key"), getBytes("值-\uD83D\uDE00")); + sessions.session().commit(); + Assert.assertTrue(sessions.close()); + + RocksDBSessions reopened = this.openWithoutTable(path); + Assert.assertEquals("值-\uD83D\uDE00", + getString(reopened.session().get(TABLE, getBytes("图-key")))); + Assert.assertArrayEquals("图-key".getBytes(StandardCharsets.UTF_8), getBytes("图-key")); + } + private String newPath(String name) throws Exception { return new File(this.temporary.newFolder(name), "db").getAbsolutePath(); } @@ -327,10 +343,10 @@ private RocksDBSessions openWithoutTable(String path) throws RocksDBException { } private static byte[] getBytes(String str) { - return str.getBytes(); + return str.getBytes(StandardCharsets.UTF_8); } private static String getString(byte[] bytes) { - return bytes == null ? null : new String(bytes); + return bytes == null ? null : new String(bytes, StandardCharsets.UTF_8); } } diff --git a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java index 41120832a0..d1724dde60 100644 --- a/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java @@ -57,7 +57,7 @@ private static String read(File file) throws IOException { } private File maliciousZip(String... entryNames) throws IOException { - File zip = this.folder.newFile("malicious.zip"); + File zip = this.folder.newFile(); try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zip))) { zos.putNextEntry(new ZipEntry("snapshot/ok.sst")); zos.write("ok".getBytes(StandardCharsets.UTF_8)); @@ -102,8 +102,11 @@ public void testRoundTripKeepsNestedLayoutAndChecksumsAgree() throws IOException @Test public void testRocksDbDecompressSkipsEntriesEscapingTheTargetDir() throws IOException { File out = this.folder.newFolder("out"); + String absoluteEntry = new File(this.folder.getRoot(), "absolute.txt") + .getAbsolutePath().replace(File.separatorChar, '/'); + assertFalse(out.toPath().resolve(absoluteEntry).normalize().startsWith(out.toPath())); File zip = maliciousZip("../escaped.txt", "snapshot/../../escaped2.txt", "../out-evil/x.txt", - new File(this.folder.getRoot(), "absolute.txt").getAbsolutePath()); + absoluteEntry); ZipUtils.decompress(zip.getAbsolutePath(), out.getAbsolutePath(), new CRC32()); @@ -127,7 +130,7 @@ public void testCoreDecompressRejectsEntriesEscapingTheTargetDir() throws IOExce assertTrue(e.getMessage(), e.getMessage().contains("outside of the target dir")); assertFalse(new File(this.folder.getRoot(), "escaped.txt").exists()); assertFalse(new File(this.folder.getRoot(), "out-evil").exists()); - assertTrue(zip.delete()); + // Each attack has its own archive; TemporaryFolder owns cleanup. } } From 14b1f41369805279a8d993d99cfdc7f6431e0fa6 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 13 Oct 2026 03:43:14 +0800 Subject: [PATCH 12/15] fix: synchronize native cursor test handoff - wait for the initial producer handoff before capacity stop - preserve native cursor and producer cleanup assertions - retain the existing capacity and edge-count expectations --- .../hugegraph/core/OltpTraverserCursorTest.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/OltpTraverserCursorTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/OltpTraverserCursorTest.java index 3e53074f8e..430bcd568e 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/OltpTraverserCursorTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/core/OltpTraverserCursorTest.java @@ -377,6 +377,7 @@ private int consumeLazyProducer(Probe traverser, OltpTraverser.EdgesConsumer>, AutoCloseable { private int reads; @@ -385,6 +386,8 @@ class LazyInputs implements Iterator>, AutoCloseable { @Override public boolean hasNext() { if (this.reads == 1) { + // The first provide() has returned; Stop now occurs before the second one. + releaseConsumer.countDown(); try { // Runs after the real Consumers task and its worker transaction cleanup. worker.submit(() -> { }).get(5L, TimeUnit.SECONDS); @@ -410,11 +413,21 @@ public void close() { } LazyInputs source = new LazyInputs(); try { - long total = traverser.traverseLazy(source, consumer); + long total = traverser.traverseLazy(source, iterator -> { + try { + Assert.assertTrue("Producer must return from its first provide()", + releaseConsumer.await(5L, TimeUnit.SECONDS)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + consumer.accept(iterator); + }); Assert.assertEquals((long) source.reads, total); Assert.assertTrue("Traversal must close its lazy producer", source.closed); return source.reads; } finally { + releaseConsumer.countDown(); OltpTraverser.destroy(); Assert.assertTrue(worker.awaitTermination(5L, TimeUnit.SECONDS)); } From 4204567c45c49b1d8aac1b16f410cbca5f854dd7 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 13 Oct 2026 07:45:29 +0800 Subject: [PATCH 13/15] fix(server): verify map worker completion - exercise sparse map growth and value removal - propagate writer and reader failures through futures - close owned executors after checking stored values --- .../unit/store/RamIntObjectMapTest.java | 81 ++++++++------- .../unit/util/collection/IntMapTest.java | 98 +++++++++++-------- 2 files changed, 99 insertions(+), 80 deletions(-) diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/store/RamIntObjectMapTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/store/RamIntObjectMapTest.java index 400ede6dc9..b16bcfbf97 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/store/RamIntObjectMapTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/store/RamIntObjectMapTest.java @@ -17,7 +17,13 @@ package org.apache.hugegraph.unit.store; +import java.util.ArrayList; +import java.util.List; 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.apache.hugegraph.backend.store.ram.IntObjectMap; import org.junit.Assert; @@ -26,47 +32,46 @@ public class RamIntObjectMapTest { @Test - public void testConcurrency() { + public void testConcurrency() throws Exception { int size = 32; IntObjectMap map = new IntObjectMap<>(size); - - final int numThreads = 10; - final CountDownLatch startSignal = new CountDownLatch(1); - final CountDownLatch doneSignal = new CountDownLatch(numThreads); - - for (int i = 0; i < numThreads; i++) { - new Thread(() -> { - try { - startSignal.await(); - } catch (InterruptedException e) { - e.printStackTrace(); - Assert.fail(e.getMessage()); - } - - for (int j = 0; j < size; j++) { - map.set(j, j); - } - - doneSignal.countDown(); - }).start(); - } - - startSignal.countDown(); - + int numThreads = 10; + CountDownLatch startSignal = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(numThreads); + List> workers = new ArrayList<>(); try { - doneSignal.await(); - } catch (InterruptedException e) { - e.printStackTrace(); - Assert.fail(e.getMessage()); - } - - for (int i = 0; i < numThreads; i++) { - new Thread(() -> { - for (int j = 0; j < size; j++) { - Integer value = map.get(j); - Assert.assertNotNull(value); - } - }).start(); + for (int i = 0; i < numThreads; i++) { + workers.add(executor.submit(() -> { + startSignal.await(); + for (int j = 0; j < size; j++) { + map.set(j, j); + } + return null; + })); + } + startSignal.countDown(); + for (Future worker : workers) { + worker.get(10, TimeUnit.SECONDS); + } + workers.clear(); + for (int i = 0; i < numThreads; i++) { + workers.add(executor.submit(() -> { + for (int j = 0; j < size; j++) { + Assert.assertEquals(Integer.valueOf(j), map.get(j)); + } + })); + } + for (Future worker : workers) { + worker.get(10, TimeUnit.SECONDS); + } + Assert.assertEquals(size, map.size()); + for (int j = 0; j < size; j++) { + Assert.assertEquals(Integer.valueOf(j), map.get(j)); + } + } finally { + startSignal.countDown(); + executor.shutdownNow(); + Assert.assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); } } } diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/collection/IntMapTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/collection/IntMapTest.java index c22a76a8ed..5a8f5294c5 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/collection/IntMapTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/util/collection/IntMapTest.java @@ -17,15 +17,16 @@ package org.apache.hugegraph.unit.util.collection; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.Random; -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.LinkedBlockingDeque; -import java.util.concurrent.ThreadPoolExecutor; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; import java.util.function.BiFunction; import org.apache.hugegraph.testutil.Assert; @@ -433,51 +434,64 @@ public void testIntMapByDynamicHashSingleThread() { } @Test - public void testIntMapByDynamicHashMultiThread() throws InterruptedException { - IntMap map = new IntMapByDynamicHash(); + public void testIntMapByDynamicHashSparseKeysAcrossGrowth() { + IntMap map = new IntMapByDynamicHash(4); + int count = 32; + int stride = 1024; + for (int i = 0; i < count; i++) { + Assert.assertTrue(map.put(i * stride, i + 1)); + Assert.assertEquals(i + 1, map.size()); + for (int k = 0; k <= i; k++) { + Assert.assertTrue(map.containsKey(k * stride)); + Assert.assertEquals(k + 1, map.get(k * stride)); + } + } + for (int i = 0; i < count; i++) { + Assert.assertTrue(map.put(i * stride, -i - 1)); + Assert.assertEquals(-i - 1, map.get(i * stride)); + Assert.assertEquals(count, map.size()); + } + for (int i = 0; i < count; i++) { + Assert.assertTrue(map.remove(i * stride)); + Assert.assertFalse(map.containsKey(i * stride)); + Assert.assertFalse(map.remove(i * stride)); + Assert.assertEquals(count - i - 1, map.size()); + for (int k = i + 1; k < count; k++) { + Assert.assertEquals(-k - 1, map.get(k * stride)); + } + } + } - //int cpus = IntSet.CPUS; + @Test + public void testIntMapByDynamicHashMultiThread() throws Exception { + IntMap map = new IntMapByDynamicHash(); int cpus = 16; - ThreadPoolExecutor executor = - new ThreadPoolExecutor(cpus, cpus, 1, TimeUnit.MINUTES, - new LinkedBlockingDeque<>()) { - @Override - protected void afterExecute(Runnable r, Throwable t) { - super.afterExecute(r, t); - if (t != null) { - Assert.fail(t.getMessage()); - } - } - }; - - AtomicInteger size = new AtomicInteger(); int mapSize = 100; - CountDownLatch latch = new CountDownLatch(cpus); - for (int i = 1; i <= cpus; i++) { - int index = i; - executor.submit(() -> { - try { + ExecutorService executor = Executors.newFixedThreadPool(cpus); + List> workers = new ArrayList<>(); + try { + for (int i = 0; i < cpus; i++) { + int index = i; + workers.add(executor.submit(() -> { for (int j = 0; j < mapSize; j++) { - int key = j + (index - 1) * mapSize; - map.put(key, j); - size.getAndIncrement(); - //Assert.assertTrue(map.containsKey(key)); + int key = j + index * mapSize; + Assert.assertTrue(map.put(key, j)); Assert.assertEquals(j, map.get(key)); - //System.out.println(key + " " + j); } - } catch (Exception e) { - e.printStackTrace(); - Assert.fail(e.getMessage()); - } finally { - latch.countDown(); - } - }); + })); + } + for (Future worker : workers) { + worker.get(10, TimeUnit.SECONDS); + } + Assert.assertEquals(cpus * mapSize, map.size()); + for (int i = 0; i < cpus * mapSize; i++) { + Assert.assertTrue(map.containsKey(i)); + Assert.assertEquals(i % mapSize, map.get(i)); + } + } finally { + executor.shutdownNow(); + Assert.assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); } - - latch.await(); - System.out.println(); - - Assert.assertEquals(size.get(), map.size()); } @Test From d5a4d7a320b7d68c0c7ab16b25057165212535c4 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 13 Oct 2026 08:44:20 +0800 Subject: [PATCH 14/15] fix(server): await Gremlin connection close - wait for the asynchronous close future in the fixture - retain channel, request release and output assertions - bound the wait and report active or pending state --- .../auth/HttpGremlinRequestHandlerTest.java | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/auth/HttpGremlinRequestHandlerTest.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/auth/HttpGremlinRequestHandlerTest.java index dd33fe3c85..dc5446e177 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/auth/HttpGremlinRequestHandlerTest.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/auth/HttpGremlinRequestHandlerTest.java @@ -139,7 +139,7 @@ public void testConnectionCloseReleasesRequestOnSuccessAndFailure() } finally { response.release(); } - channel.runPendingTasks(); + awaitClose(channel); Assert.assertFalse(channel.isActive()); Assert.assertEquals(0, request.refCnt()); Assert.assertNull(channel.readOutbound()); @@ -281,4 +281,16 @@ private static FullHttpResponse response(EmbeddedChannel channel) } throw new AssertionError("Gremlin HTTP response did not arrive"); } + + private static void awaitClose(EmbeddedChannel channel) + throws InterruptedException { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (!channel.closeFuture().isDone() && System.nanoTime() < deadline) { + channel.runPendingTasks(); + Thread.sleep(5L); + } + Assert.assertTrue("Gremlin HTTP channel did not close: active=" + channel.isActive() + + ", pendingTasks=" + channel.hasPendingTasks(), + channel.closeFuture().isDone()); + } } From b7c2d254411a4d588b8d5faa5f288cb96d581909 Mon Sep 17 00:00:00 2001 From: imbajin Date: Tue, 13 Oct 2026 09:05:49 +0800 Subject: [PATCH 15/15] fix(server): register consumer tests once - retain the existing ConsumersTest suite entry - remove duplicate execution without losing test methods - keep consumer and Gremlin test implementations unchanged --- .../src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java | 1 - 1 file changed, 1 deletion(-) diff --git a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java index 7cfbe40db8..3b48e87589 100644 --- a/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/UnitTestSuite.java @@ -264,7 +264,6 @@ JsonUtilTest.class, StringEncodingTest.class, CompressUtilTest.class, - ConsumersTest.class, RateLimiterTest.FixedTimerWindowRateLimiterTest.class, RateLimiterTest.FixedWatchWindowRateLimiterTest.class,