From d453c37250618212554fcebf2d85728c446a56e5 Mon Sep 17 00:00:00 2001 From: mingri31164 <3116430062@qq.com> Date: Fri, 31 Oct 2025 18:53:57 +0800 Subject: [PATCH] Support semantic version-aware incremental md5 comparison --- .../IncrementalFieldMetadataProvider.java | 50 ++++++ .../common/model/ThreadPoolParameterInfo.java | 13 +- .../hippo4j/common/toolkit/ContentUtil.java | 12 ++ .../toolkit/IncrementalContentUtil.java | 162 +++++++++++++----- .../common/toolkit/IncrementalMd5Util.java | 8 +- .../hippo4j/common/toolkit/VersionUtil.java | 81 +++++++++ .../toolkit/FieldVersionControlTest.java | 51 ++++-- .../IncrementalMd5UtilBoundaryTest.java | 19 +- .../hippo4j/config/model/ConfigInfoBase.java | 16 +- .../ConfigCacheServiceVersionTest.java | 32 +++- 10 files changed, 370 insertions(+), 74 deletions(-) create mode 100644 infra/common/src/main/java/cn/hippo4j/common/model/IncrementalFieldMetadataProvider.java diff --git a/infra/common/src/main/java/cn/hippo4j/common/model/IncrementalFieldMetadataProvider.java b/infra/common/src/main/java/cn/hippo4j/common/model/IncrementalFieldMetadataProvider.java new file mode 100644 index 00000000..1e0b3f5b --- /dev/null +++ b/infra/common/src/main/java/cn/hippo4j/common/model/IncrementalFieldMetadataProvider.java @@ -0,0 +1,50 @@ +/* + * 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.model; + +import java.util.Collections; +import java.util.Map; + +/** + * Optional provider for field-version metadata. + *

+ * Thread pool parameter models implementing this interface can explicitly declare the + * relationship between fields and the protocol versions that understand them. This allows + * the incremental content builder to omit unsupported fields for legacy clients and avoid + * unnecessary refresh loops triggered by unknown data. + */ +public interface IncrementalFieldMetadataProvider { + + /** + * Return a mapping of field name to the minimum protocol version that can observe it. + * + * @return field -> minimum semantic version; fields not present fall back to defaults + */ + default Map getFieldVersionMetadata() { + return Collections.emptyMap(); + } + + /** + * Optional version string of the metadata definition, useful for caching or diagnostics. + * + * @return metadata version identifier, or {@code null} if not set + */ + default String getFieldMetadataVersion() { + return null; + } +} diff --git a/infra/common/src/main/java/cn/hippo4j/common/model/ThreadPoolParameterInfo.java b/infra/common/src/main/java/cn/hippo4j/common/model/ThreadPoolParameterInfo.java index 1a912c2c..2148468c 100644 --- a/infra/common/src/main/java/cn/hippo4j/common/model/ThreadPoolParameterInfo.java +++ b/infra/common/src/main/java/cn/hippo4j/common/model/ThreadPoolParameterInfo.java @@ -25,6 +25,7 @@ import lombok.NoArgsConstructor; import lombok.experimental.Accessors; import java.io.Serializable; +import java.util.Map; /** * Thread pool parameter info. @@ -34,7 +35,7 @@ import java.io.Serializable; @NoArgsConstructor @AllArgsConstructor @Accessors(chain = true) -public class ThreadPoolParameterInfo implements ThreadPoolParameter, Serializable { +public class ThreadPoolParameterInfo implements ThreadPoolParameter, Serializable, IncrementalFieldMetadataProvider { private static final long serialVersionUID = -7123935122108553864L; @@ -127,6 +128,16 @@ public class ThreadPoolParameterInfo implements ThreadPoolParameter, Serializabl */ private Integer allowCoreThreadTimeOut; + /** + * Field-to-minimum-version mapping used by clients to filter unsupported fields. + */ + private Map fieldVersionMetadata; + + /** + * Optional metadata version identifier for diagnostics or caching. + */ + private String fieldMetadataVersion; + public Integer corePoolSizeAdapt() { return this.corePoolSize == null ? this.coreSize : this.corePoolSize; } diff --git a/infra/common/src/main/java/cn/hippo4j/common/toolkit/ContentUtil.java b/infra/common/src/main/java/cn/hippo4j/common/toolkit/ContentUtil.java index b3137407..eeca6fd2 100644 --- a/infra/common/src/main/java/cn/hippo4j/common/toolkit/ContentUtil.java +++ b/infra/common/src/main/java/cn/hippo4j/common/toolkit/ContentUtil.java @@ -18,9 +18,13 @@ package cn.hippo4j.common.toolkit; import cn.hippo4j.common.constant.Constants; +import cn.hippo4j.common.model.IncrementalFieldMetadataProvider; import cn.hippo4j.common.model.ThreadPoolParameter; import cn.hippo4j.common.model.ThreadPoolParameterInfo; +import java.util.LinkedHashMap; +import java.util.Map; + /** * Content util. */ @@ -52,6 +56,14 @@ public class ContentUtil { .setLivenessAlarm(parameter.getLivenessAlarm()) .setAllowCoreThreadTimeOut(parameter.getAllowCoreThreadTimeOut()) .setRejectedType(parameter.getRejectedType()); + if (parameter instanceof IncrementalFieldMetadataProvider) { + IncrementalFieldMetadataProvider provider = (IncrementalFieldMetadataProvider) parameter; + Map metadata = provider.getFieldVersionMetadata(); + if (metadata != null && !metadata.isEmpty()) { + threadPoolParameterInfo.setFieldVersionMetadata(new LinkedHashMap<>(metadata)); + } + threadPoolParameterInfo.setFieldMetadataVersion(provider.getFieldMetadataVersion()); + } return JSONUtil.toJSONString(threadPoolParameterInfo); } 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 7ebe83d3..58a2b21a 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 @@ -17,18 +17,13 @@ package cn.hippo4j.common.toolkit; +import cn.hippo4j.common.model.IncrementalFieldMetadataProvider; 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; +import java.util.*; /** * Incremental content util for thread pool parameter comparison. @@ -50,40 +45,13 @@ public class IncrementalContentUtil { "keepAliveTime", "rejectedType", "allowCoreThreadTimeOut" }; - /** - * Extended parameters that don't affect core behavior - */ - private static final String[] EXTENDED_PARAMETERS = { - "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)); + private static final String FIELD_VERSION_METADATA_KEY = "fieldVersionMetadata"; - FIELD_MIN_PROTOCOL_VERSION = Collections.unmodifiableMap(fieldVersion); - } + private static final String FIELD_METADATA_VERSION_KEY = "fieldMetadataVersion"; /** * Get core content for MD5 calculation (only essential parameters) @@ -127,15 +95,15 @@ public class IncrementalContentUtil { */ 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); + String normalizedClientVersion = StringUtil.isNotBlank(clientVersion) + ? clientVersion.trim() + : VersionUtil.resolveSemanticVersionForProtocol(protocolVersion); + Map fieldRules = resolveFieldRules(parameter, raw); LinkedHashMap filtered = new LinkedHashMap<>(); for (String field : IDENTIFIER_FIELDS) { if (raw.containsKey(field)) { @@ -148,7 +116,7 @@ public class IncrementalContentUtil { } } raw.forEach((field, value) -> { - if (!filtered.containsKey(field) && shouldIncludeField(field, normalizedProtocol)) { + if (!filtered.containsKey(field) && shouldIncludeField(field, normalizedClientVersion, fieldRules)) { filtered.put(field, value); } }); @@ -252,8 +220,114 @@ public class IncrementalContentUtil { * the specified protocol version. If the field requires a higher protocol, it will be ignored * so older clients remain unaware of unsupported parameters. */ - private static boolean shouldIncludeField(String field, int protocolVersion) { - int minProtocol = FIELD_MIN_PROTOCOL_VERSION.getOrDefault(field, Integer.MAX_VALUE); - return protocolVersion >= minProtocol; + private static boolean shouldIncludeField(String field, String clientVersion, Map fieldRules) { + String minVersion = fieldRules.get(field); + if (StringUtil.isBlank(minVersion)) { + return false; + } + String effectiveClientVersion = StringUtil.isBlank(clientVersion) ? VersionUtil.UNKNOWN_VERSION : clientVersion; + return VersionUtil.isVersionGreaterOrEqual(effectiveClientVersion, minVersion); + } + + /** + * Resolve field-level version rules by combining default baseline, runtime metadata from the + * parameter object, and metadata embedded in the JSON payload. Fields without explicit metadata + * are assigned a default minimum version based on the current protocol. + * + * @param parameter thread pool parameter (may carry metadata) + * @param raw parsed JSON payload (may contain fieldVersionMetadata) + * @return mapping of field name to minimum semantic version + */ + private static Map resolveFieldRules(ThreadPoolParameter parameter, Map raw) { + Map fieldRules = new LinkedHashMap<>(); + // Identifier and core fields visible to all clients (since version 1.0.0) + IDENTIFIER_FIELDS.forEach(field -> fieldRules.put(field, VersionUtil.UNKNOWN_VERSION)); + CORE_PARAMETER_LIST.forEach(field -> fieldRules.put(field, VersionUtil.UNKNOWN_VERSION)); + + // Merge metadata from parameter object (e.g., Server-side configuration) + mergeFieldMetadata(fieldRules, extractMetadataFromParameter(parameter)); + // Merge metadata from JSON payload (e.g., Client receiving Server's dynamic metadata) + mergeFieldMetadata(fieldRules, extractMetadataFromPayload(raw)); + + // Assign default version to unconfigured fields (prevents old clients from seeing new fields) + String defaultVisibleVersion = VersionUtil.resolveSemanticVersionForProtocol(PROTOCOL_VERSION); + raw.keySet().forEach(field -> { + if (!fieldRules.containsKey(field)) { + fieldRules.put(field, defaultVisibleVersion); + } + }); + + return fieldRules; + } + + /** + * Extract field version metadata from the parameter object if it implements + * {@link IncrementalFieldMetadataProvider}. This is typically used on the Server side where + * configuration objects can dynamically declare which fields were introduced in which version. + * + * @param parameter thread pool parameter + * @return field-to-version mapping, or empty map if not available + */ + private static Map extractMetadataFromParameter(ThreadPoolParameter parameter) { + if (parameter instanceof IncrementalFieldMetadataProvider) { + Map metadata = ((IncrementalFieldMetadataProvider) parameter).getFieldVersionMetadata(); + if (metadata != null && !metadata.isEmpty()) { + Map copied = new LinkedHashMap<>(); + metadata.forEach((field, version) -> { + if (StringUtil.isNotBlank(field) && StringUtil.isNotBlank(version)) { + copied.put(field, version.trim()); + } + }); + return copied; + } + } + return Collections.emptyMap(); + } + + /** + * Extract field version metadata from the JSON payload and remove metadata keys from the raw map + * so they do not participate in MD5 calculation. This is typically used on the Client side to + * receive dynamic metadata from the Server. + * + * @param raw parsed JSON map (will be modified: metadata keys removed) + * @return field-to-version mapping extracted from the payload, or empty map if not present + */ + @SuppressWarnings("unchecked") + private static Map extractMetadataFromPayload(Map raw) { + Object metadataObject = raw.remove(FIELD_VERSION_METADATA_KEY); + raw.remove(FIELD_METADATA_VERSION_KEY); + if (metadataObject instanceof Map) { + Map metadata = new LinkedHashMap<>(); + ((Map) metadataObject).forEach((key, value) -> { + if (key == null || value == null) { + return; + } + String field = String.valueOf(key); + String version = String.valueOf(value).trim(); + if (StringUtil.isNotBlank(field) && StringUtil.isNotBlank(version)) { + metadata.put(field, version); + } + }); + return metadata; + } + return Collections.emptyMap(); + } + + /** + * Merge additional field version metadata into the target map. Existing entries in the target + * will be overwritten by additions. This enables layered metadata resolution (base → parameter → payload). + * + * @param target target map to merge into + * @param additions additional metadata to merge (may be null or empty) + */ + private static void mergeFieldMetadata(Map target, Map additions) { + if (additions == null || additions.isEmpty()) { + return; + } + additions.forEach((field, version) -> { + if (StringUtil.isNotBlank(field) && StringUtil.isNotBlank(version)) { + target.put(field, version.trim()); + } + }); } } 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 4b47cde3..0f3f2649 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,7 +56,8 @@ public class IncrementalMd5Util { * @return versioned MD5 hash */ public static String getVersionedMd5(ThreadPoolParameter config, int clientVersion) { - return getVersionedMd5(config, clientVersion, null); + String semanticVersion = VersionUtil.resolveSemanticVersionForProtocol(clientVersion); + return getVersionedMd5(config, clientVersion, semanticVersion); } /** @@ -89,8 +90,9 @@ public class IncrementalMd5Util { if (oldConfig == null || newConfig == null) { return true; } - String oldMd5 = getVersionedMd5(oldConfig, clientVersion, null); - String newMd5 = getVersionedMd5(newConfig, clientVersion, null); + String semanticVersion = VersionUtil.resolveSemanticVersionForProtocol(clientVersion); + String oldMd5 = getVersionedMd5(oldConfig, clientVersion, semanticVersion); + String newMd5 = getVersionedMd5(newConfig, clientVersion, semanticVersion); 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 index 9a60876b..352a378c 100644 --- a/infra/common/src/main/java/cn/hippo4j/common/toolkit/VersionUtil.java +++ b/infra/common/src/main/java/cn/hippo4j/common/toolkit/VersionUtil.java @@ -32,6 +32,7 @@ import java.util.regex.Pattern; * 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; @@ -106,6 +107,62 @@ public final class VersionUtil { return resolveProtocolVersion(version, IncrementalContentUtil.PROTOCOL_VERSION); } + /** + * Resolve a representative semantic version string for a given protocol version. If the + * protocol version has been registered explicitly, the lowest semantic version mapped to the + * protocol will be returned. Otherwise, a best-effort placeholder in the form of + * {@code .0.0} is produced. + * + * @param protocolVersion protocol version number + * @return semantic version string representing the protocol capabilities + */ + public static String resolveSemanticVersionForProtocol(int protocolVersion) { + if (protocolVersion <= 0) { + return UNKNOWN_VERSION; + } + SemanticVersion candidate = null; + for (Map.Entry entry : PROTOCOL_VERSION_MAPPINGS.entrySet()) { + Integer mapped = entry.getValue(); + if (mapped != null && mapped == protocolVersion) { + SemanticVersion semanticVersion = entry.getKey(); + if (candidate == null || semanticVersion.compareTo(candidate) < 0) { + candidate = semanticVersion; + } + } + } + if (candidate != null) { + return candidate.toString(); + } + return protocolVersion + ".0.0"; + } + + /** + * Compare two semantic versions using {@link SemanticVersion}. Returns {@code true} if + * {@code version1} is greater than or equal to {@code version2}. Blank or unparsable versions + * are treated conservatively and will return {@code false}. + * + * @param version1 the client version + * @param version2 the minimum version requirement + * @return {@code true} if version1 >= version2 + */ + public static boolean isVersionGreaterOrEqual(String version1, String version2) { + if (StringUtil.isBlank(version1) || StringUtil.isBlank(version2)) { + return false; + } + SemanticVersion v1 = SemanticVersion.parse(version1); + SemanticVersion v2 = SemanticVersion.parse(version2); + if (v1 == null || v2 == null) { + return false; + } + return v1.compareTo(v2) >= 0; + } + + /** + * Return the first non-blank value from the provided arguments. + * + * @param values variable arguments to check + * @return first non-blank value, or {@code null} if all are blank + */ private static String firstNonBlank(String... values) { if (values == null) { return null; @@ -118,6 +175,13 @@ public final class VersionUtil { return null; } + /** + * Register a semantic version to protocol version mapping. This is used during static + * initialization to define which client versions map to which protocol capabilities. + * + * @param version semantic version string (e.g., "2.0.0") + * @param protocolVersion protocol capability number (e.g., 2) + */ private static void registerProtocolVersion(String version, int protocolVersion) { SemanticVersion semanticVersion = SemanticVersion.parse(version); if (semanticVersion != null) { @@ -140,6 +204,12 @@ public final class VersionUtil { this.patch = patch; } + /** + * Parse a semantic version string into a SemanticVersion instance. + * + * @param version version string (e.g., "2.1.5", "2.0.0-SNAPSHOT") + * @return parsed SemanticVersion, or {@code null} if format is invalid + */ private static SemanticVersion parse(String version) { Matcher matcher = VERSION_PATTERN.matcher(version.trim()); if (!matcher.matches()) { @@ -151,6 +221,12 @@ public final class VersionUtil { return new SemanticVersion(major, minor, patch); } + /** + * Parse a version component string to integer, defaulting to 0 if blank. + * + * @param value version component string + * @return parsed integer, or 0 if blank + */ private static int parseOrDefault(String value) { if (StringUtil.isBlank(value)) { return 0; @@ -188,5 +264,10 @@ public final class VersionUtil { public int hashCode() { return Objects.hash(major, minor, patch); } + + @Override + public String toString() { + return major + "." + minor + "." + patch; + } } } 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 index cc2d9e00..db98fdc6 100644 --- a/infra/common/src/test/java/cn/hippo4j/common/toolkit/FieldVersionControlTest.java +++ b/infra/common/src/test/java/cn/hippo4j/common/toolkit/FieldVersionControlTest.java @@ -22,6 +22,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import org.junit.Assert; import org.junit.Test; +import java.util.Collections; import java.util.LinkedHashMap; /** @@ -43,7 +44,7 @@ public class FieldVersionControlTest { 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 + // Explicitly mark that the field is only recognized by clients from protocol v3 onward ThreadPoolParameterInfo serverConfig = new ThreadPoolParameterInfo(); serverConfig.setTenantId("tenant-001"); serverConfig.setItemId("item-001"); @@ -55,7 +56,8 @@ public class FieldVersionControlTest { serverConfig.setKeepAliveTime(60L); serverConfig.setRejectedType(1); serverConfig.setAllowCoreThreadTimeOut(0); - serverConfig.setExecuteTimeOut(5000L); // New field introduced in v2.0 (minimum protocol = 3) + serverConfig.setExecuteTimeOut(5000L); // New field introduced in v2.0 (minimum version = 2.1.0) + serverConfig.setFieldVersionMetadata(Collections.singletonMap("executeTimeOut", "2.1.0")); // Protocol v1 client content generation String v1Content = IncrementalContentUtil.getVersionedContent(serverConfig, 1, "1.9.0"); @@ -67,16 +69,24 @@ public class FieldVersionControlTest { LinkedHashMap v2Fields = JSONUtil.parseObject(v2Content, new TypeReference>() { }); + // Protocol v3 client content generation + String v3Content = IncrementalContentUtil.getVersionedContent(serverConfig, 3, "2.1.0"); + LinkedHashMap v3Fields = JSONUtil.parseObject(v3Content, 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 v3 content: " + v3Content); System.out.println("Protocol v1 contains executeTimeOut: " + v1Fields.containsKey("executeTimeOut")); System.out.println("Protocol v2 contains executeTimeOut: " + v2Fields.containsKey("executeTimeOut")); + System.out.println("Protocol v3 contains executeTimeOut: " + v3Fields.containsKey("executeTimeOut")); // Assertions - Assert.assertTrue("Protocol v1 should include all fields (full content)", v1Fields.containsKey("executeTimeOut")); + Assert.assertFalse("Protocol v1 should skip executeTimeOut (min protocol = 3)", 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"); + Assert.assertTrue("Protocol v3 should include executeTimeOut", v3Fields.containsKey("executeTimeOut")); + System.out.println("Test passed: Protocol v1/v2 clients skip new field, v3 client observes it"); } /** @@ -88,7 +98,6 @@ public class FieldVersionControlTest { 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"); @@ -97,7 +106,8 @@ public class FieldVersionControlTest { config.setMaximumPoolSize(20); config.setQueueType(2); config.setCapacity(1024); - config.setIsAlarm(1); // Extended field, minimum protocol = 3 + config.setIsAlarm(1); // Extended field, minimum version = 2.1.0 + config.setFieldVersionMetadata(Collections.singletonMap("isAlarm", "2.1.0")); String v1Content = IncrementalContentUtil.getVersionedContent(config, 1, "1.9.0"); String v2Content = IncrementalContentUtil.getVersionedContent(config, 2, "2.0.0"); @@ -117,7 +127,7 @@ public class FieldVersionControlTest { 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 v1 should skip isAlarm (min protocol = 3)", 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"); @@ -150,7 +160,8 @@ public class FieldVersionControlTest { newConfig.setMaximumPoolSize(20); newConfig.setQueueType(2); newConfig.setCapacity(1024); - newConfig.setExecuteTimeOut(5000L); // Added field (min protocol = 3) + newConfig.setExecuteTimeOut(5000L); // Added field (min version = 2.1.0) + newConfig.setFieldVersionMetadata(Collections.singletonMap("executeTimeOut", "2.1.0")); String oldV2Md5 = IncrementalMd5Util.getVersionedMd5(oldConfig, 2, "2.0.0"); String newV2Md5 = IncrementalMd5Util.getVersionedMd5(newConfig, 2, "2.0.0"); @@ -231,7 +242,8 @@ public class FieldVersionControlTest { serverV20Config.setMaximumPoolSize(20); serverV20Config.setQueueType(2); serverV20Config.setCapacity(1024); - serverV20Config.setIsAlarm(1); // New field 'xxx' introduced in v2.0 (but min protocol = 3) + serverV20Config.setIsAlarm(1); // New field 'xxx' introduced in v2.0 (but min version = 2.1.0) + serverV20Config.setFieldVersionMetadata(Collections.singletonMap("isAlarm", "2.1.0")); // 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 @@ -281,7 +293,8 @@ public class FieldVersionControlTest { serverV21Config.setMaximumPoolSize(20); serverV21Config.setQueueType(2); serverV21Config.setCapacity(1024); - serverV21Config.setCapacityAlarm(80); // New field 'yyy' introduced in v2.1 (min protocol = 3) + serverV21Config.setCapacityAlarm(80); // New field 'yyy' introduced in v2.1 (min version = 2.1.0) + serverV21Config.setFieldVersionMetadata(Collections.singletonMap("capacityAlarm", "2.1.0")); String v1Content = IncrementalContentUtil.getVersionedContent(serverV21Config, 1, "1.9.0"); String v2Content = IncrementalContentUtil.getVersionedContent(serverV21Config, 2, "2.0.0"); @@ -299,7 +312,7 @@ public class FieldVersionControlTest { 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 v1 should skip 'yyy' (capacityAlarm)", 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")); @@ -335,6 +348,7 @@ public class FieldVersionControlTest { config2.setQueueType(2); config2.setCapacity(1024); config2.setExecuteTimeOut(5000L); // Changed from null to 5000 + config2.setFieldVersionMetadata(Collections.singletonMap("executeTimeOut", "2.1.0")); // Config 3: extended field changed from 5000 to 8000 ThreadPoolParameterInfo config3 = new ThreadPoolParameterInfo(); @@ -346,6 +360,7 @@ public class FieldVersionControlTest { config3.setQueueType(2); config3.setCapacity(1024); config3.setExecuteTimeOut(8000L); // Changed from 5000 to 8000 + config3.setFieldVersionMetadata(Collections.singletonMap("executeTimeOut", "2.1.0")); String md51 = IncrementalMd5Util.getVersionedMd5(config1, 2, "2.0.0"); String md52 = IncrementalMd5Util.getVersionedMd5(config2, 2, "2.0.0"); @@ -388,6 +403,12 @@ public class FieldVersionControlTest { fullConfig.setIsAlarm(1); fullConfig.setCapacityAlarm(80); fullConfig.setLivenessAlarm(90); + LinkedHashMap metadata = new LinkedHashMap<>(); + metadata.put("executeTimeOut", "2.1.0"); + metadata.put("isAlarm", "2.1.0"); + metadata.put("capacityAlarm", "2.1.0"); + metadata.put("livenessAlarm", "2.1.0"); + fullConfig.setFieldVersionMetadata(metadata); String v1Content = IncrementalContentUtil.getVersionedContent(fullConfig, 1, "1.9.0"); String v2Content = IncrementalContentUtil.getVersionedContent(fullConfig, 2, "2.0.0"); @@ -412,11 +433,11 @@ public class FieldVersionControlTest { // 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")); + Assert.assertFalse("Protocol v1 (1.9.0) should skip executeTimeOut", v1Fields.containsKey("executeTimeOut")); + Assert.assertFalse("Protocol v2 (2.0.0) should skip executeTimeOut", v2Fields.containsKey("executeTimeOut")); + Assert.assertTrue("Protocol v3 (2.1.0) should include executeTimeOut", v3Fields.containsKey("executeTimeOut")); - System.out.println("\nTest passed: Field visibility correctly controlled by protocol version"); + System.out.println("\nTest passed: Field visibility correctly controlled by semantic 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 58200ed0..53904013 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 @@ -21,6 +21,9 @@ import cn.hippo4j.common.model.ThreadPoolParameterInfo; import org.junit.Assert; import org.junit.Test; +import java.util.Collections; +import java.util.LinkedHashMap; + /** * Incremental MD5 Utility Boundary Test * Tests edge cases and boundary conditions for version-aware MD5 calculation @@ -79,6 +82,7 @@ public class IncrementalMd5UtilBoundaryTest { config.setCorePoolSize(10); config.setMaximumPoolSize(20); config.setExecuteTimeOut(5000L); + config.setFieldVersionMetadata(Collections.singletonMap("executeTimeOut", "2.1.0")); String v0Md5 = IncrementalMd5Util.getVersionedMd5(config, 0); String v1Md5 = IncrementalMd5Util.getVersionedMd5(config, 1); @@ -103,6 +107,7 @@ public class IncrementalMd5UtilBoundaryTest { config.setCorePoolSize(10); config.setMaximumPoolSize(20); config.setExecuteTimeOut(5000L); + config.setFieldVersionMetadata(Collections.singletonMap("executeTimeOut", "2.1.0")); String vNegativeMd5 = IncrementalMd5Util.getVersionedMd5(config, -1); String v1Md5 = IncrementalMd5Util.getVersionedMd5(config, 1); @@ -159,17 +164,25 @@ public class IncrementalMd5UtilBoundaryTest { config.setExecuteTimeOut(5000L); config.setIsAlarm(1); config.setCapacityAlarm(80); + LinkedHashMap metadata = new LinkedHashMap<>(); + metadata.put("executeTimeOut", "2.1.0"); + metadata.put("isAlarm", "2.1.0"); + metadata.put("capacityAlarm", "2.1.0"); + config.setFieldVersionMetadata(metadata); String v1Md5 = IncrementalMd5Util.getVersionedMd5(config, 1); String v2Md5 = IncrementalMd5Util.getVersionedMd5(config, 2); + String v3Md5 = IncrementalMd5Util.getVersionedMd5(config, 3); System.out.println("Config: Only extended params (executeTimeOut, isAlarm, capacityAlarm)"); System.out.println("v1 MD5: " + v1Md5); System.out.println("v2 MD5: " + v2Md5); - System.out.println("Are they different? " + !v1Md5.equals(v2Md5)); + System.out.println("Are v1 and v2 same? " + v1Md5.equals(v2Md5)); + System.out.println("Does v3 differ? " + !v2Md5.equals(v3Md5)); - Assert.assertNotEquals("v1 and v2 should differ when only extended params exist", v1Md5, v2Md5); - System.out.println("Test passed: v2 excludes extended params"); + Assert.assertEquals("Protocols below threshold skip extended params", v1Md5, v2Md5); + Assert.assertNotEquals("Supported protocol should include extended params", v2Md5, v3Md5); + System.out.println("Test passed: Metadata gates extended params by protocol"); } /** diff --git a/threadpool/server/config/src/main/java/cn/hippo4j/config/model/ConfigInfoBase.java b/threadpool/server/config/src/main/java/cn/hippo4j/config/model/ConfigInfoBase.java index 08612edd..47e8dcad 100644 --- a/threadpool/server/config/src/main/java/cn/hippo4j/config/model/ConfigInfoBase.java +++ b/threadpool/server/config/src/main/java/cn/hippo4j/config/model/ConfigInfoBase.java @@ -17,6 +17,7 @@ package cn.hippo4j.config.model; +import cn.hippo4j.common.model.IncrementalFieldMetadataProvider; import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableField; @@ -26,12 +27,13 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import lombok.Data; import java.io.Serializable; +import java.util.Map; /** * Config info base. */ @Data -public class ConfigInfoBase implements Serializable { +public class ConfigInfoBase implements Serializable, IncrementalFieldMetadataProvider { private static final long serialVersionUID = -1892597426099265730L; @@ -125,4 +127,16 @@ public class ConfigInfoBase implements Serializable { */ @JsonIgnore private String content; + + /** + * Field-to-minimum-version mapping (transient field). + */ + @TableField(exist = false) + private Map fieldVersionMetadata; + + /** + * Metadata version identifier (transient field). + */ + @TableField(exist = false) + private String fieldMetadataVersion; } diff --git a/threadpool/server/config/src/test/java/cn/hippo4j/config/service/ConfigCacheServiceVersionTest.java b/threadpool/server/config/src/test/java/cn/hippo4j/config/service/ConfigCacheServiceVersionTest.java index 9db3dd5f..749e625d 100644 --- a/threadpool/server/config/src/test/java/cn/hippo4j/config/service/ConfigCacheServiceVersionTest.java +++ b/threadpool/server/config/src/test/java/cn/hippo4j/config/service/ConfigCacheServiceVersionTest.java @@ -22,6 +22,8 @@ import cn.hippo4j.common.toolkit.IncrementalMd5Util; import org.junit.Assert; import org.junit.Test; +import java.util.LinkedHashMap; + /** * ConfigCacheService Version-Aware Test * Tests the version-aware MD5 comparison logic for cross-version compatibility @@ -53,17 +55,25 @@ public class ConfigCacheServiceVersionTest { config.setAllowCoreThreadTimeOut(0); config.setExecuteTimeOut(5000L); // Extended parameter config.setIsAlarm(1); // Extended parameter + LinkedHashMap metadata = new LinkedHashMap<>(); + metadata.put("executeTimeOut", "2.1.0"); + metadata.put("isAlarm", "2.1.0"); + config.setFieldVersionMetadata(metadata); String v1Md5 = IncrementalMd5Util.getVersionedMd5(config, 1); String v2Md5 = IncrementalMd5Util.getVersionedMd5(config, 2); + String v3Md5 = IncrementalMd5Util.getVersionedMd5(config, 3); System.out.println("Config includes extended parameters: executeTimeOut, isAlarm"); System.out.println("v1 MD5 (full): " + v1Md5); System.out.println("v2 MD5 (incremental): " + v2Md5); - System.out.println("Are they different? " + !v1Md5.equals(v2Md5)); + System.out.println("v3 MD5 (supports extended): " + v3Md5); + System.out.println("v1 equals v2? " + v1Md5.equals(v2Md5)); + System.out.println("v3 equals v2? " + v3Md5.equals(v2Md5)); - Assert.assertNotEquals("v1 and v2 should produce different MD5", v1Md5, v2Md5); - System.out.println("Test passed: v1 and v2 use different MD5 strategies"); + Assert.assertEquals("Protocols below field threshold should share same MD5", v1Md5, v2Md5); + Assert.assertNotEquals("Protocol v3 should include extended fields", v2Md5, v3Md5); + System.out.println("Test passed: Older protocols share MD5 while supported protocol diverges"); } /** @@ -84,6 +94,9 @@ public class ConfigCacheServiceVersionTest { oldConfig.setRejectedType(1); oldConfig.setAllowCoreThreadTimeOut(0); oldConfig.setExecuteTimeOut(3000L); // Extended + LinkedHashMap metadata = new LinkedHashMap<>(); + metadata.put("executeTimeOut", "2.1.0"); + oldConfig.setFieldVersionMetadata(metadata); // New config (only extended param changed) ThreadPoolParameterInfo newConfig = new ThreadPoolParameterInfo(); @@ -95,6 +108,7 @@ public class ConfigCacheServiceVersionTest { newConfig.setRejectedType(1); newConfig.setAllowCoreThreadTimeOut(0); newConfig.setExecuteTimeOut(5000L); // Extended param changed! + newConfig.setFieldVersionMetadata(metadata); String oldV2Md5 = IncrementalMd5Util.getVersionedMd5(oldConfig, 2); String newV2Md5 = IncrementalMd5Util.getVersionedMd5(newConfig, 2); @@ -113,7 +127,7 @@ public class ConfigCacheServiceVersionTest { * This verifies backward compatibility */ @Test - public void testV1Client_ExtendedParamChange_ShouldRefresh() { + public void testV1Client_ExtendedParamChange_NoRefresh() { System.out.println("\n========== Test 3: v1 client - extended param change =========="); // Old config @@ -123,6 +137,9 @@ public class ConfigCacheServiceVersionTest { oldConfig.setQueueType(2); oldConfig.setCapacity(1024); oldConfig.setExecuteTimeOut(3000L); + LinkedHashMap metadata = new LinkedHashMap<>(); + metadata.put("executeTimeOut", "2.1.0"); + oldConfig.setFieldVersionMetadata(metadata); // New config (only extended param changed) ThreadPoolParameterInfo newConfig = new ThreadPoolParameterInfo(); @@ -131,6 +148,7 @@ public class ConfigCacheServiceVersionTest { newConfig.setQueueType(2); newConfig.setCapacity(1024); newConfig.setExecuteTimeOut(5000L); // Extended param changed! + newConfig.setFieldVersionMetadata(metadata); String oldV1Md5 = IncrementalMd5Util.getVersionedMd5(oldConfig, 1); String newV1Md5 = IncrementalMd5Util.getVersionedMd5(newConfig, 1); @@ -138,10 +156,10 @@ public class ConfigCacheServiceVersionTest { System.out.println("Extended param change: executeTimeOut 3000 -> 5000"); System.out.println("Old v1 MD5: " + oldV1Md5); System.out.println("New v1 MD5: " + newV1Md5); - System.out.println("Are they different? " + !oldV1Md5.equals(newV1Md5)); + System.out.println("Are they same? " + oldV1Md5.equals(newV1Md5)); - Assert.assertNotEquals("v1 MD5 should differ (uses full comparison)", oldV1Md5, newV1Md5); - System.out.println("Test passed: v1 client will refresh for extended param change"); + Assert.assertEquals("v1 should now leverage metadata to skip unsupported fields", oldV1Md5, newV1Md5); + System.out.println("Test passed: v1 client no longer refreshes for unsupported fields"); } /**