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 { 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..5cdcecdfa6 --- /dev/null +++ b/hugegraph-commons/hugegraph-rpc/src/test/java/org/apache/hugegraph/unit/RpcConfigTest.java @@ -0,0 +1,247 @@ +/* + * 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()); + // Self is dropped from the direct URL the SOFA consumer is built with + Assert.assertEquals("127.0.0.1:8092", + consumerConfig(withPeer.config(), null, HELLO).getDirectUrl()); + } 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 { } 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..0ba3dd0923 --- /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, false); + 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); + } +} 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()); + } } 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..3f65597852 --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/backend/store/rocksdb/RocksDBIteratorPoolTest.java @@ -0,0 +1,149 @@ +/* + * 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. Any of them may + // be missing when setup() failed, and closing it must not hide that failure. + try { + if (this.pool != null) { + this.pool.close(); + } + } finally { + if (this.cfh != null) { + this.cfh.close(); + } + if (this.rocksdb != null) { + this.rocksdb.close(); + } + if (this.options != null) { + 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/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)); } 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 b83e083a77..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 @@ -27,9 +27,11 @@ 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.MemoryManageTest; import org.apache.hugegraph.meta.EtcdMetaDriverTest; import org.apache.hugegraph.meta.MetaManagerSchemaCacheClearEventTest; import org.apache.hugegraph.meta.managers.AuthMetaManagerTest; @@ -74,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; @@ -100,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; @@ -207,6 +211,7 @@ GraphManagerAdminInitTest.class, GraphManagerConfigTest.class, HstoreSessionsTest.class, + HstoreTableRoutingTest.class, BackendStoreInfoTest.class, TraversalUtilTest.class, TraversalUtilOptimizeTest.class, @@ -247,6 +252,11 @@ RocksDBSessionTest.class, RocksDBCountersTest.class, RocksDBTableQueryByIdsTest.class, + RocksDBStdSessionsLifecycleTest.class, + RocksDBIteratorPoolTest.class, + + /* memory */ + MemoryManageTest.class, /* utils */ ConsumersTest.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..7f9e8a49bd --- /dev/null +++ b/hugegraph-server/hugegraph-test/src/main/java/org/apache/hugegraph/unit/rocksdb/RocksDBStdSessionsLifecycleTest.java @@ -0,0 +1,352 @@ +/* + * 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.nio.charset.StandardCharsets; +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 org.rocksdb.Status; + +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.assertEquals(Status.Code.IOError, ((RocksDBException) e).getStatus().getCode()); + }); + // 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")); + } + + @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(); + } + + 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(StandardCharsets.UTF_8); + } + + private static String getString(byte[] bytes) { + return bytes == null ? null : new String(bytes, StandardCharsets.UTF_8); + } +} 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 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..70de5aee32 --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/SessionOperatorTest.java @@ -0,0 +1,293 @@ +/* + * 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 java.util.concurrent.locks.ReentrantReadWriteLock; + +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; +import org.junit.rules.Timeout; + +public class SessionOperatorTest extends BaseRocksDbTest { + + private static final String TABLE = "tbl"; + private static final String GRAPH = "op-graph"; + + @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; + 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() { + 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; + } + } + } + + 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..d1724dde60 --- /dev/null +++ b/hugegraph-store/hg-store-test/src/main/java/org/apache/hugegraph/store/rocksdb/ZipUtilsPathSafetyTest.java @@ -0,0 +1,159 @@ +/* + * 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(); + 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"); + 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", + absoluteEntry); + + 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()); + // Each attack has its own archive; TemporaryFolder owns cleanup. + } + } + + @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")); + } +} 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)); + } +}