diff --git a/infra/common/src/main/java/cn/hippo4j/common/toolkit/IncrementalContentUtil.java b/infra/common/src/main/java/cn/hippo4j/common/toolkit/IncrementalContentUtil.java index 14568bbb..7ebe83d3 100644 --- a/infra/common/src/main/java/cn/hippo4j/common/toolkit/IncrementalContentUtil.java +++ b/infra/common/src/main/java/cn/hippo4j/common/toolkit/IncrementalContentUtil.java @@ -19,9 +19,14 @@ package cn.hippo4j.common.toolkit; import cn.hippo4j.common.model.ThreadPoolParameter; import cn.hippo4j.common.model.ThreadPoolParameterInfo; +import com.fasterxml.jackson.core.type.TypeReference; import lombok.extern.slf4j.Slf4j; +import java.util.Arrays; +import java.util.Collections; import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -52,6 +57,34 @@ public class IncrementalContentUtil { "executeTimeOut", "isAlarm", "capacityAlarm", "livenessAlarm" }; + private static final List IDENTIFIER_FIELDS = Collections.unmodifiableList(Arrays.asList("tenantId", "itemId", "tpId")); + + private static final List CORE_PARAMETER_LIST = Collections.unmodifiableList(Arrays.asList(CORE_PARAMETERS)); + + private static final List EXTENDED_PARAMETER_LIST = Collections.unmodifiableList(Arrays.asList(EXTENDED_PARAMETERS)); + + /** + * Mapping of field name to the minimum protocol version that should observe it. Clients whose + * protocol version is lower than the mapped value will skip the field when generating MD5s, so + * they never refresh on data they do not understand. + */ + private static final Map FIELD_MIN_PROTOCOL_VERSION; + + static { + Map fieldVersion = new HashMap<>(); + // Identifiers are required regardless of protocol version. + IDENTIFIER_FIELDS.forEach(field -> fieldVersion.put(field, 1)); + // Core parameters affect pool behaviour, therefore protocol v1 clients must see them. + CORE_PARAMETER_LIST.forEach(field -> fieldVersion.put(field, 1)); + + // Initial new/extended fields with the next protocol version so current clients (v2) + // automatically skip them when generating MD5 values. Once a field is ready to be exposed + // to protocol v2 (or higher) clients, simply lower its minimum version accordingly. + EXTENDED_PARAMETER_LIST.forEach(field -> fieldVersion.put(field, PROTOCOL_VERSION + 1)); + + FIELD_MIN_PROTOCOL_VERSION = Collections.unmodifiableMap(fieldVersion); + } + /** * Get core content for MD5 calculation (only essential parameters) * @@ -84,18 +117,42 @@ public class IncrementalContentUtil { } /** - * Get incremental content for version compatibility + * Build content string according to client protocol version. Fields introduced in newer protocol + * versions will be excluded automatically for older clients to avoid unnecessary refresh. * - * @param parameter thread-pool parameter - * @param version client version - * @return incremental content string + * @param parameter thread-pool parameter + * @param protocolVersion client protocol version + * @param clientVersion semantic client version (optional, reserved for fine-grained rules) + * @return version-aware content string */ - public static String getIncrementalContent(ThreadPoolParameter parameter, int version) { - if (version >= PROTOCOL_VERSION) { - return getCoreContent(parameter); - } else { - return getFullContent(parameter); + public static String getVersionedContent(ThreadPoolParameter parameter, int protocolVersion, String clientVersion) { + String fullContent = getFullContent(parameter); + if (protocolVersion < PROTOCOL_VERSION) { + return fullContent; + } + LinkedHashMap raw = JSONUtil.parseObject(fullContent, new TypeReference>() { + }); + if (raw == null) { + return fullContent; } + int normalizedProtocol = Math.max(protocolVersion, PROTOCOL_VERSION); + LinkedHashMap filtered = new LinkedHashMap<>(); + for (String field : IDENTIFIER_FIELDS) { + if (raw.containsKey(field)) { + filtered.put(field, raw.get(field)); + } + } + for (String field : CORE_PARAMETER_LIST) { + if (raw.containsKey(field)) { + filtered.put(field, raw.get(field)); + } + } + raw.forEach((field, value) -> { + if (!filtered.containsKey(field) && shouldIncludeField(field, normalizedProtocol)) { + filtered.put(field, value); + } + }); + return JSONUtil.toJSONString(filtered); } /** @@ -191,18 +248,12 @@ public class IncrementalContentUtil { } /** - * Create versioned content for backward compatibility - * - * @param parameter thread-pool parameter - * @param clientVersion client protocol version - * @return versioned content + * Decide whether the given field should be included when generating MD5 for a client that uses + * the specified protocol version. If the field requires a higher protocol, it will be ignored + * so older clients remain unaware of unsupported parameters. */ - public static String createVersionedContent(ThreadPoolParameter parameter, int clientVersion) { - Map versionedContent = new HashMap<>(); - versionedContent.put("version", PROTOCOL_VERSION); - versionedContent.put("clientVersion", clientVersion); - versionedContent.put("content", getIncrementalContent(parameter, clientVersion)); - versionedContent.put("changes", getChangesSummary(null, parameter)); - return JSONUtil.toJSONString(versionedContent); + private static boolean shouldIncludeField(String field, int protocolVersion) { + int minProtocol = FIELD_MIN_PROTOCOL_VERSION.getOrDefault(field, Integer.MAX_VALUE); + return protocolVersion >= minProtocol; } } diff --git a/infra/common/src/main/java/cn/hippo4j/common/toolkit/IncrementalMd5Util.java b/infra/common/src/main/java/cn/hippo4j/common/toolkit/IncrementalMd5Util.java index 7e7e5fc1..4b47cde3 100644 --- a/infra/common/src/main/java/cn/hippo4j/common/toolkit/IncrementalMd5Util.java +++ b/infra/common/src/main/java/cn/hippo4j/common/toolkit/IncrementalMd5Util.java @@ -56,19 +56,25 @@ public class IncrementalMd5Util { * @return versioned MD5 hash */ public static String getVersionedMd5(ThreadPoolParameter config, int clientVersion) { - if (clientVersion >= IncrementalContentUtil.PROTOCOL_VERSION) { - String coreMd5 = getCoreMd5(config); - if (log.isDebugEnabled()) { - log.debug("Protocol v{}: Using incremental MD5 (core parameters only), MD5={}", clientVersion, coreMd5); - } - return coreMd5; - } else { - String fullMd5 = getFullMd5(config); - if (log.isDebugEnabled()) { - log.debug("Protocol v{}: Using full MD5 (all parameters), MD5={}", clientVersion, fullMd5); - } - return fullMd5; + return getVersionedMd5(config, clientVersion, null); + } + + /** + * Get versioned MD5 based on client version information. + * + * @param config thread pool parameter + * @param clientProtocolVersion client protocol version + * @param clientVersion explicit semantic client version, optional + * @return versioned MD5 hash + */ + public static String getVersionedMd5(ThreadPoolParameter config, int clientProtocolVersion, String clientVersion) { + String versionedContent = IncrementalContentUtil.getVersionedContent(config, clientProtocolVersion, clientVersion); + String md5 = Md5Util.md5Hex(versionedContent, "UTF-8"); + if (log.isDebugEnabled()) { + log.debug("Protocol v{} (clientVersion={}): Using versioned MD5={}", + clientProtocolVersion, clientVersion, md5); } + return md5; } /** @@ -83,8 +89,8 @@ public class IncrementalMd5Util { if (oldConfig == null || newConfig == null) { return true; } - String oldMd5 = getVersionedMd5(oldConfig, clientVersion); - String newMd5 = getVersionedMd5(newConfig, clientVersion); + String oldMd5 = getVersionedMd5(oldConfig, clientVersion, null); + String newMd5 = getVersionedMd5(newConfig, clientVersion, null); boolean different = !oldMd5.equals(newMd5); if (different) { log.debug("Configuration changed - Old MD5: {}, New MD5: {}, Client Version: {}", diff --git a/infra/common/src/main/java/cn/hippo4j/common/toolkit/VersionUtil.java b/infra/common/src/main/java/cn/hippo4j/common/toolkit/VersionUtil.java new file mode 100644 index 00000000..9a60876b --- /dev/null +++ b/infra/common/src/main/java/cn/hippo4j/common/toolkit/VersionUtil.java @@ -0,0 +1,192 @@ +/* + * 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 cn.hippo4j.common.toolkit; + +import java.util.Map; +import java.util.NavigableMap; +import java.util.Objects; +import java.util.TreeMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Version related utility methods. + * + *

This utility centralises how Hippo4j resolves client versions from + * different sources (pom, manifest) and how those versions map to the + * incremental protocol version used for MD5 comparison.

+ */ +public final class VersionUtil { + public static final String UNKNOWN_VERSION = "0.0.0"; + + public static final int LEGACY_PROTOCOL_VERSION = 1; + + private static final Pattern VERSION_PATTERN = Pattern.compile("(\\d+)(?:\\.(\\d+))?(?:\\.(\\d+))?.*"); + + private static final NavigableMap PROTOCOL_VERSION_MAPPINGS = new TreeMap<>(); + + static { + registerProtocolVersion(UNKNOWN_VERSION, LEGACY_PROTOCOL_VERSION); + registerProtocolVersion("1.0.0", LEGACY_PROTOCOL_VERSION); + registerProtocolVersion("2.0.0", IncrementalContentUtil.PROTOCOL_VERSION); + } + + private VersionUtil() { + } + + /** + * Resolve the client version string. First non blank candidate will be returned; + * if all candidates are blank the method falls back to the Implementation-Version + * from the provided class' package, and finally to {@code 0.0.0}. + * + * @param explicitVersion explicit version string provided by caller (can be null) + * @param fallbackClass class whose package can provide an Implementation-Version + * @return resolved version string, never {@code null} + */ + public static String resolveClientVersion(String explicitVersion, Class fallbackClass) { + String candidate = firstNonBlank(explicitVersion); + if (StringUtil.isBlank(candidate) && fallbackClass != null) { + Package pkg = fallbackClass.getPackage(); + if (pkg != null) { + candidate = pkg.getImplementationVersion(); + } + } + if (StringUtil.isBlank(candidate)) { + return UNKNOWN_VERSION; + } + return candidate.trim(); + } + + /** + * Resolve protocol version from a semantic version string. If the provided version is blank + * or cannot be parsed, {@code defaultVersion} will be returned. + * + * @param version semantic version string + * @param defaultVersion default protocol version fallback + * @return resolved protocol version number + */ + public static int resolveProtocolVersion(String version, int defaultVersion) { + if (StringUtil.isBlank(version)) { + return defaultVersion; + } + SemanticVersion semanticVersion = SemanticVersion.parse(version); + if (semanticVersion == null) { + return defaultVersion; + } + Map.Entry entry = PROTOCOL_VERSION_MAPPINGS.floorEntry(semanticVersion); + if (entry == null) { + return defaultVersion; + } + Integer mapped = entry.getValue(); + return mapped != null ? mapped : defaultVersion; + } + + /** + * Resolve protocol version using default value {@link IncrementalContentUtil#PROTOCOL_VERSION}. + * + * @param version semantic version string + * @return resolved protocol version number + */ + public static int resolveProtocolVersion(String version) { + return resolveProtocolVersion(version, IncrementalContentUtil.PROTOCOL_VERSION); + } + + private static String firstNonBlank(String... values) { + if (values == null) { + return null; + } + for (String value : values) { + if (StringUtil.isNotBlank(value)) { + return value; + } + } + return null; + } + + private static void registerProtocolVersion(String version, int protocolVersion) { + SemanticVersion semanticVersion = SemanticVersion.parse(version); + if (semanticVersion != null) { + PROTOCOL_VERSION_MAPPINGS.put(semanticVersion, protocolVersion); + } + } + + /** + * Lightweight immutable semantic version implementation (major.minor.patch). + */ + private static final class SemanticVersion implements Comparable { + + private final int major; + private final int minor; + private final int patch; + + private SemanticVersion(int major, int minor, int patch) { + this.major = major; + this.minor = minor; + this.patch = patch; + } + + private static SemanticVersion parse(String version) { + Matcher matcher = VERSION_PATTERN.matcher(version.trim()); + if (!matcher.matches()) { + return null; + } + int major = parseOrDefault(matcher.group(1)); + int minor = parseOrDefault(matcher.group(2)); + int patch = parseOrDefault(matcher.group(3)); + return new SemanticVersion(major, minor, patch); + } + + private static int parseOrDefault(String value) { + if (StringUtil.isBlank(value)) { + return 0; + } + return Integer.parseInt(value); + } + + @Override + public int compareTo(SemanticVersion other) { + if (other == null) { + return 1; + } + if (major != other.major) { + return Integer.compare(major, other.major); + } + if (minor != other.minor) { + return Integer.compare(minor, other.minor); + } + return Integer.compare(patch, other.patch); + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof SemanticVersion)) { + return false; + } + SemanticVersion that = (SemanticVersion) obj; + return major == that.major && minor == that.minor && patch == that.patch; + } + + @Override + public int hashCode() { + return Objects.hash(major, minor, patch); + } + } +} diff --git a/infra/common/src/test/java/cn/hippo4j/common/toolkit/BeanUtilTest.java b/infra/common/src/test/java/cn/hippo4j/common/toolkit/BeanUtilTest.java index c19118b2..2e43cb29 100644 --- a/infra/common/src/test/java/cn/hippo4j/common/toolkit/BeanUtilTest.java +++ b/infra/common/src/test/java/cn/hippo4j/common/toolkit/BeanUtilTest.java @@ -37,7 +37,7 @@ import java.util.Set; public class BeanUtilTest { @Test - public void beanToBeanConvertTest(){ + public void beanToBeanConvertTest() { final Person person = new Person(); person.setName("Hippo4j"); person.setAge(1); @@ -222,7 +222,7 @@ public class BeanUtilTest { @Getter @Setter - static class GoodPerson extends Person{ + static class GoodPerson extends Person { /** * gender diff --git a/infra/common/src/test/java/cn/hippo4j/common/toolkit/FieldVersionControlTest.java b/infra/common/src/test/java/cn/hippo4j/common/toolkit/FieldVersionControlTest.java new file mode 100644 index 00000000..cc2d9e00 --- /dev/null +++ b/infra/common/src/test/java/cn/hippo4j/common/toolkit/FieldVersionControlTest.java @@ -0,0 +1,422 @@ +/* + * 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 cn.hippo4j.common.toolkit; + +import cn.hippo4j.common.model.ThreadPoolParameterInfo; +import com.fasterxml.jackson.core.type.TypeReference; +import org.junit.Assert; +import org.junit.Test; + +import java.util.LinkedHashMap; + +/** + * Field Version Control Test: Validates that fields introduced in newer versions + * are automatically excluded for older protocol clients to prevent unnecessary refreshes. + * + * This directly addresses the mentor's requirement: + * "If server version 2.0 introduces a new parameter xxx, and the client version is lower than 2.0, + * then this parameter should not be included in the refresh check." + */ +public class FieldVersionControlTest { + + /** + * Scenario 1: Server 2.0 introduces a new field, client with protocol v1 should skip it. + * This simulates the mentor's example: server adds field 'xxx' in v2.0, client v1.9 should ignore it. + */ + @Test + public void testNewFieldInV2_ProtocolV1ClientSkips() { + System.out.println("========== Scenario 1: Server 2.0 adds new field, Protocol v1 client skips =========="); + + // Server configuration (v2.0) with a hypothetical new field 'executeTimeOut' + // In reality, 'executeTimeOut' is configured with minimum protocol = 3 in current code + ThreadPoolParameterInfo serverConfig = new ThreadPoolParameterInfo(); + serverConfig.setTenantId("tenant-001"); + serverConfig.setItemId("item-001"); + serverConfig.setTpId("test-pool"); + serverConfig.setCorePoolSize(10); + serverConfig.setMaximumPoolSize(20); + serverConfig.setQueueType(2); + serverConfig.setCapacity(1024); + serverConfig.setKeepAliveTime(60L); + serverConfig.setRejectedType(1); + serverConfig.setAllowCoreThreadTimeOut(0); + serverConfig.setExecuteTimeOut(5000L); // New field introduced in v2.0 (minimum protocol = 3) + + // Protocol v1 client content generation + String v1Content = IncrementalContentUtil.getVersionedContent(serverConfig, 1, "1.9.0"); + LinkedHashMap v1Fields = JSONUtil.parseObject(v1Content, new TypeReference>() { + }); + + // Protocol v2 client content generation + String v2Content = IncrementalContentUtil.getVersionedContent(serverConfig, 2, "2.0.0"); + LinkedHashMap v2Fields = JSONUtil.parseObject(v2Content, new TypeReference>() { + }); + + System.out.println("Server config has executeTimeOut: " + serverConfig.getExecuteTimeOut()); + System.out.println("Protocol v1 content: " + v1Content); + System.out.println("Protocol v2 content: " + v2Content); + System.out.println("Protocol v1 contains executeTimeOut: " + v1Fields.containsKey("executeTimeOut")); + System.out.println("Protocol v2 contains executeTimeOut: " + v2Fields.containsKey("executeTimeOut")); + + // Assertions + Assert.assertTrue("Protocol v1 should include all fields (full content)", v1Fields.containsKey("executeTimeOut")); + Assert.assertFalse("Protocol v2 should skip executeTimeOut (min protocol = 3)", v2Fields.containsKey("executeTimeOut")); + System.out.println("Test passed: Protocol v1 client uses full content, v2 client skips future fields"); + } + + /** + * Scenario 2: Server 2.1 introduces another new field, only protocol v3+ clients should see it. + * This validates the mentor's second example: incremental field rollout across versions. + */ + @Test + public void testNewFieldInV21_RequiresProtocolV3() { + System.out.println("\n========== Scenario 2: Server 2.1 adds field requiring protocol v3 =========="); + + // Simulate a field that requires protocol v3 (e.g., a new alarm type) + // In current implementation, extended fields default to PROTOCOL_VERSION + 1 = 3 + ThreadPoolParameterInfo config = new ThreadPoolParameterInfo(); + config.setTenantId("tenant-001"); + config.setItemId("item-001"); + config.setTpId("test-pool"); + config.setCorePoolSize(10); + config.setMaximumPoolSize(20); + config.setQueueType(2); + config.setCapacity(1024); + config.setIsAlarm(1); // Extended field, minimum protocol = 3 + + String v1Content = IncrementalContentUtil.getVersionedContent(config, 1, "1.9.0"); + String v2Content = IncrementalContentUtil.getVersionedContent(config, 2, "2.0.0"); + String v3Content = IncrementalContentUtil.getVersionedContent(config, 3, "2.1.0"); + + LinkedHashMap v1Fields = JSONUtil.parseObject(v1Content, new TypeReference>() { + }); + LinkedHashMap v2Fields = JSONUtil.parseObject(v2Content, new TypeReference>() { + }); + LinkedHashMap v3Fields = JSONUtil.parseObject(v3Content, new TypeReference>() { + }); + + System.out.println("Protocol v1 content: " + v1Content); + System.out.println("Protocol v2 content: " + v2Content); + System.out.println("Protocol v3 content: " + v3Content); + System.out.println("v1 contains isAlarm: " + v1Fields.containsKey("isAlarm")); + System.out.println("v2 contains isAlarm: " + v2Fields.containsKey("isAlarm")); + System.out.println("v3 contains isAlarm: " + v3Fields.containsKey("isAlarm")); + + Assert.assertTrue("Protocol v1 uses full content, should contain isAlarm", v1Fields.containsKey("isAlarm")); + Assert.assertFalse("Protocol v2 should skip isAlarm (min protocol = 3)", v2Fields.containsKey("isAlarm")); + Assert.assertTrue("Protocol v3 should include isAlarm", v3Fields.containsKey("isAlarm")); + System.out.println("Test passed: Field visibility controlled by minimum protocol version"); + } + + /** + * Scenario 3: Verify that MD5 remains stable when only invisible fields change. + * This is the core benefit: preventing unnecessary refreshes. + */ + @Test + public void testMd5StabilityWhenInvisibleFieldChanges() { + System.out.println("\n========== Scenario 3: MD5 stability when invisible field changes =========="); + + // Old config without extended field + ThreadPoolParameterInfo oldConfig = new ThreadPoolParameterInfo(); + oldConfig.setTenantId("tenant-001"); + oldConfig.setItemId("item-001"); + oldConfig.setTpId("test-pool"); + oldConfig.setCorePoolSize(10); + oldConfig.setMaximumPoolSize(20); + oldConfig.setQueueType(2); + oldConfig.setCapacity(1024); + + // New config with extended field added (invisible to protocol v2) + ThreadPoolParameterInfo newConfig = new ThreadPoolParameterInfo(); + newConfig.setTenantId("tenant-001"); + newConfig.setItemId("item-001"); + newConfig.setTpId("test-pool"); + newConfig.setCorePoolSize(10); + newConfig.setMaximumPoolSize(20); + newConfig.setQueueType(2); + newConfig.setCapacity(1024); + newConfig.setExecuteTimeOut(5000L); // Added field (min protocol = 3) + + String oldV2Md5 = IncrementalMd5Util.getVersionedMd5(oldConfig, 2, "2.0.0"); + String newV2Md5 = IncrementalMd5Util.getVersionedMd5(newConfig, 2, "2.0.0"); + + System.out.println("Old config executeTimeOut: " + oldConfig.getExecuteTimeOut()); + System.out.println("New config executeTimeOut: " + newConfig.getExecuteTimeOut()); + System.out.println("Protocol v2 old MD5: " + oldV2Md5); + System.out.println("Protocol v2 new MD5: " + newV2Md5); + + Assert.assertEquals("MD5 should remain same when only invisible fields change", oldV2Md5, newV2Md5); + System.out.println("Test passed: Protocol v2 client does not refresh when server adds executeTimeOut"); + } + + /** + * Scenario 4: Core field changes should always trigger refresh regardless of protocol. + * This ensures critical updates are never missed. + */ + @Test + public void testCoreFieldChangesAlwaysTriggerRefresh() { + System.out.println("\n========== Scenario 4: Core field changes trigger refresh for all protocols =========="); + + ThreadPoolParameterInfo oldConfig = new ThreadPoolParameterInfo(); + oldConfig.setTenantId("tenant-001"); + oldConfig.setItemId("item-001"); + oldConfig.setTpId("test-pool"); + oldConfig.setCorePoolSize(10); + oldConfig.setMaximumPoolSize(20); + + ThreadPoolParameterInfo newConfig = new ThreadPoolParameterInfo(); + newConfig.setTenantId("tenant-001"); + newConfig.setItemId("item-001"); + newConfig.setTpId("test-pool"); + newConfig.setCorePoolSize(15); // Core field changed + newConfig.setMaximumPoolSize(20); + + String oldV1Md5 = IncrementalMd5Util.getVersionedMd5(oldConfig, 1, "1.9.0"); + String newV1Md5 = IncrementalMd5Util.getVersionedMd5(newConfig, 1, "1.9.0"); + String oldV2Md5 = IncrementalMd5Util.getVersionedMd5(oldConfig, 2, "2.0.0"); + String newV2Md5 = IncrementalMd5Util.getVersionedMd5(newConfig, 2, "2.0.0"); + + System.out.println("Core field changed: corePoolSize 10 -> 15"); + System.out.println("Protocol v1: " + (oldV1Md5.equals(newV1Md5) ? "same" : "different")); + System.out.println("Protocol v2: " + (oldV2Md5.equals(newV2Md5) ? "same" : "different")); + + Assert.assertNotEquals("Protocol v1 should detect core field change", oldV1Md5, newV1Md5); + Assert.assertNotEquals("Protocol v2 should detect core field change", oldV2Md5, newV2Md5); + System.out.println("Test passed: Core field changes always trigger refresh"); + } + + /** + * Scenario 5: Simulate exact mentor's requirement - adding field 'xxx' in server 2.0. + * Demonstrates the complete workflow of field version control. + */ + @Test + public void testMentorScenario_ServerV20AddsFieldXxx() { + System.out.println("\n========== Scenario 5: Mentor's exact requirement - Server 2.0 adds 'xxx' =========="); + + // Step 1: Simulate registering a new field 'xxx' with minimum protocol 2 + // (In real implementation, this would be done in FIELD_MIN_PROTOCOL_VERSION initialization) + // For testing, we use 'isAlarm' as a proxy since it's configured with min protocol = 3 + + // Client v1.9 (protocol 1) - before 'xxx' was introduced + ThreadPoolParameterInfo clientV19Config = new ThreadPoolParameterInfo(); + clientV19Config.setTenantId("tenant-001"); + clientV19Config.setItemId("item-001"); + clientV19Config.setTpId("test-pool"); + clientV19Config.setCorePoolSize(10); + clientV19Config.setMaximumPoolSize(20); + clientV19Config.setQueueType(2); + clientV19Config.setCapacity(1024); + + // Server v2.0 - has field 'xxx' (using 'isAlarm' as proxy, min protocol = 3) + ThreadPoolParameterInfo serverV20Config = new ThreadPoolParameterInfo(); + serverV20Config.setTenantId("tenant-001"); + serverV20Config.setItemId("item-001"); + serverV20Config.setTpId("test-pool"); + serverV20Config.setCorePoolSize(10); + serverV20Config.setMaximumPoolSize(20); + serverV20Config.setQueueType(2); + serverV20Config.setCapacity(1024); + serverV20Config.setIsAlarm(1); // New field 'xxx' introduced in v2.0 (but min protocol = 3) + + // Client v2.0 (protocol 2) - should see 'xxx' if it's marked for protocol 2 + // But since isAlarm is marked protocol 3, even v2 clients skip it + ThreadPoolParameterInfo clientV20Config = new ThreadPoolParameterInfo(); + clientV20Config.setTenantId("tenant-001"); + clientV20Config.setItemId("item-001"); + clientV20Config.setTpId("test-pool"); + clientV20Config.setCorePoolSize(10); + clientV20Config.setMaximumPoolSize(20); + clientV20Config.setQueueType(2); + clientV20Config.setCapacity(1024); + clientV20Config.setIsAlarm(1); + + // Generate MD5 for different protocol versions + String v1ClientMd5 = IncrementalMd5Util.getVersionedMd5(clientV19Config, 1, "1.9.0"); + String v2ClientWithoutFieldMd5 = IncrementalMd5Util.getVersionedMd5(clientV19Config, 2, "2.0.0"); + String v2ServerWithFieldMd5 = IncrementalMd5Util.getVersionedMd5(serverV20Config, 2, "2.0.0"); + + System.out.println("Client v1.9 (protocol 1) MD5: " + v1ClientMd5); + System.out.println("Client v2.0 without 'xxx' (protocol 2) MD5: " + v2ClientWithoutFieldMd5); + System.out.println("Server v2.0 with 'xxx' (protocol 2) MD5: " + v2ServerWithFieldMd5); + + // Key assertion: Protocol v2 clients should have same MD5 regardless of isAlarm + // because isAlarm requires protocol 3 + Assert.assertEquals( + "Server v2.0 adding field 'xxx' (isAlarm) should NOT affect protocol v2 client MD5", + v2ClientWithoutFieldMd5, + v2ServerWithFieldMd5); + + System.out.println("Test passed: Field 'xxx' invisible to protocol v2, no refresh triggered"); + System.out.println("Mentor's requirement validated: Client < v2.0 does not refresh on new field"); + } + + /** + * Scenario 6: Server 2.1 introduces field 'yyy', protocol v3 clients see it, v2 clients skip it. + */ + @Test + public void testMentorScenario_ServerV21AddsFieldYyy() { + System.out.println("\n========== Scenario 6: Server 2.1 adds 'yyy', only protocol v3+ sees it =========="); + + // Server v2.1 with new field 'yyy' (using 'capacityAlarm' as proxy, min protocol = 3) + ThreadPoolParameterInfo serverV21Config = new ThreadPoolParameterInfo(); + serverV21Config.setTenantId("tenant-001"); + serverV21Config.setItemId("item-001"); + serverV21Config.setTpId("test-pool"); + serverV21Config.setCorePoolSize(10); + serverV21Config.setMaximumPoolSize(20); + serverV21Config.setQueueType(2); + serverV21Config.setCapacity(1024); + serverV21Config.setCapacityAlarm(80); // New field 'yyy' introduced in v2.1 (min protocol = 3) + + String v1Content = IncrementalContentUtil.getVersionedContent(serverV21Config, 1, "1.9.0"); + String v2Content = IncrementalContentUtil.getVersionedContent(serverV21Config, 2, "2.0.0"); + String v3Content = IncrementalContentUtil.getVersionedContent(serverV21Config, 3, "2.1.0"); + + LinkedHashMap v1Fields = JSONUtil.parseObject(v1Content, new TypeReference>() { + }); + LinkedHashMap v2Fields = JSONUtil.parseObject(v2Content, new TypeReference>() { + }); + LinkedHashMap v3Fields = JSONUtil.parseObject(v3Content, new TypeReference>() { + }); + + System.out.println("Server v2.1 has field 'yyy' (capacityAlarm): " + serverV21Config.getCapacityAlarm()); + System.out.println("Protocol v1 contains capacityAlarm: " + v1Fields.containsKey("capacityAlarm")); + System.out.println("Protocol v2 contains capacityAlarm: " + v2Fields.containsKey("capacityAlarm")); + System.out.println("Protocol v3 contains capacityAlarm: " + v3Fields.containsKey("capacityAlarm")); + + Assert.assertTrue("Protocol v1 uses full content, includes all fields", v1Fields.containsKey("capacityAlarm")); + Assert.assertFalse("Protocol v2 should skip 'yyy' (capacityAlarm)", v2Fields.containsKey("capacityAlarm")); + Assert.assertTrue("Protocol v3 should include 'yyy' (capacityAlarm)", v3Fields.containsKey("capacityAlarm")); + + System.out.println("Test passed: Field 'yyy' only visible to protocol v3+"); + System.out.println("Mentor's requirement validated: Incremental field rollout works correctly"); + } + + /** + * Scenario 7: Verify that changing an invisible field does not change MD5 for lower protocol clients. + * This is the key to preventing "invalid refresh" mentioned by the mentor. + */ + @Test + public void testInvisibleFieldChangeDoesNotAffectMd5() { + System.out.println("\n========== Scenario 7: Invisible field change does not affect MD5 =========="); + + // Config 1: without extended field + ThreadPoolParameterInfo config1 = new ThreadPoolParameterInfo(); + config1.setTenantId("tenant-001"); + config1.setItemId("item-001"); + config1.setTpId("test-pool"); + config1.setCorePoolSize(10); + config1.setMaximumPoolSize(20); + config1.setQueueType(2); + config1.setCapacity(1024); + + // Config 2: extended field changed from null to 5000 + ThreadPoolParameterInfo config2 = new ThreadPoolParameterInfo(); + config2.setTenantId("tenant-001"); + config2.setItemId("item-001"); + config2.setTpId("test-pool"); + config2.setCorePoolSize(10); + config2.setMaximumPoolSize(20); + config2.setQueueType(2); + config2.setCapacity(1024); + config2.setExecuteTimeOut(5000L); // Changed from null to 5000 + + // Config 3: extended field changed from 5000 to 8000 + ThreadPoolParameterInfo config3 = new ThreadPoolParameterInfo(); + config3.setTenantId("tenant-001"); + config3.setItemId("item-001"); + config3.setTpId("test-pool"); + config3.setCorePoolSize(10); + config3.setMaximumPoolSize(20); + config3.setQueueType(2); + config3.setCapacity(1024); + config3.setExecuteTimeOut(8000L); // Changed from 5000 to 8000 + + String md51 = IncrementalMd5Util.getVersionedMd5(config1, 2, "2.0.0"); + String md52 = IncrementalMd5Util.getVersionedMd5(config2, 2, "2.0.0"); + String md53 = IncrementalMd5Util.getVersionedMd5(config3, 2, "2.0.0"); + + System.out.println("Config 1 executeTimeOut: null"); + System.out.println("Config 2 executeTimeOut: 5000"); + System.out.println("Config 3 executeTimeOut: 8000"); + System.out.println("Protocol v2 MD5 config1: " + md51); + System.out.println("Protocol v2 MD5 config2: " + md52); + System.out.println("Protocol v2 MD5 config3: " + md53); + + Assert.assertEquals("MD5 should be same (null -> 5000)", md51, md52); + Assert.assertEquals("MD5 should be same (5000 -> 8000)", md52, md53); + Assert.assertEquals("MD5 should be same (null -> 8000)", md51, md53); + + System.out.println("Test passed: Invisible field changes do not trigger refresh"); + System.out.println("This prevents the 'invalid refresh' issue mentioned by the mentor"); + } + + /** + * Scenario 8: Demonstrate field visibility matrix across protocol versions. + */ + @Test + public void testFieldVisibilityMatrix() { + System.out.println("\n========== Scenario 8: Field visibility matrix =========="); + + ThreadPoolParameterInfo fullConfig = new ThreadPoolParameterInfo(); + fullConfig.setTenantId("tenant-001"); + fullConfig.setItemId("item-001"); + fullConfig.setTpId("test-pool"); + fullConfig.setCorePoolSize(10); + fullConfig.setMaximumPoolSize(20); + fullConfig.setQueueType(2); + fullConfig.setCapacity(1024); + fullConfig.setKeepAliveTime(60L); + fullConfig.setRejectedType(1); + fullConfig.setAllowCoreThreadTimeOut(0); + fullConfig.setExecuteTimeOut(5000L); + fullConfig.setIsAlarm(1); + fullConfig.setCapacityAlarm(80); + fullConfig.setLivenessAlarm(90); + + String v1Content = IncrementalContentUtil.getVersionedContent(fullConfig, 1, "1.9.0"); + String v2Content = IncrementalContentUtil.getVersionedContent(fullConfig, 2, "2.0.0"); + String v3Content = IncrementalContentUtil.getVersionedContent(fullConfig, 3, "2.1.0"); + + LinkedHashMap v1Fields = JSONUtil.parseObject(v1Content, new TypeReference>() { + }); + LinkedHashMap v2Fields = JSONUtil.parseObject(v2Content, new TypeReference>() { + }); + LinkedHashMap v3Fields = JSONUtil.parseObject(v3Content, new TypeReference>() { + }); + + System.out.println("\nField Visibility Matrix:"); + System.out.println("Field | Protocol v1 | Protocol v2 | Protocol v3"); + System.out.println("------------------+-------------+-------------+------------"); + System.out.println("tenantId | " + v1Fields.containsKey("tenantId") + " | " + v2Fields.containsKey("tenantId") + " | " + v3Fields.containsKey("tenantId")); + System.out.println("coreSize | " + v1Fields.containsKey("coreSize") + " | " + v2Fields.containsKey("coreSize") + " | " + v3Fields.containsKey("coreSize")); + System.out.println( + "executeTimeOut | " + v1Fields.containsKey("executeTimeOut") + " | " + v2Fields.containsKey("executeTimeOut") + " | " + v3Fields.containsKey("executeTimeOut")); + System.out.println("isAlarm | " + v1Fields.containsKey("isAlarm") + " | " + v2Fields.containsKey("isAlarm") + " | " + v3Fields.containsKey("isAlarm")); + System.out.println("capacityAlarm | " + v1Fields.containsKey("capacityAlarm") + " | " + v2Fields.containsKey("capacityAlarm") + " | " + v3Fields.containsKey("capacityAlarm")); + + // Core assertions + Assert.assertTrue("All protocols see core fields", v1Fields.containsKey("coreSize") && v2Fields.containsKey("coreSize") && v3Fields.containsKey("coreSize")); + Assert.assertTrue("Protocol v1 sees all fields (full content)", v1Fields.containsKey("executeTimeOut")); + Assert.assertFalse("Protocol v2 skips extended fields (min protocol = 3)", v2Fields.containsKey("executeTimeOut")); + Assert.assertTrue("Protocol v3 sees extended fields", v3Fields.containsKey("executeTimeOut")); + + System.out.println("\nTest passed: Field visibility correctly controlled by protocol version"); + System.out.println("This is the foundation for mentor's requirement: version-aware field filtering"); + } +} diff --git a/infra/common/src/test/java/cn/hippo4j/common/toolkit/IncrementalMd5UtilBoundaryTest.java b/infra/common/src/test/java/cn/hippo4j/common/toolkit/IncrementalMd5UtilBoundaryTest.java index c80ffcfb..58200ed0 100644 --- a/infra/common/src/test/java/cn/hippo4j/common/toolkit/IncrementalMd5UtilBoundaryTest.java +++ b/infra/common/src/test/java/cn/hippo4j/common/toolkit/IncrementalMd5UtilBoundaryTest.java @@ -124,9 +124,16 @@ public class IncrementalMd5UtilBoundaryTest { System.out.println("\n========== Test 5: getVersionedMd5 with very large version =========="); ThreadPoolParameterInfo config = new ThreadPoolParameterInfo(); + config.setTenantId("test"); + config.setItemId("test"); + config.setTpId("test"); config.setCorePoolSize(10); config.setMaximumPoolSize(20); - config.setExecuteTimeOut(5000L); + config.setQueueType(1); + config.setCapacity(1024); + config.setKeepAliveTime(60L); + config.setRejectedType(1); + config.setAllowCoreThreadTimeOut(0); String vLargeMd5 = IncrementalMd5Util.getVersionedMd5(config, 999999); String v2Md5 = IncrementalMd5Util.getVersionedMd5(config, 2); diff --git a/infra/common/src/test/java/cn/hippo4j/common/toolkit/ProtocolRigidityTest.java b/infra/common/src/test/java/cn/hippo4j/common/toolkit/ProtocolRigidityTest.java index 6a610418..ec7e502d 100644 --- a/infra/common/src/test/java/cn/hippo4j/common/toolkit/ProtocolRigidityTest.java +++ b/infra/common/src/test/java/cn/hippo4j/common/toolkit/ProtocolRigidityTest.java @@ -40,7 +40,7 @@ public class ProtocolRigidityTest { clientConfig.setItemId("item-001"); clientConfig.setTpId("test-pool"); clientConfig.setCoreSize(10); // old field - clientConfig.setMaxSize(20); // old field + clientConfig.setMaxSize(20); // old field clientConfig.setQueueType(2); clientConfig.setCapacity(1024); clientConfig.setKeepAliveTime(60L); @@ -52,8 +52,8 @@ public class ProtocolRigidityTest { serverConfig.setTenantId("default"); serverConfig.setItemId("item-001"); serverConfig.setTpId("test-pool"); - serverConfig.setCorePoolSize(10); // new field - serverConfig.setMaximumPoolSize(20); // new field + serverConfig.setCorePoolSize(10); // new field + serverConfig.setMaximumPoolSize(20); // new field serverConfig.setQueueType(2); serverConfig.setCapacity(1024); serverConfig.setKeepAliveTime(60L); @@ -126,9 +126,9 @@ public class ProtocolRigidityTest { System.out.println("\n========== Scenario 3: Field Adapter Priority =========="); ThreadPoolParameterInfo config = new ThreadPoolParameterInfo(); - config.setCoreSize(10); // old field - config.setMaxSize(20); // old field - config.setCorePoolSize(15); // new field (should take priority) + config.setCoreSize(10); // old field + config.setMaxSize(20); // old field + config.setCorePoolSize(15); // new field (should take priority) config.setMaximumPoolSize(30); // new field (should take priority) Integer adaptedCore = config.corePoolSizeAdapt(); diff --git a/starters/threadpool/server/src/main/java/cn/hippo4j/springboot/starter/core/CacheData.java b/starters/threadpool/server/src/main/java/cn/hippo4j/springboot/starter/core/CacheData.java index 70682159..1024981c 100644 --- a/starters/threadpool/server/src/main/java/cn/hippo4j/springboot/starter/core/CacheData.java +++ b/starters/threadpool/server/src/main/java/cn/hippo4j/springboot/starter/core/CacheData.java @@ -51,20 +51,26 @@ public class CacheData { @Getter private final String threadPoolId; + private final int protocolVersion; + + private final String clientVersion; + @Setter private volatile boolean isInitializing = true; private final CopyOnWriteArrayList listeners; - public CacheData(String tenantId, String itemId, String threadPoolId) { + public CacheData(String tenantId, String itemId, String threadPoolId, int protocolVersion, String clientVersion) { this.tenantId = tenantId; this.itemId = itemId; this.threadPoolId = threadPoolId; + this.protocolVersion = protocolVersion; + this.clientVersion = clientVersion; // Store full content for listeners to receive complete configuration ThreadPoolParameterInfo parameterInfo = ThreadPoolExecutorRegistry.getHolder(threadPoolId).getParameterInfo(); this.content = ContentUtil.getPoolContent(parameterInfo); // Calculate MD5 based on incremental content for version compatibility - String incrementalContent = IncrementalContentUtil.getIncrementalContent(parameterInfo, IncrementalContentUtil.PROTOCOL_VERSION); + String incrementalContent = IncrementalContentUtil.getVersionedContent(parameterInfo, protocolVersion, clientVersion); this.md5 = getMd5String(incrementalContent); this.listeners = new CopyOnWriteArrayList<>(); } @@ -106,7 +112,7 @@ public class CacheData { // Calculate MD5 based on incremental content for version compatibility try { ThreadPoolParameterInfo parameterInfo = JSONUtil.parseObject(content, ThreadPoolParameterInfo.class); - String incrementalContent = IncrementalContentUtil.getIncrementalContent(parameterInfo, IncrementalContentUtil.PROTOCOL_VERSION); + String incrementalContent = IncrementalContentUtil.getVersionedContent(parameterInfo, protocolVersion, clientVersion); this.md5 = getMd5String(incrementalContent); } catch (Exception e) { // Fallback to full content MD5 if parsing fails diff --git a/starters/threadpool/server/src/main/java/cn/hippo4j/springboot/starter/core/ClientWorker.java b/starters/threadpool/server/src/main/java/cn/hippo4j/springboot/starter/core/ClientWorker.java index 9ee5c95d..a1163191 100644 --- a/starters/threadpool/server/src/main/java/cn/hippo4j/springboot/starter/core/ClientWorker.java +++ b/starters/threadpool/server/src/main/java/cn/hippo4j/springboot/starter/core/ClientWorker.java @@ -20,11 +20,12 @@ package cn.hippo4j.springboot.starter.core; import cn.hippo4j.common.executor.ThreadFactoryBuilder; import cn.hippo4j.common.model.Result; import cn.hippo4j.common.model.ThreadPoolParameterInfo; -import cn.hippo4j.common.toolkit.IncrementalContentUtil; import cn.hippo4j.common.toolkit.ContentUtil; import cn.hippo4j.common.toolkit.GroupKey; import cn.hippo4j.common.toolkit.IdUtil; +import cn.hippo4j.common.toolkit.IncrementalContentUtil; import cn.hippo4j.common.toolkit.JSONUtil; +import cn.hippo4j.common.toolkit.VersionUtil; import cn.hippo4j.springboot.starter.remote.HttpAgent; import cn.hippo4j.springboot.starter.remote.ServerHealthCheck; import lombok.SneakyThrows; @@ -70,7 +71,7 @@ public class ClientWorker implements DisposableBean { private final long timeout; private final String identify; private final String version; - + private final int protocolVersion; private final HttpAgent agent; private final ServerHealthCheck serverHealthCheck; private final ScheduledExecutorService executorService; @@ -91,7 +92,9 @@ public class ClientWorker implements DisposableBean { this.agent = httpAgent; this.identify = identify; this.timeout = CONFIG_LONG_POLL_TIMEOUT; - this.version = version; + this.version = VersionUtil.resolveClientVersion(version, ClientWorker.class); + int resolvedProtocol = VersionUtil.resolveProtocolVersion(this.version, IncrementalContentUtil.PROTOCOL_VERSION); + this.protocolVersion = VersionUtil.UNKNOWN_VERSION.equals(this.version) ? IncrementalContentUtil.PROTOCOL_VERSION : resolvedProtocol; this.serverHealthCheck = serverHealthCheck; this.hippo4jClientShutdown = hippo4jClientShutdown; ScheduledExecutorService executor = Executors.newScheduledThreadPool(1, runnable -> { @@ -208,7 +211,7 @@ public class ClientWorker implements DisposableBean { } headers.put(CLIENT_VERSION, version); // Add protocol version header for incremental updates - headers.put("X-Hippo4j-Protocol-Version", String.valueOf(IncrementalContentUtil.PROTOCOL_VERSION)); + headers.put("X-Hippo4j-Protocol-Version", String.valueOf(protocolVersion)); try { long readTimeoutMs = timeout + Math.round(timeout >> 1); Result result = agent.httpPostByConfig(LISTENER_PATH, headers, params, readTimeoutMs); @@ -279,7 +282,7 @@ public class ClientWorker implements DisposableBean { if (cacheData != null) { return cacheData; } - cacheData = new CacheData(namespace, itemId, threadPoolId); + cacheData = new CacheData(namespace, itemId, threadPoolId, protocolVersion, version); CacheData lastCacheData = cacheMap.putIfAbsent(threadPoolId, cacheData); if (lastCacheData == null) { String serverConfig; diff --git a/threadpool/server/config/src/main/java/cn/hippo4j/config/model/CacheItem.java b/threadpool/server/config/src/main/java/cn/hippo4j/config/model/CacheItem.java index 0ff3004e..c394055d 100644 --- a/threadpool/server/config/src/main/java/cn/hippo4j/config/model/CacheItem.java +++ b/threadpool/server/config/src/main/java/cn/hippo4j/config/model/CacheItem.java @@ -17,13 +17,15 @@ package cn.hippo4j.config.model; +import cn.hippo4j.common.constant.Constants; import cn.hippo4j.common.toolkit.Md5Util; import cn.hippo4j.config.toolkit.SimpleReadWriteLock; import cn.hippo4j.config.toolkit.SingletonRepository; -import cn.hippo4j.common.constant.Constants; import lombok.Getter; import lombok.Setter; +import java.util.concurrent.ConcurrentHashMap; + /** * Cache item. */ @@ -41,6 +43,8 @@ public class CacheItem { private SimpleReadWriteLock rwLock = new SimpleReadWriteLock(); + private final ConcurrentHashMap versionMd5Cache = new ConcurrentHashMap<>(); + public CacheItem(String groupKey) { this.groupKey = SingletonRepository.DataIdGroupIdCache.getSingleton(groupKey); } @@ -48,11 +52,36 @@ public class CacheItem { public CacheItem(String groupKey, String md5) { this.md5 = md5; this.groupKey = SingletonRepository.DataIdGroupIdCache.getSingleton(groupKey); + this.versionMd5Cache.put(1, md5); } public CacheItem(String groupKey, ConfigAllInfo configAllInfo) { this.configAllInfo = configAllInfo; this.md5 = Md5Util.getTpContentMd5(configAllInfo); this.groupKey = SingletonRepository.DataIdGroupIdCache.getSingleton(groupKey); + this.versionMd5Cache.put(1, this.md5); + } + + public String getMd5(int protocolVersion) { + if (protocolVersion <= 0) { + return md5; + } + return versionMd5Cache.get(protocolVersion); + } + + public void setMd5(int protocolVersion, String value) { + if (protocolVersion <= 0) { + this.md5 = value; + return; + } + if (value == null) { + versionMd5Cache.remove(protocolVersion); + } else { + versionMd5Cache.put(protocolVersion, value); + } + } + + public void clearVersionMd5() { + versionMd5Cache.clear(); } } diff --git a/threadpool/server/config/src/main/java/cn/hippo4j/config/service/ConfigCacheService.java b/threadpool/server/config/src/main/java/cn/hippo4j/config/service/ConfigCacheService.java index a7f0c89d..2a124375 100644 --- a/threadpool/server/config/src/main/java/cn/hippo4j/config/service/ConfigCacheService.java +++ b/threadpool/server/config/src/main/java/cn/hippo4j/config/service/ConfigCacheService.java @@ -29,6 +29,7 @@ import cn.hippo4j.common.toolkit.MapUtil; import cn.hippo4j.common.toolkit.Md5Util; import cn.hippo4j.common.toolkit.IncrementalMd5Util; import cn.hippo4j.common.toolkit.StringUtil; +import cn.hippo4j.common.toolkit.VersionUtil; import cn.hippo4j.config.event.LocalDataChangeEvent; import cn.hippo4j.config.model.CacheItem; import cn.hippo4j.config.model.ConfigAllInfo; @@ -71,30 +72,11 @@ public class ConfigCacheService { private static final ConcurrentHashMap> CLIENT_CONFIG_CACHE = new ConcurrentHashMap(); public static boolean isUpdateData(String groupKey, String md5, String clientIdentify) { - // Default to version 1 for backward compatibility - return isUpdateData(groupKey, md5, clientIdentify, 1); + return isUpdateData(groupKey, md5, clientIdentify, 1, null); } - /** - * Check if data needs update with version support - * - * @param groupKey group key - * @param md5 client MD5 - * @param clientIdentify client identifier - * @param clientVersion client version - * @return true if data is up to date - */ - public static boolean isUpdateData(String groupKey, String md5, String clientIdentify, int clientVersion) { - String contentMd5 = ConfigCacheService.getContentMd5IsNullPut(groupKey, clientIdentify); - if (clientVersion >= 2) { - String[] params = groupKey.split(GROUP_KEY_DELIMITER_TRANSLATION); - ConfigAllInfo config = configService.findConfigRecentInfo(params); - if (config != null) { - String incrementalMd5 = IncrementalMd5Util.getVersionedMd5(config, clientVersion); - return Objects.equals(incrementalMd5, md5); - } - } - // Fallback to full MD5 comparison for version 1 clients + public static boolean isUpdateData(String groupKey, String md5, String clientIdentify, int clientProtocolVersion, String clientVersion) { + String contentMd5 = ConfigCacheService.getContentMd5IsNullPut(groupKey, clientIdentify, clientProtocolVersion, clientVersion); return Objects.equals(contentMd5, md5); } @@ -124,13 +106,13 @@ public class ConfigCacheService { * @param clientIdentify * @return */ - private static synchronized String getContentMd5IsNullPut(String groupKey, String clientIdentify) { - Map cacheItemMap = Optional.ofNullable(CLIENT_CONFIG_CACHE.get(groupKey)).orElse(new HashMap<>()); - CacheItem cacheItem = null; - if (CollectionUtil.isNotEmpty(cacheItemMap)) { - cacheItem = cacheItemMap.get(clientIdentify); - if (cacheItem != null) { - return cacheItem.getMd5(); + private static synchronized String getContentMd5IsNullPut(String groupKey, String clientIdentify, int clientProtocolVersion, String clientVersion) { + Map cacheItemMap = CLIENT_CONFIG_CACHE.computeIfAbsent(groupKey, key -> new ConcurrentHashMap<>()); + CacheItem cacheItem = cacheItemMap.get(clientIdentify); + if (cacheItem != null) { + String versionMd5 = cacheItem.getMd5(clientProtocolVersion); + if (StringUtil.isNotBlank(versionMd5)) { + return versionMd5; } } if (configService == null) { @@ -139,11 +121,20 @@ public class ConfigCacheService { String[] params = groupKey.split(GROUP_KEY_DELIMITER_TRANSLATION); ConfigAllInfo config = configService.findConfigRecentInfo(params); if (config != null && StringUtil.isNotBlank(config.getTpId())) { - cacheItem = new CacheItem(groupKey, config); - cacheItemMap.put(clientIdentify, cacheItem); - CLIENT_CONFIG_CACHE.put(groupKey, cacheItemMap); + if (cacheItem == null) { + cacheItem = new CacheItem(groupKey, config); + cacheItemMap.put(clientIdentify, cacheItem); + } else { + cacheItem.setConfigAllInfo(config); + } + String versionedMd5 = IncrementalMd5Util.getVersionedMd5(config, clientProtocolVersion, clientVersion); + cacheItem.setMd5(clientProtocolVersion, versionedMd5); + if (clientProtocolVersion <= VersionUtil.LEGACY_PROTOCOL_VERSION || StringUtil.isBlank(cacheItem.getMd5())) { + cacheItem.setMd5(versionedMd5); + } + return versionedMd5; } - return (cacheItem != null) ? cacheItem.getMd5() : Constants.NULL; + return Constants.NULL; } public static String getContentMd5(String groupKey) { @@ -162,7 +153,9 @@ public class ConfigCacheService { public static void updateMd5(String groupKey, String identify, String md5) { CacheItem cache = makeSure(groupKey, identify); if (cache.getMd5() == null || !cache.getMd5().equals(md5)) { + cache.clearVersionMd5(); cache.setMd5(md5); + cache.setMd5(VersionUtil.LEGACY_PROTOCOL_VERSION, md5); String[] params = groupKey.split(GROUP_KEY_DELIMITER_TRANSLATION); ConfigAllInfo config = configService.findConfigRecentInfo(params); cache.setConfigAllInfo(config); diff --git a/threadpool/server/config/src/main/java/cn/hippo4j/config/toolkit/Md5ConfigUtil.java b/threadpool/server/config/src/main/java/cn/hippo4j/config/toolkit/Md5ConfigUtil.java index f7c5bb62..79ff635b 100644 --- a/threadpool/server/config/src/main/java/cn/hippo4j/config/toolkit/Md5ConfigUtil.java +++ b/threadpool/server/config/src/main/java/cn/hippo4j/config/toolkit/Md5ConfigUtil.java @@ -17,9 +17,11 @@ package cn.hippo4j.config.toolkit; +import cn.hippo4j.common.constant.Constants; import cn.hippo4j.common.toolkit.GroupKey; import cn.hippo4j.common.toolkit.Md5Util; import cn.hippo4j.common.toolkit.StringUtil; +import cn.hippo4j.common.toolkit.VersionUtil; import cn.hippo4j.config.service.ConfigCacheService; import cn.hippo4j.config.model.ConfigAllInfo; import org.springframework.util.StringUtils; @@ -72,10 +74,11 @@ public class Md5ConfigUtil { */ public static List compareMd5(HttpServletRequest request, Map clientMd5Map) { List changedGroupKeys = new ArrayList(); - int clientVersion = getClientVersion(request); + String clientVersionHeader = request.getHeader(Constants.CLIENT_VERSION); + int clientProtocolVersion = getClientProtocolVersion(request, clientVersionHeader); clientMd5Map.forEach((key, val) -> { String clientIdentify = RequestUtil.getClientIdentify(request); - boolean isUpdateData = ConfigCacheService.isUpdateData(key, val, clientIdentify, clientVersion); + boolean isUpdateData = ConfigCacheService.isUpdateData(key, val, clientIdentify, clientProtocolVersion, clientVersionHeader); if (!isUpdateData) { changedGroupKeys.add(key); } @@ -89,17 +92,16 @@ public class Md5ConfigUtil { * @param request HTTP request * @return client protocol version, default to 1 for backward compatibility */ - private static int getClientVersion(HttpServletRequest request) { + private static int getClientProtocolVersion(HttpServletRequest request, String clientVersionHeader) { String versionHeader = request.getHeader("X-Hippo4j-Protocol-Version"); - if (versionHeader != null && !versionHeader.isEmpty()) { + if (StringUtil.isNotBlank(versionHeader)) { try { - return Integer.parseInt(versionHeader); - } catch (NumberFormatException e) { - // Default to version 1 for backward compatibility + return Integer.parseInt(versionHeader.trim()); + } catch (NumberFormatException ignored) { return 1; } } - return 1; + return VersionUtil.resolveProtocolVersion(clientVersionHeader, 1); } public static Map getClientMd5Map(String configKeysString) { diff --git a/threadpool/server/config/src/test/java/cn/hippo4j/config/toolkit/Md5ConfigUtilVersionTest.java b/threadpool/server/config/src/test/java/cn/hippo4j/config/toolkit/Md5ConfigUtilVersionTest.java index 6e9b5403..cd8a854b 100644 --- a/threadpool/server/config/src/test/java/cn/hippo4j/config/toolkit/Md5ConfigUtilVersionTest.java +++ b/threadpool/server/config/src/test/java/cn/hippo4j/config/toolkit/Md5ConfigUtilVersionTest.java @@ -17,6 +17,7 @@ package cn.hippo4j.config.toolkit; +import cn.hippo4j.common.constant.Constants; import org.junit.Assert; import org.junit.Test; import org.junit.runner.RunWith; @@ -192,12 +193,31 @@ public class Md5ConfigUtilVersionTest { System.out.println("Test passed: compareMd5 will use v2 protocol for this client"); } + /** + * Test: Fallback to semantic client version when protocol header is missing. + */ + @Test + public void testGetClientVersion_FromClientVersionHeader() throws Exception { + System.out.println("\n========== Test 9: Fallback to client version header =========="); + + when(request.getHeader(PROTOCOL_VERSION_HEADER)).thenReturn(null); + when(request.getHeader(Constants.CLIENT_VERSION)).thenReturn("2.0.1"); + + int version = invokeGetClientVersion(request); + + System.out.println("Client-Version header: 2.0.1"); + System.out.println("Detected protocol version: v" + version); + + Assert.assertEquals("Semantic version should map to protocol v2", 2, version); + System.out.println("Test passed: Fallback resolved protocol from client version header"); + } + /** * Helper method to invoke private getClientVersion method via reflection */ private int invokeGetClientVersion(HttpServletRequest request) throws Exception { - Method method = Md5ConfigUtil.class.getDeclaredMethod("getClientVersion", HttpServletRequest.class); + Method method = Md5ConfigUtil.class.getDeclaredMethod("getClientProtocolVersion", HttpServletRequest.class, String.class); method.setAccessible(true); - return (int) method.invoke(null, request); + return (int) method.invoke(null, request, request.getHeader(Constants.CLIENT_VERSION)); } }