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");
}
/**