diff --git a/config/checkstyle/checkstyle.xml b/config/checkstyle/checkstyle.xml index 0de7181..829987c 100644 --- a/config/checkstyle/checkstyle.xml +++ b/config/checkstyle/checkstyle.xml @@ -10,6 +10,7 @@ + diff --git a/src/main/java/org/cache/cluster/ClusterInfo.java b/src/main/java/org/cache/cluster/ClusterInfo.java new file mode 100644 index 0000000..c10bd47 --- /dev/null +++ b/src/main/java/org/cache/cluster/ClusterInfo.java @@ -0,0 +1,9 @@ +package org.cache.cluster; + +import java.util.List; + +public record ClusterInfo( + Integer replicationFactor, + List nodes +) { +} diff --git a/src/main/java/org/cache/config/CacheConfig.java b/src/main/java/org/cache/config/CacheConfig.java index 86f5f12..cadad71 100644 --- a/src/main/java/org/cache/config/CacheConfig.java +++ b/src/main/java/org/cache/config/CacheConfig.java @@ -1,6 +1,7 @@ package org.cache.config; import org.cache.cluster.CacheNode; +import org.cache.cluster.ClusterInfo; import org.cache.eviction.EvictionPolicy; import org.cache.protocol.codec.KeyCodec; @@ -9,6 +10,7 @@ public record CacheConfig( long defaultTtlMillis, KeyCodec keyCodec, EvictionPolicy evictionPolicy, - CacheNode cacheNode + CacheNode cacheNode, + ClusterInfo clusterInfo ) { } diff --git a/src/main/java/org/cache/config/CacheConfigException.java b/src/main/java/org/cache/config/CacheConfigException.java new file mode 100644 index 0000000..7c97de6 --- /dev/null +++ b/src/main/java/org/cache/config/CacheConfigException.java @@ -0,0 +1,12 @@ +package org.cache.config; + +public class CacheConfigException extends IllegalArgumentException { + + public CacheConfigException(String message) { + super(message); + } + + public CacheConfigException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/org/cache/config/CacheConfigLoader.java b/src/main/java/org/cache/config/CacheConfigLoader.java index 70753d2..da2f0ce 100644 --- a/src/main/java/org/cache/config/CacheConfigLoader.java +++ b/src/main/java/org/cache/config/CacheConfigLoader.java @@ -1,6 +1,7 @@ package org.cache.config; import org.cache.cluster.CacheNode; +import org.cache.cluster.ClusterInfo; import org.cache.eviction.EvictionPolicy; import org.cache.eviction.EvictionPolicyType; import org.cache.eviction.LruEvictionPolicy; @@ -12,8 +13,12 @@ import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; import java.util.Objects; import java.util.Properties; +import java.util.Set; public final class CacheConfigLoader { @@ -27,6 +32,7 @@ public final class CacheConfigLoader { private static final int DEFAULT_HTTP_PORT = 8080; private static final int DEFAULT_TCP_PORT = 2020; private static final int DEFAULT_CLUSTER_PORT = 10001; + private static final String CLUSTER_NODES = ConfigKey.merge(ConfigKey.CLUSTER, ConfigKey.NODES); private final String configFile; @@ -42,15 +48,17 @@ public CacheConfig load() { Properties properties = loadProperties(); return new CacheConfig( - getInt(properties, ConfigKey.CAPACITY, DEFAULT_CAPACITY), + getInt(properties, ConfigKey.CAPACITY.getPropertyName(), DEFAULT_CAPACITY), getLong(properties, ConfigKey.DEFAULT_TTL_MILLIS, DEFAULT_TTL_MILLIS), - createKeyCodec(KeyType.from(getString(properties, ConfigKey.KEY_TYPE, DEFAULT_KEY_TYPE))), + createKeyCodec(KeyType.from(getString(properties, ConfigKey.KEY_TYPE.getPropertyName(), + DEFAULT_KEY_TYPE))), createEvictionPolicy(EvictionPolicyType.from(getString( properties, - ConfigKey.EVICTION_POLICY, + ConfigKey.EVICTION_POLICY.getPropertyName(), DEFAULT_EVICTION_POLICY ))), - buildCacheNode(properties) + buildCacheNode(properties), + buildClusterInfo(properties) ); } @@ -71,14 +79,91 @@ private Properties loadProperties() { private CacheNode buildCacheNode(Properties properties) { return new CacheNode( - getString(properties, ConfigKey.NODE_ID, DEFAULT_NODE_ID), - getString(properties, ConfigKey.NODE_HOST, DEFAULT_NODE_HOST), - getInt(properties, ConfigKey.NODE_HTTP_PORT, DEFAULT_HTTP_PORT), - getInt(properties, ConfigKey.NODE_TCP_PORT, DEFAULT_TCP_PORT), - getInt(properties, ConfigKey.NODE_CLUSTER_PORT, DEFAULT_CLUSTER_PORT) + getString(properties, ConfigKey.merge(ConfigKey.NODE, ConfigKey.ID), DEFAULT_NODE_ID), + getString(properties, ConfigKey.merge(ConfigKey.NODE, ConfigKey.HOST), DEFAULT_NODE_HOST), + getInt(properties, ConfigKey.merge(ConfigKey.NODE, ConfigKey.HTTP_PORT), DEFAULT_HTTP_PORT), + getInt(properties, ConfigKey.merge(ConfigKey.NODE, ConfigKey.TCP_PORT), DEFAULT_TCP_PORT), + getInt(properties, ConfigKey.merge(ConfigKey.NODE, ConfigKey.CLUSTER_PORT), DEFAULT_CLUSTER_PORT) ); } + private ClusterInfo buildClusterInfo(Properties properties) { + if (!hasClusterConfig(properties)) { + return null; + } + + int replicationFactor = getInt(properties, + ConfigKey.merge(ConfigKey.CLUSTER, ConfigKey.REPLICATION_FACTOR), 1); + + List nodes = new ArrayList<>(); + int index = 0; + + while (true) { + String idKey = clusterNodeKey(index, ConfigKey.ID); + String id = properties.getProperty(idKey); + + if (id == null) { + break; + } + + CacheNode node = new CacheNode( + getString(properties, idKey), + getString(properties, clusterNodeKey(index, ConfigKey.HOST)), + getInt(properties, clusterNodeKey(index, ConfigKey.HTTP_PORT)), + getInt(properties, clusterNodeKey(index, ConfigKey.TCP_PORT)), + getInt(properties, clusterNodeKey(index, ConfigKey.CLUSTER_PORT)) + ); + + nodes.add(node); + index++; + } + + validateClusterInfo(replicationFactor, nodes); + + return new ClusterInfo(replicationFactor, nodes); + } + + private void validateClusterInfo(int replicationFactor, List nodes) { + if (replicationFactor < 1) { + throw new CacheConfigException("Cluster replication factor must be at least 1"); + } + + if (replicationFactor > nodes.size()) { + throw new CacheConfigException("Cluster replication factor must not exceed number of active nodes"); + } + + Set nodeIds = new HashSet<>(); + Set hostPorts = new HashSet<>(); + + for (CacheNode node : nodes) { + if (!nodeIds.add(node.id())) { + throw new CacheConfigException("Cluster node ids must be unique: " + node.id()); + } + + addHostPort(hostPorts, node.host(), node.httpPort()); + addHostPort(hostPorts, node.host(), node.tcpPort()); + addHostPort(hostPorts, node.host(), node.clusterPort()); + } + } + + private void addHostPort(Set hostPorts, String host, int port) { + String hostPort = host + ":" + port; + if (!hostPorts.add(hostPort)) { + throw new CacheConfigException("Cluster node host-port combinations must be unique: " + hostPort); + } + } + + private boolean hasClusterConfig(Properties properties) { + return properties.stringPropertyNames() + .stream() + .anyMatch(key -> key.equals(ConfigKey.CLUSTER.getPropertyName()) + || key.startsWith(ConfigKey.CLUSTER.getPropertyName() + ".")); + } + + private String clusterNodeKey(int index, ConfigKey field) { + return CLUSTER_NODES + "[" + index + "]." + field.getPropertyName(); + } + @SuppressWarnings("unchecked") private KeyCodec createKeyCodec(KeyType keyType) { return switch (keyType) { @@ -94,8 +179,8 @@ private EvictionPolicy createEvictionPolicy(EvictionPolicyType policy) { }; } - private String getString(Properties properties, ConfigKey key, String defaultValue) { - String value = properties.getProperty(key.getPropertyName()); + private String getString(Properties properties, String key, String defaultValue) { + String value = properties.getProperty(key); if (value == null || value.isBlank()) { return defaultValue; } @@ -103,8 +188,17 @@ private String getString(Properties properties, ConfigKey key, String defaultVal return value.trim(); } - private int getInt(Properties properties, ConfigKey key, int defaultValue) { - String value = properties.getProperty(key.getPropertyName()); + private String getString(Properties properties, String key) { + String value = properties.getProperty(key); + if (value == null || value.isBlank()) { + throw new CacheConfigException("Missing required configuration key: " + key); + } + + return value.trim(); + } + + private int getInt(Properties properties, String key, int defaultValue) { + String value = properties.getProperty(key); if (value == null || value.isBlank()) { return defaultValue; } @@ -112,7 +206,17 @@ private int getInt(Properties properties, ConfigKey key, int defaultValue) { try { return Integer.parseInt(value.trim()); } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid integer value for configuration key '" + key.getPropertyName() + "': " + value, e); + throw new CacheConfigException("Invalid integer value for configuration key '" + key + "': " + value, e); + } + } + + private int getInt(Properties properties, String key) { + String value = getString(properties, key); + + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + throw new CacheConfigException("Invalid integer value for configuration key '" + key + "': " + value, e); } } @@ -125,7 +229,8 @@ private long getLong(Properties properties, ConfigKey key, long defaultValue) { try { return Long.parseLong(value.trim()); } catch (NumberFormatException e) { - throw new IllegalArgumentException("Invalid long value for configuration key '" + key.getPropertyName() + "': " + value, e); + throw new CacheConfigException("Invalid long value for configuration key '" + key.getPropertyName() + + "': " + value, e); } } } diff --git a/src/main/java/org/cache/config/ConfigKey.java b/src/main/java/org/cache/config/ConfigKey.java index 8bbe609..a58e29c 100644 --- a/src/main/java/org/cache/config/ConfigKey.java +++ b/src/main/java/org/cache/config/ConfigKey.java @@ -5,11 +5,15 @@ public enum ConfigKey { DEFAULT_TTL_MILLIS("defaultTtlMillis"), KEY_TYPE("key-type"), EVICTION_POLICY("eviction-policy"), - NODE_ID("node.id"), - NODE_HOST("node.host"), - NODE_HTTP_PORT("node.http-port"), - NODE_TCP_PORT("node.tcp-port"), - NODE_CLUSTER_PORT("node.cluster-port"); + NODE("node"), + CLUSTER("cluster"), + NODES("nodes"), + ID("id"), + HOST("host"), + HTTP_PORT("http-port"), + TCP_PORT("tcp-port"), + CLUSTER_PORT("cluster-port"), + REPLICATION_FACTOR("replication-factor"); private final String propertyName; @@ -20,4 +24,14 @@ public enum ConfigKey { public String getPropertyName() { return propertyName; } + + public static String merge(ConfigKey first, ConfigKey... others) { + StringBuilder propertyName = new StringBuilder(first.getPropertyName()); + + for (ConfigKey key : others) { + propertyName.append('.').append(key.getPropertyName()); + } + + return propertyName.toString(); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index b968853..e69dff9 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -9,3 +9,24 @@ node: http-port: 8080 tcp-port: 2020 cluster-port: 10001 + +cluster: + replication-factor: 2 + nodes: + - id: node-a + host: localhost + http-port: 8080 + tcp-port: 2020 + cluster-port: 10001 + + - id: node-b + host: localhost + http-port: 9001 + tcp-port: 9002 + cluster-port: 10002 + + - id: node-c + host: localhost + http-port: 9003 + tcp-port: 9004 + cluster-port: 10003 \ No newline at end of file diff --git a/src/test/java/org/cache/config/CacheConfigLoaderTest.java b/src/test/java/org/cache/config/CacheConfigLoaderTest.java index 6661443..0d3f308 100644 --- a/src/test/java/org/cache/config/CacheConfigLoaderTest.java +++ b/src/test/java/org/cache/config/CacheConfigLoaderTest.java @@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; class CacheConfigLoaderTest { @@ -25,6 +27,7 @@ void loadUsesDefaultsWhenConfigFileDoesNotExist() { assertEquals(8080, config.cacheNode().httpPort()); assertEquals(2020, config.cacheNode().tcpPort()); assertEquals(10001, config.cacheNode().clusterPort()); + assertNull(config.clusterInfo()); } @Test @@ -41,6 +44,66 @@ void loadReadsConfiguredValues() { assertEquals(18080, config.cacheNode().httpPort()); assertEquals(12020, config.cacheNode().tcpPort()); assertEquals(11001, config.cacheNode().clusterPort()); + assertNull(config.clusterInfo()); + } + + @Test + void loadReadsClusterValuesWhenConfigured() { + CacheConfig config = new CacheConfigLoader("config/cluster-config.yml").load(); + + assertNotNull(config.clusterInfo()); + assertEquals(2, config.clusterInfo().replicationFactor()); + assertEquals(3, config.clusterInfo().nodes().size()); + assertEquals("node-a", config.clusterInfo().nodes().getFirst().id()); + } + + @Test + void loadUsesDefaultReplicationFactorWhenClusterOmitsIt() { + CacheConfig config = new CacheConfigLoader("config/cluster-config-default-replication-factor.yml").load(); + + assertNotNull(config.clusterInfo()); + assertEquals(1, config.clusterInfo().replicationFactor()); + assertEquals(1, config.clusterInfo().nodes().size()); + } + + @Test + void loadRejectsReplicationFactorLessThanOne() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> new CacheConfigLoader("config/invalid-cluster-replication-factor-too-small.yml").load() + ); + + assertEquals("Cluster replication factor must be at least 1", exception.getMessage()); + } + + @Test + void loadRejectsReplicationFactorGreaterThanActiveNodes() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> new CacheConfigLoader("config/invalid-cluster-replication-factor-too-large.yml").load() + ); + + assertEquals("Cluster replication factor must not exceed number of active nodes", exception.getMessage()); + } + + @Test + void loadRejectsDuplicateClusterNodeIds() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> new CacheConfigLoader("config/invalid-cluster-duplicate-node-id.yml").load() + ); + + assertEquals("Cluster node ids must be unique: node-a", exception.getMessage()); + } + + @Test + void loadRejectsDuplicateClusterHostPorts() { + var exception = assertThrows( + IllegalArgumentException.class, + () -> new CacheConfigLoader("config/invalid-cluster-duplicate-host-port.yml").load() + ); + + assertEquals("Cluster node host-port combinations must be unique: localhost:2020", exception.getMessage()); } @Test diff --git a/src/test/resources/config/cluster-config-default-replication-factor.yml b/src/test/resources/config/cluster-config-default-replication-factor.yml new file mode 100644 index 0000000..4e5866d --- /dev/null +++ b/src/test/resources/config/cluster-config-default-replication-factor.yml @@ -0,0 +1,7 @@ +cluster: + nodes: + - id: node-a + host: localhost + http-port: 8080 + tcp-port: 2020 + cluster-port: 10001 diff --git a/src/test/resources/config/cluster-config.yml b/src/test/resources/config/cluster-config.yml new file mode 100644 index 0000000..c3fde0c --- /dev/null +++ b/src/test/resources/config/cluster-config.yml @@ -0,0 +1,20 @@ +cluster: + replication-factor: 2 + nodes: + - id: node-a + host: localhost + http-port: 8080 + tcp-port: 2020 + cluster-port: 10001 + + - id: node-b + host: localhost + http-port: 9001 + tcp-port: 9002 + cluster-port: 10002 + + - id: node-c + host: localhost + http-port: 9003 + tcp-port: 9004 + cluster-port: 10003 diff --git a/src/test/resources/config/invalid-cluster-duplicate-host-port.yml b/src/test/resources/config/invalid-cluster-duplicate-host-port.yml new file mode 100644 index 0000000..72041a9 --- /dev/null +++ b/src/test/resources/config/invalid-cluster-duplicate-host-port.yml @@ -0,0 +1,14 @@ +cluster: + replication-factor: 1 + nodes: + - id: node-a + host: localhost + http-port: 8080 + tcp-port: 2020 + cluster-port: 10001 + + - id: node-b + host: localhost + http-port: 9001 + tcp-port: 2020 + cluster-port: 10002 diff --git a/src/test/resources/config/invalid-cluster-duplicate-node-id.yml b/src/test/resources/config/invalid-cluster-duplicate-node-id.yml new file mode 100644 index 0000000..5662183 --- /dev/null +++ b/src/test/resources/config/invalid-cluster-duplicate-node-id.yml @@ -0,0 +1,14 @@ +cluster: + replication-factor: 1 + nodes: + - id: node-a + host: localhost + http-port: 8080 + tcp-port: 2020 + cluster-port: 10001 + + - id: node-a + host: localhost + http-port: 9001 + tcp-port: 9002 + cluster-port: 10002 diff --git a/src/test/resources/config/invalid-cluster-replication-factor-too-large.yml b/src/test/resources/config/invalid-cluster-replication-factor-too-large.yml new file mode 100644 index 0000000..24c12c7 --- /dev/null +++ b/src/test/resources/config/invalid-cluster-replication-factor-too-large.yml @@ -0,0 +1,8 @@ +cluster: + replication-factor: 2 + nodes: + - id: node-a + host: localhost + http-port: 8080 + tcp-port: 2020 + cluster-port: 10001 diff --git a/src/test/resources/config/invalid-cluster-replication-factor-too-small.yml b/src/test/resources/config/invalid-cluster-replication-factor-too-small.yml new file mode 100644 index 0000000..9852dab --- /dev/null +++ b/src/test/resources/config/invalid-cluster-replication-factor-too-small.yml @@ -0,0 +1,8 @@ +cluster: + replication-factor: 0 + nodes: + - id: node-a + host: localhost + http-port: 8080 + tcp-port: 2020 + cluster-port: 10001