This is an automated email from the ASF dual-hosted git repository.

zstan pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/ignite-3.git


The following commit(s) were added to refs/heads/main by this push:
     new eb0f19d085c IGNITE-25747 Configuration compatibility. Support 
annotated values validation. (#6225)
eb0f19d085c is described below

commit eb0f19d085c440e9d7a4a50d242d796c2f412644
Author: Max Zhuravkov <[email protected]>
AuthorDate: Tue Jul 15 18:48:50 2025 +0300

    IGNITE-25747 Configuration compatibility. Support annotated values 
validation. (#6225)
---
 .../AnnotationCompatibilityValidator.java          |  45 +++
 .../compatibility/framework/ConfigAnnotation.java  |  47 ++-
 .../framework/ConfigAnnotationValue.java           | 101 ++++++
 .../framework/ConfigAnnotationsValidator.java      | 178 +++++++++
 .../compatibility/framework/ConfigNode.java        |  11 +-
 .../framework/ConfigNodeSerializer.java            |   3 +
 .../ConfigurationAnnotationValidatorSelfTest.java  | 400 +++++++++++++++++++++
 .../framework/ConfigurationTreeComparator.java     |  39 +-
 .../ConfigurationTreeComparatorSelfTest.java       |   4 +-
 .../framework/ConfigurationTreeScanner.java        | 166 ++++++++-
 ...seAnnotationCompatibilityValidatorSelfTest.java |  48 +++
 .../DefaultAnnotationCompatibilityValidator.java   |  56 +++
 ...ExceptKeysAnnotationCompatibilityValidator.java |  39 ++
 .../annotations/ExceptKeysValidatorTest.java       |  73 ++++
 .../OneOfAnnotationCompatibilityValidator.java     |  46 +++
 .../framework/annotations/OneOfValidatorTest.java  |  96 +++++
 .../RangeAnnotationCompatibilityValidator.java     |  46 +++
 .../framework/annotations/RangeValidatorTest.java  | 101 ++++++
 .../compatibility/configuration/snapshot.bin       | Bin 3636 -> 4020 bytes
 19 files changed, 1458 insertions(+), 41 deletions(-)

diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/AnnotationCompatibilityValidator.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/AnnotationCompatibilityValidator.java
new file mode 100644
index 00000000000..bbfaf70b878
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/AnnotationCompatibilityValidator.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.configuration.compatibility.framework;
+
+import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
+
+import java.util.List;
+import java.util.function.Function;
+
+/**
+ * Annotation compatibility validator.
+ */
+@FunctionalInterface
+public interface AnnotationCompatibilityValidator {
+    /** Validates compatibility between annotation revisions. */
+    void validate(ConfigAnnotation candidate, ConfigAnnotation current, 
List<String> errors);
+
+    /** 
+     * Reads a value of the given annotation property. Note that all int 
values are stored as {@code long} values.
+     */
+    static <T> T getValue(ConfigAnnotation annotation, String name, 
Function<ConfigAnnotationValue, T> parse) {
+        ConfigAnnotationValue value = annotation.properties().get(name);
+        try {
+            return parse.apply(value);
+        } catch (Exception e) {
+            String errorMessage = format("Unable to read annotation property. 
Property: {}, annotation: {}", name, annotation.name());
+            throw new IllegalStateException(errorMessage, e);
+        }
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotation.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotation.java
index b8f841cac52..c3017860400 100644
--- 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotation.java
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotation.java
@@ -17,7 +17,10 @@
 
 package org.apache.ignite.internal.configuration.compatibility.framework;
 
+import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
+
 import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.LinkedHashMap;
 import java.util.Map;
 import java.util.Objects;
 import java.util.stream.Collectors;
@@ -29,31 +32,53 @@ public class ConfigAnnotation {
     @JsonProperty
     private String name;
     @JsonProperty
-    private Map<String, String> properties;
+    private Map<String, ConfigAnnotationValue> properties = new 
LinkedHashMap<>();
 
+    @SuppressWarnings("unused")
     ConfigAnnotation() {
         // Default constructor for Jackson deserialization.
     }
 
-    ConfigAnnotation(String name) {
-        this(name, Map.of());
-    }
-
-    ConfigAnnotation(String name, Map<String, String> properties) {
+    ConfigAnnotation(String name, Map<String, ConfigAnnotationValue> 
properties) {
         this.name = name;
         this.properties = properties;
     }
 
-    @Override
-    public int hashCode() {
-        return Objects.hashCode(name);
+    /**
+     * Returns annotation type name.
+     */
+    public String name() {
+        return name;
+    }
+
+    /**
+     * Returns values of annotation properties.
+     */
+    public Map<String, ConfigAnnotationValue> properties() {
+        return properties;
     }
 
     @Override
     public boolean equals(Object o) {
-        // TODO https://issues.apache.org/jira/browse/IGNITE-25747 Validate 
annotations properly.
-        return o != null && getClass() == o.getClass() && 
name.equals(((ConfigAnnotation) o).name);
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        ConfigAnnotation that = (ConfigAnnotation) o;
+        return Objects.equals(name, that.name) && Objects.equals(properties, 
that.properties);
+    }
 
+    @Override
+    public int hashCode() {
+        return Objects.hash(name, properties);
+    }
+
+    String digest() {
+        // This method is used for metadata comparison,
+        // so we exclude values from the comparison. 
+        return name + (properties == null || properties.isEmpty() ? ""
+                : properties.entrySet().stream()
+                        .map(e -> format("{}=<{}>", e.getKey(), 
e.getValue().typeName()))
+                        .collect(Collectors.joining(",", "(", ")")));
     }
 
     @Override
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotationValue.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotationValue.java
new file mode 100644
index 00000000000..ebde01b8464
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotationValue.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.configuration.compatibility.framework;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Annotation property value.
+ */
+public class ConfigAnnotationValue {
+    @JsonProperty
+    private String typeName;
+    @JsonProperty
+    private Object value;
+
+    @SuppressWarnings("unused")
+    public ConfigAnnotationValue() {
+        // Default constructor for Jackson deserialization.
+    }
+
+    private ConfigAnnotationValue(String typeName, Object value) {
+        this.typeName = typeName;
+        this.value = value;
+    }
+
+    /**
+     * Creates a value for a non-array property.
+     *
+     * @param className Class name.
+     * @param value Value.
+     * @return Value.
+     */
+    public static ConfigAnnotationValue createValue(String className, Object 
value) {
+        return new ConfigAnnotationValue(className, value);
+    }
+
+
+    /**
+     * Creates a value for an array property.
+     *
+     * @param elementClassName Element type name.
+     * @param elements Elements.
+     * @return Value.
+     */
+    public static ConfigAnnotationValue createArray(String elementClassName, 
List<Object> elements) {
+        return new ConfigAnnotationValue(elementClassName + "[]", elements);
+    }
+
+    /**
+     * Returns a value.
+     */
+    public Object value() {
+        return value;
+    }
+
+    /**
+     * Returns a type name.
+     */
+    public String typeName() {
+        return typeName;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public boolean equals(Object o) {
+        if (o == null || getClass() != o.getClass()) {
+            return false;
+        }
+        ConfigAnnotationValue that = (ConfigAnnotationValue) o;
+        return Objects.equals(typeName, that.typeName) && 
Objects.equals(value, that.value);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public int hashCode() {
+        return Objects.hash(typeName, value);
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public String toString() {
+        return typeName + ":" + value;
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotationsValidator.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotationsValidator.java
new file mode 100644
index 00000000000..f96eb325825
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigAnnotationsValidator.java
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.configuration.compatibility.framework;
+
+import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import java.util.stream.Collectors;
+import org.apache.ignite.configuration.validation.ExceptKeys;
+import org.apache.ignite.configuration.validation.OneOf;
+import org.apache.ignite.configuration.validation.Range;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.annotations.DefaultAnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.annotations.ExceptKeysAnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.annotations.OneOfAnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.annotations.RangeAnnotationCompatibilityValidator;
+
+/**
+ * Configuration annotation validator.
+ */
+final class ConfigAnnotationsValidator {
+
+    private static final DefaultAnnotationCompatibilityValidator 
DEFAULT_VALIDATOR = new DefaultAnnotationCompatibilityValidator();
+
+    private final Map<String, AnnotationCompatibilityValidator> validators;
+
+    /** Constructor. */
+    ConfigAnnotationsValidator() {
+        this(Map.of(
+                Range.class.getName(), new 
RangeAnnotationCompatibilityValidator(),
+                ExceptKeys.class.getName(), new 
ExceptKeysAnnotationCompatibilityValidator(),
+                OneOf.class.getName(), new 
OneOfAnnotationCompatibilityValidator()
+        ));
+    }
+
+    /** Constructor. */
+    ConfigAnnotationsValidator(Map<String, AnnotationCompatibilityValidator> 
validators) {
+        this.validators = Map.copyOf(validators);
+    }
+
+    /**
+     * Validates annotations.
+     */
+    void validate(ConfigNode candidate, ConfigNode node, List<String> errors) {
+        List<ConfigAnnotation> currentAnnotations = node.annotations();
+        List<ConfigAnnotation> candidateAnnotations = candidate.annotations();
+
+        Map<String, ConfigAnnotation> currentMap = new HashMap<>();
+        for (ConfigAnnotation annotation : currentAnnotations) {
+            currentMap.put(annotation.name(), annotation);
+        }
+
+        Map<String, ConfigAnnotation> candidateMap = new HashMap<>();
+        for (ConfigAnnotation annotation : candidateAnnotations) {
+            candidateMap.put(annotation.name(), annotation);
+        }
+
+        Set<String> newAnnotations = new TreeSet<>();
+
+        for (String currentKey : currentMap.keySet()) {
+            if (!candidateMap.containsKey(currentKey)) {
+                newAnnotations.add(currentKey);
+            }
+        }
+
+        if (!newAnnotations.isEmpty()) {
+            errors.add("Adding annotations is not allowed. New annotations: " 
+ newAnnotations);
+        }
+
+        Set<String> removedAnnotations = new TreeSet<>();
+
+        for (Map.Entry<String, ConfigAnnotation> entry : 
candidateMap.entrySet()) {
+            ConfigAnnotation candidateAnnotation = entry.getValue();
+            ConfigAnnotation currentAnnotation = 
currentMap.get(entry.getKey());
+
+            if (currentAnnotation == null) {
+                removedAnnotations.add(candidateAnnotation.name());
+                continue;
+            }
+
+            validateStructure(candidateAnnotation, currentAnnotation, errors);
+        }
+
+        if (!removedAnnotations.isEmpty()) {
+            errors.add("Removing annotations is not allowed. Removed 
annotations: " + removedAnnotations);
+        }
+    }
+
+    private void validateStructure(ConfigAnnotation candidate, 
ConfigAnnotation current, List<String> errors) {
+        assert candidate.name().equals(current.name()) : "Annotation name 
mismatch";
+
+        Set<String> currentProperties = current.properties().keySet();
+        Set<String> candidateProperties = candidate.properties().keySet();
+
+        Set<String> removed = candidateProperties.stream()
+                .filter(c -> !currentProperties.contains(c))
+                .collect(Collectors.toSet());
+
+        Set<String> added = currentProperties.stream()
+                .filter(c -> !candidateProperties.contains(c))
+                .collect(Collectors.toSet());
+
+        if (!removed.isEmpty()) {
+            errors.add(candidate.name() + " removed properties " + new 
TreeSet<>(removed));
+        }
+
+        if (!added.isEmpty()) {
+            errors.add(candidate.name() + " added properties " + new 
TreeSet<>(added));
+        }
+
+        Set<String> changedTypes = new TreeSet<>();
+
+        for (Map.Entry<String, ConfigAnnotationValue> entry : 
candidate.properties().entrySet()) {
+            ConfigAnnotationValue currentValue = 
current.properties().get(entry.getKey());
+            if (currentValue == null) {
+                // Already an error, just skip it.
+                continue;
+            }
+
+            ConfigAnnotationValue candidateValue = entry.getValue();
+
+            if (!Objects.equals(candidateValue.typeName(), 
currentValue.typeName())) {
+                changedTypes.add(entry.getKey());
+            }
+        }
+
+        if (!changedTypes.isEmpty()) {
+            errors.add(candidate.name() + " properties with changed types " + 
changedTypes);
+        }
+
+        validateSpecificAnnotation(candidate, current, errors);
+    }
+
+    private void validateSpecificAnnotation(ConfigAnnotation candidate, 
ConfigAnnotation current, List<String> errors) {
+        AnnotationCompatibilityValidator validator = 
validators.getOrDefault(candidate.name(), DEFAULT_VALIDATOR);
+
+        List<String> newErrors = new ArrayList<>();
+        validator.validate(candidate, current, newErrors);
+
+        errors.addAll(newErrors);
+
+        // Report additional error iff the annotation has properties and there 
is no custom validator.
+        if (!newErrors.isEmpty()) {
+            boolean hasProperties = !candidate.properties().isEmpty() || 
!current.properties().isEmpty();
+            if (hasProperties && validator == DEFAULT_VALIDATOR) {
+                String error = format("Annotation requires a custom 
compatibility validator: {}. "
+                                + "Consider using {} if every change should be 
treated as incompatible "
+                                + "or implement an {} for this annotation", 
+                        candidate.name(),
+                        
DefaultAnnotationCompatibilityValidator.class.getName(),
+                        AnnotationCompatibilityValidator.class.getName()
+                );
+
+                errors.add(error);
+            }
+        }
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigNode.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigNode.java
index 019d746999a..b964f9cedbd 100644
--- 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigNode.java
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigNode.java
@@ -52,10 +52,12 @@ public class ConfigNode {
 
     // Non-serializable fields.
     @JsonIgnore
-    @Nullable private ConfigNode parent;
+    @Nullable 
+    private ConfigNode parent;
     @JsonIgnore
     private EnumSet<Flags> flags;
 
+    @SuppressWarnings("unused")
     ConfigNode() {
         // Default constructor for Jackson deserialization.
     }
@@ -238,7 +240,8 @@ public class ConfigNode {
     /**
      * Constructs the full path of this node in the configuration tree.
      */
-    String path() {
+    @JsonIgnore
+    public String path() {
         String name = name();
 
         return parent == null ? name : parent.path() + '.' + name;
@@ -259,7 +262,7 @@ public class ConfigNode {
         // Avoid actual class name from being compared for non-value nodes.
         Predicate<Entry<String, String>> filter = isValue()
                 ? e -> true
-                : e  -> !e.getKey().equals(Attributes.CLASS);
+                : e -> !e.getKey().equals(Attributes.CLASS);
 
         String attributes = this.attributes.entrySet().stream()
                 .filter(filter)
@@ -268,7 +271,7 @@ public class ConfigNode {
 
         return path() + ": ["
                 + attributes
-                + ", annotations=" + 
annotations().stream().map(ConfigAnnotation::toString).collect(Collectors.joining(",",
 "[", "]"))
+                + ", annotations=" + 
annotations().stream().map(ConfigAnnotation::digest).collect(Collectors.joining(",",
 "[", "]"))
                 + ", flags=" + flagsHexString
                 + (childNodeMap.isEmpty() ? "" : ", children=" + 
childNodeMap.size())
                 + ']';
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigNodeSerializer.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigNodeSerializer.java
index ef2b370168b..16033713ecb 100644
--- 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigNodeSerializer.java
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigNodeSerializer.java
@@ -17,6 +17,7 @@
 
 package org.apache.ignite.internal.configuration.compatibility.framework;
 
+import com.fasterxml.jackson.databind.DeserializationFeature;
 import com.fasterxml.jackson.databind.MappingIterator;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import java.io.IOException;
@@ -42,6 +43,8 @@ public class ConfigNodeSerializer {
      */
     public static List<ConfigNode> readAsJson(IgniteDataInput in) throws 
IOException {
         ObjectMapper objectMapper = new ObjectMapper();
+        // To support {Object val} where val can be both long and int, 
deserialize always ints as longs.
+        objectMapper.configure(DeserializationFeature.USE_LONG_FOR_INTS, true);
 
         MappingIterator<ConfigNode> objectMappingIterator = 
objectMapper.readerFor(ConfigNode.class).readValues(in);
 
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationAnnotationValidatorSelfTest.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationAnnotationValidatorSelfTest.java
new file mode 100644
index 00000000000..354b088e91a
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationAnnotationValidatorSelfTest.java
@@ -0,0 +1,400 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.ignite.internal.configuration.compatibility.framework;
+
+import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.lang.annotation.Annotation;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.stream.Stream;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigNode.Flags;
+import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+import org.hamcrest.CoreMatchers;
+import org.hamcrest.Matcher;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests case for {@link ConfigAnnotationsValidator}.
+ */
+public class ConfigurationAnnotationValidatorSelfTest extends 
BaseIgniteAbstractTest {
+
+    @Test
+    public void addingAnnotationBreaksCompatibility() {
+        ConfigNode candidate = newNode(new ConfigAnnotation("a", Map.of()));
+        ConfigNode current = newNode(new ConfigAnnotation("a", Map.of()), new 
ConfigAnnotation("b", Map.of()));
+
+        List<String> errors = new ArrayList<>();
+        ConfigAnnotationsValidator validator = new 
ConfigAnnotationsValidator(Map.of());
+
+        validator.validate(candidate, current, errors);
+
+        assertEquals(List.of("Adding annotations is not allowed. New 
annotations: [b]"), errors);
+    }
+
+    @Test
+    public void removingAnnotationBreaksCompatibility() {
+        ConfigNode candidate = newNode(new ConfigAnnotation("a", Map.of()), 
new ConfigAnnotation("b", Map.of()));
+        ConfigNode current = newNode(new ConfigAnnotation("a", Map.of()));
+
+        ConfigAnnotationsValidator validator = new 
ConfigAnnotationsValidator(Map.of());
+
+        List<String> errors = new ArrayList<>();
+        validator.validate(candidate, current, errors);
+
+        assertEquals(List.of("Removing annotations is not allowed. Removed 
annotations: [b]"), errors);
+    }
+
+    @Test
+    public void testConvertAnnotation() {
+        class SomeClass {
+            @AllTypes
+            @SuppressWarnings("unused")
+            public int allTypes;
+        }
+
+        ConfigAnnotation annotation = getAnnotation(SomeClass.class, 
"allTypes", AllTypes.class.getName(), AllTypes.class);
+        assertEquals(AllTypes.class.getName(), annotation.name());
+
+        Map<String, Object> expected = new HashMap<>(Map.of(
+                "bool", true,
+                "int8", 8L, // store integer types as longs
+                "int16", 16L, 
+                "int32", 32L,
+                "int64", 64L,
+                "f32", 32.0f,
+                "f64", 64.0d,
+                "string", "str",
+                "enumElement", Abc.B.name(),
+                "classElement", Integer.class.getName()
+        ));
+
+        for (var e : new HashMap<>(expected).entrySet()) {
+            expected.put(e.getKey() + "s", List.of(e.getValue()));
+        }
+
+        Map<String, Object> actual = new HashMap<>();
+        for (String p : annotation.properties().keySet()) {
+            ConfigAnnotationValue value = annotation.properties().get(p);
+            actual.put(p, value.value());
+        }
+
+        assertEquals(new TreeMap<>(expected), new TreeMap<>(actual));
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    private @interface AllTypes {
+        @SuppressWarnings("unused")
+        boolean bool() default true;
+
+        @SuppressWarnings("unused")
+        byte int8() default 8;
+
+        @SuppressWarnings("unused")
+        short int16() default 16;
+
+        @SuppressWarnings("unused")
+        int int32() default 32;
+
+        @SuppressWarnings("unused")
+        long int64() default 64;
+
+        @SuppressWarnings("unused")
+        float f32() default 32.0f;
+
+        @SuppressWarnings("unused")
+        double f64() default 64.0d;
+
+        @SuppressWarnings("unused")
+        String string() default "str";
+
+        @SuppressWarnings("unused")
+        Abc enumElement() default Abc.B;
+
+        @SuppressWarnings("unused")
+        Class<?> classElement() default Integer.class;
+
+        // Arrays
+
+        @SuppressWarnings("unused")
+        boolean[] bools() default true;
+
+        @SuppressWarnings("unused")
+        byte[] int8s() default 8;
+
+        @SuppressWarnings("unused")
+        short[] int16s() default 16;
+
+        @SuppressWarnings("unused")
+        int[] int32s() default 32;
+
+        @SuppressWarnings("unused")
+        long[] int64s() default 64;
+
+        @SuppressWarnings("unused")
+        float[] f32s() default 32.0f;
+
+        @SuppressWarnings("unused")
+        double[] f64s() default 64.0d;
+
+        @SuppressWarnings("unused")
+        String[] strings() default "str";
+
+        @SuppressWarnings("unused")
+        Abc[] enumElements() default Abc.B;
+
+        @SuppressWarnings("unused")
+        Class<?>[] classElements() default Integer.class;
+    }
+
+    private enum Abc {
+        A, B, C
+    }
+
+    @ParameterizedTest
+    @MethodSource("annotationValidationRules")
+    public void basicAnnotationValidationRules(AnnotationValidationRuleArgs 
args) {
+        class SomeClass {
+            @Base
+            @BasePlusField
+            @BaseArray
+            @BaseTypeChange
+            @BaseArrayTypeChange
+            @BaseMultipleChanges(f3 = "")
+            @SuppressWarnings("unused")
+            public String f1;
+        }
+
+        ConfigAnnotation candidate = getAnnotation(SomeClass.class, "f1", 
"AnnotationType", args.ann1);
+        ConfigAnnotation current = getAnnotation(SomeClass.class, "f1", 
"AnnotationType", args.ann2);
+
+        ConfigAnnotationsValidator validator = new 
ConfigAnnotationsValidator(Map.of("AnnotationType",
+                (candidate1, current1, errors) -> {
+                }));
+
+        List<String> errors = new ArrayList<>();
+        validator.validate(newNode(candidate), newNode(current), errors);
+
+        expectErrors(args.errors, errors);
+    }
+
+    private static Stream<AnnotationValidationRuleArgs> 
annotationValidationRules() {
+        return Stream.of(
+                // No changes
+                new AnnotationValidationRuleArgs(Base.class, Base.class, 
Set.of()),
+
+                // Adding field
+                new AnnotationValidationRuleArgs(Base.class, 
BasePlusField.class,
+                        Set.of("AnnotationType added properties [f2]")),
+
+                // Removing field 
+                new AnnotationValidationRuleArgs(BasePlusField.class, 
Base.class,
+                        Set.of("AnnotationType removed properties [f2]")),
+
+                // Changing field type
+                new AnnotationValidationRuleArgs(Base.class, 
BaseTypeChange.class,
+                        Set.of("AnnotationType properties with changed types 
[f1]")),
+
+                // Changing array field type
+                new AnnotationValidationRuleArgs(BaseArray.class, 
BaseArrayTypeChange.class,
+                        Set.of("AnnotationType properties with changed types 
[f1]")),
+
+                // Multiple errors
+
+                new AnnotationValidationRuleArgs(BasePlusField.class, 
BaseMultipleChanges.class,
+                        Set.of(
+                                "AnnotationType properties with changed types 
[f1]",
+                                "AnnotationType added properties [f3]",
+                                "AnnotationType removed properties [f2]"
+                        )
+                )
+        );
+    }
+
+    private static class AnnotationValidationRuleArgs {
+        final Class<? extends Annotation> ann1;
+
+        final Class<? extends Annotation> ann2;
+
+        final Set<String> errors;
+
+        AnnotationValidationRuleArgs(
+                Class<? extends Annotation> ann1,
+                Class<? extends Annotation> ann2,
+                Set<String> errors) {
+            this.ann1 = ann1;
+            this.ann2 = ann2;
+            this.errors = errors;
+        }
+
+        @Override
+        public String toString() {
+            return ann1.getSimpleName() + " -> " + ann2.getSimpleName() + " : 
" + errors;
+        }
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface Base {
+        @SuppressWarnings("unused")
+        String f1() default "";
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface BaseTypeChange {
+        @SuppressWarnings("unused")
+        int f1() default 0;
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface BaseArray {
+        @SuppressWarnings("unused")
+        int[] f1() default 0;
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface BaseArrayTypeChange {
+        @SuppressWarnings("unused")
+        boolean[] f1() default {true};
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface BasePlusField {
+        @SuppressWarnings("unused")
+        String f1() default "";
+
+        @SuppressWarnings("unused")
+        String f2() default "";
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface BaseMultipleChanges {
+        @SuppressWarnings("unused")
+        int f1() default 0;
+
+        @SuppressWarnings("unused")
+        String f3();
+    }
+
+    @ParameterizedTest
+    @MethodSource("noValidationRules")
+    public void noValidator(AnnotationValidationRuleArgs args) {
+        class SomeClass {
+            @Base
+            @BaseValueChange
+            @SuppressWarnings("unused")
+            public String f1;
+        }
+
+        ConfigAnnotation candidate = getAnnotation(SomeClass.class, "f1", 
"AnnotationType", args.ann1);
+        ConfigAnnotation current = getAnnotation(SomeClass.class, "f1", 
"AnnotationType", args.ann2);
+
+        ConfigAnnotationsValidator validator = new 
ConfigAnnotationsValidator(Map.of());
+
+        List<String> errors = new ArrayList<>();
+        validator.validate(newNode(candidate), newNode(current), errors);
+
+        expectErrors(args.errors, errors);
+    }
+
+    private static Stream<AnnotationValidationRuleArgs> noValidationRules() {
+        return Stream.of(
+                // No changes
+                new AnnotationValidationRuleArgs(Base.class, Base.class, 
Set.of()),
+                // Value changes
+                new AnnotationValidationRuleArgs(Base.class, 
BaseValueChange.class,
+                        Set.of(
+                                "Annotation requires a custom compatibility 
validator: AnnotationType",
+                                "AnnotationType changed values [f1]"
+                        )
+                )
+        );
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface BaseValueChange {
+        @SuppressWarnings("unused")
+        String f1() default "x";
+    }
+
+    @Test
+    public void callsSpecificValidator() {
+        class SomeClass {
+            @InvalidAnnotation
+            @SuppressWarnings("unused")
+            public String f1;
+        }
+
+        ConfigAnnotation candidate = getAnnotation(SomeClass.class, "f1", 
InvalidAnnotation.class.getName(), InvalidAnnotation.class);
+        ConfigAnnotation current = getAnnotation(SomeClass.class, "f1", 
InvalidAnnotation.class.getName(), InvalidAnnotation.class);
+
+        Map<String, AnnotationCompatibilityValidator> validators = 
Map.of(InvalidAnnotation.class.getName(),
+                (candidate1, current1, errors) -> errors.add("Invalid"));
+
+        ConfigAnnotationsValidator validator = new 
ConfigAnnotationsValidator(validators);
+
+        List<String> errors = new ArrayList<>();
+        validator.validate(newNode(candidate), newNode(current), errors);
+
+        assertEquals(List.of("Invalid"), errors);
+    }
+
+    @Retention(RetentionPolicy.RUNTIME)
+    @interface InvalidAnnotation {
+    }
+
+    private static ConfigNode newNode(ConfigAnnotation... annotations) {
+        return new ConfigNode(null, Map.of(), Arrays.asList(annotations), 
EnumSet.noneOf(Flags.class));
+    }
+
+    @SuppressWarnings({"unchecked", "rawtypes"})
+    private static void expectErrors(Collection<String> expected, List<String> 
actual) {
+        Matcher[] matchers = 
expected.stream().map(CoreMatchers::containsString).toArray(Matcher[]::new);
+        assertThat(actual, CoreMatchers.hasItems(matchers));
+    }
+
+    private static <A extends Annotation> ConfigAnnotation 
getAnnotation(Class<?> clazz,
+            String field,
+            String annotationName,
+            Class<A> annotationClass
+    ) {
+        try {
+            Field f = clazz.getField(field);
+            A annotation = f.getAnnotation(annotationClass);
+            if (annotation == null) {
+                throw new IllegalStateException(format("No annotation: {} 
found. Class: {}, field: {}", annotationClass, clazz, field));
+            }
+            return ConfigurationTreeScanner.extractAnnotation(annotationName, 
annotation);
+        } catch (NoSuchFieldException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeComparator.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeComparator.java
index a42ada46b95..73d4b4b9b41 100644
--- 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeComparator.java
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeComparator.java
@@ -34,6 +34,9 @@ import org.apache.ignite.configuration.KeyIgnorer;
  * Compares two configuration trees (snapshot and current).
  */
 public class ConfigurationTreeComparator {
+
+    private static final ConfigAnnotationsValidator ANNOTATION_VALIDATOR = new 
ConfigAnnotationsValidator();
+
     /**
      * Validates the current configuration is compatible with the snapshot.
      */
@@ -108,9 +111,15 @@ public class ConfigurationTreeComparator {
          */
         private ConfigNode find(ConfigNode node, Collection<ConfigNode> 
candidates) {
             for (ConfigNode candidate : candidates) {
-                if (match(node, candidate)) {
-                    return candidate;
+                if (!match(node, candidate)) {
+                    continue;
                 }
+
+                // node is a snapshot node
+                // candidate is a current configuration node.
+                validateAnnotations(node, candidate);
+
+                return candidate;
             }
 
             throw new IllegalStateException("No match found for node: " + node 
+ " in candidates: \n\t"
@@ -141,9 +150,29 @@ public class ConfigurationTreeComparator {
                 && matchNames(candidate, node)
                 && validateFlags(candidate, node)
                 && 
candidate.deletedPrefixes().containsAll(node.deletedPrefixes())
-                && (!node.isValue() || Objects.equals(candidate.type(), 
node.type())) // Value node types can be changed.
-                // TODO https://issues.apache.org/jira/browse/IGNITE-25747 
Validate annotations properly.
-                && candidate.annotations().containsAll(node.annotations()); // 
Annotations can't be removed.
+                && (!node.isValue() || Objects.equals(candidate.type(), 
node.type())); // Value node types can be changed.
+    }
+
+    private static void validateAnnotations(ConfigNode candidate, ConfigNode 
node) {
+        List<String> errors = new ArrayList<>();
+
+        ANNOTATION_VALIDATOR.validate(candidate, node, errors);
+
+        if (errors.isEmpty()) {
+            return;
+        }
+
+        StringBuilder sb = new StringBuilder();
+        sb.append("Configuration compatibility issues for ")
+                .append(node.path())
+                .append(':')
+                .append(System.lineSeparator());
+
+        for (var error : errors) {
+            sb.append("\t\t").append(error).append(System.lineSeparator());
+        }
+
+        throw new IllegalStateException(sb.toString());
     }
 
     private static boolean matchNames(ConfigNode candidate, ConfigNode node) {
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeComparatorSelfTest.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeComparatorSelfTest.java
index 6b3bcacd024..ab6e421e470 100644
--- 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeComparatorSelfTest.java
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeComparatorSelfTest.java
@@ -164,8 +164,8 @@ public class ConfigurationTreeComparatorSelfTest {
                 new ConfigNode(
                         root2,
                         Map.of(ConfigNode.Attributes.NAME, "child"),
-                        List.of(new 
ConfigAnnotation(Deprecated.class.getName())),
-                        EnumSet.of(Flags.IS_VALUE))
+                        List.of(),
+                        EnumSet.of(Flags.IS_VALUE, Flags.IS_DEPRECATED))
         ));
 
         // Adding deprecation is compatible change.
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeScanner.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeScanner.java
index 5db3da36ebd..b5795826ffe 100644
--- 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeScanner.java
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/ConfigurationTreeScanner.java
@@ -17,29 +17,39 @@
 
 package org.apache.ignite.internal.configuration.compatibility.framework;
 
-import static java.util.function.Predicate.not;
-
 import java.lang.annotation.Annotation;
+import java.lang.annotation.Repeatable;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.reflect.Array;
 import java.lang.reflect.Field;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
 import java.lang.reflect.Modifier;
 import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Comparator;
 import java.util.EnumSet;
+import java.util.HashMap;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
+import java.util.stream.Stream;
 import org.apache.ignite.configuration.ConfigurationModule;
 import org.apache.ignite.configuration.annotation.AbstractConfiguration;
 import org.apache.ignite.configuration.annotation.Config;
 import org.apache.ignite.configuration.annotation.ConfigValue;
 import org.apache.ignite.configuration.annotation.ConfigurationExtension;
 import org.apache.ignite.configuration.annotation.ConfigurationRoot;
+import org.apache.ignite.configuration.annotation.InjectedName;
+import org.apache.ignite.configuration.annotation.InjectedValue;
 import org.apache.ignite.configuration.annotation.NamedConfigValue;
 import org.apache.ignite.configuration.annotation.PolymorphicConfig;
+import org.apache.ignite.configuration.annotation.PolymorphicId;
 import org.apache.ignite.configuration.annotation.PublicName;
 import org.apache.ignite.configuration.annotation.Value;
 import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigNode.Attributes;
@@ -51,27 +61,20 @@ import 
org.apache.ignite.internal.configuration.util.ConfigurationUtil;
    support named lists. See {@link 
org.apache.ignite.configuration.annotation.NamedConfigValue} annotation.
  TODO: https://issues.apache.org/jira/browse/IGNITE-25572
    support polymorphic nodes. See {@link 
org.apache.ignite.configuration.annotation.PolymorphicConfig} annotation.
- TODO https://issues.apache.org/jira/browse/IGNITE-25747
-   support {@link org.apache.ignite.configuration.validation.Range} annotation.
-   support {@link org.apache.ignite.configuration.validation.Endpoint} 
annotation.
-   support {@link org.apache.ignite.configuration.validation.PowerOfTwo} 
annotation.
-   support {@link org.apache.ignite.configuration.validation.OneOf} annotation.
-   support {@link org.apache.ignite.configuration.validation.NotBlank} 
annotation.
-   support {@link org.apache.ignite.configuration.validation.Immutable} 
annotation. ???
-   support {@link org.apache.ignite.configuration.validation.ExceptKeys} 
annotation.
-   support {@link org.apache.ignite.configuration.validation.CamelCaseKeys} 
annotation.
-   support {@link 
org.apache.ignite.internal.network.configuration.MulticastAddress} annotation. 
???
-   support {@link 
org.apache.ignite.internal.network.configuration.SslConfigurationValidator} 
annotation. ???
 */
 
 /**
  * Provides method to extract metadata from project configuration classes.
  */
 public class ConfigurationTreeScanner {
-    private static final Set<Class<?>> SUPPORTED_FIELD_ANNOTATIONS = Set.of(
+    private static final Set<Class<?>> SUPPORTED_ANNOTATIONS = Set.of(
             Value.class,
-            Deprecated.class, // See flags.
-            PublicName.class
+            Deprecated.class,
+            NamedConfigValue.class,
+            PublicName.class,
+            PolymorphicId.class,
+            InjectedName.class,
+            InjectedValue.class
     );
 
     /**
@@ -134,9 +137,14 @@ public class ConfigurationTreeScanner {
      */
     private static List<ConfigAnnotation> collectAdditionalAnnotations(Field 
field) {
         return Arrays.stream(field.getDeclaredAnnotations())
-                .map(Annotation::annotationType)
-                .filter(not(SUPPORTED_FIELD_ANNOTATIONS::contains))
-                .map(a -> new ConfigAnnotation(a.getName()))
+                .flatMap(a -> {
+                    if (SUPPORTED_ANNOTATIONS.contains(a.annotationType())) {
+                        return Stream.empty();
+                    } else {
+                        ConfigAnnotation configAnnotation = 
extractAnnotation(a.annotationType().getName(), a);
+                        return Stream.of(configAnnotation);
+                    }
+                })
                 .collect(Collectors.toList());
     }
 
@@ -232,5 +240,125 @@ public class ConfigurationTreeScanner {
             return polymorphicExtensions.getOrDefault(polymorphicClass, 
Set.of());
         }
     }
+
+    /** Creates {@link ConfigAnnotation} from the given java annotation. */
+    public static ConfigAnnotation extractAnnotation(String name, Annotation 
annotation) {
+        Class<?> type = annotation.annotationType();
+        Repeatable repeatable = type.getAnnotation(Repeatable.class);
+        if (repeatable != null) {
+            throw new IllegalStateException("Repeatable annotations are not 
supported: " + annotation);
+        }
+
+        Map<String, ConfigAnnotationValue> properties = new HashMap<>();
+
+        for (Method method : type.getMethods()) {
+            // Skip methods inherited from the object class such as equals, 
hashCode, etc.
+            if (BuiltinMethod.METHODS.contains(new BuiltinMethod(method))) {
+                continue;
+            }
+
+            String propertyName = method.getName();
+            Class<?> returnType = method.getReturnType();
+            ConfigAnnotationValue propertyValue;
+
+            Object result;
+            try {
+                result = method.invoke(annotation);
+            } catch (IllegalAccessException | InvocationTargetException e) {
+                throw new IllegalStateException("Failed invoke annotation 
method: " + method, e);
+            }
+
+            if (returnType.isArray()) {
+                Class<?> componentType = returnType.getComponentType();
+
+                List<Object> elements = convertArray(result, componentType, 
annotation);
+                propertyValue = 
ConfigAnnotationValue.createArray(componentType.getName(), elements);
+            } else {
+                Object convertedValue = convertValue(result, returnType, 
annotation);
+                propertyValue = 
ConfigAnnotationValue.createValue(returnType.getName(), convertedValue);
+            }
+
+            properties.put(propertyName, propertyValue);
+        }
+
+        return new ConfigAnnotation(name, properties);
+    }
+
+    private static <T> List<Object> convertArray(Object elements, Class<?> 
elementType, Annotation annotation) {
+        int length = Array.getLength(elements);
+        List<Object> list = new ArrayList<>(length);
+
+        for (int i = 0; i < length; i++) {
+            Object element = Array.get(elements, i);
+            Object convertedElement = convertValue(element, elementType, 
annotation);
+
+            list.add(convertedElement);
+        }
+
+        return list;
+    }
+
+    private static Object convertValue(Object value, Class<?> returnType, 
Annotation annotation) {
+        if (returnType == byte.class || returnType == short.class || 
returnType == int.class || returnType == long.class) {
+            // Store integer types as longs because jackson deserializes longs 
that fit into INT as ints by default,
+            // it is to store read ints as longs to make validation easier.
+            Number val = (Number) value;
+            return val.longValue();
+        } else if (value instanceof Float || value instanceof Double) {
+            return value;
+        } else if (returnType == String.class || returnType == boolean.class) {
+            return value;
+        } else if (returnType.isEnum()) {
+            return value.toString();
+        } else if (returnType == Class.class) {
+            Class<?> clazz = (Class<?>) value;
+            return clazz.getName();
+        } else {
+            throw new IllegalArgumentException("Supported annotation property 
type: " + returnType + ". Annotation: " + annotation);
+        }
+    }
+
+    private static final class BuiltinMethod {
+
+        @Retention(RetentionPolicy.RUNTIME)
+        private @interface EmptyAnnotation {
+        }
+
+        private static final Set<BuiltinMethod> METHODS;
+
+        static {
+            // Collect methods that are present on all annotation classes, so 
we can exclude them from processing.
+            METHODS = Arrays.stream(EmptyAnnotation.class.getMethods())
+                    .map(BuiltinMethod::new)
+                    .collect(Collectors.toSet());
+        }
+
+        private final String name;
+
+        private final Class<?> returnType;
+
+        private final List<Object> parameterTypes;
+
+        private BuiltinMethod(Method method) {
+            this.name = method.getName();
+            this.returnType = method.getReturnType();
+            this.parameterTypes = Arrays.asList(method.getParameterTypes());
+        }
+
+        @Override
+        public boolean equals(Object o) {
+            if (o == null || getClass() != o.getClass()) {
+                return false;
+            }
+            BuiltinMethod that = (BuiltinMethod) o;
+            return Objects.equals(name, that.name) && 
Objects.equals(returnType, that.returnType) && Objects.equals(
+                    parameterTypes, that.parameterTypes);
+        }
+
+        @Override
+        public int hashCode() {
+            return Objects.hash(name, returnType, parameterTypes);
+        }
+    }
 }
 
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/BaseAnnotationCompatibilityValidatorSelfTest.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/BaseAnnotationCompatibilityValidatorSelfTest.java
new file mode 100644
index 00000000000..db56eb8ca88
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/BaseAnnotationCompatibilityValidatorSelfTest.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 
org.apache.ignite.internal.configuration.compatibility.framework.annotations;
+
+import static org.apache.ignite.internal.lang.IgniteStringFormatter.format;
+
+import java.lang.annotation.Annotation;
+import java.lang.reflect.Field;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotation;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigurationTreeScanner;
+import org.apache.ignite.internal.testframework.BaseIgniteAbstractTest;
+
+/**
+ * Annotation compatibility validator tests.
+ */
+abstract class BaseAnnotationCompatibilityValidatorSelfTest extends 
BaseIgniteAbstractTest {
+    protected static ConfigAnnotation getAnnotation(Class<?> clazz,
+            String field,
+            String annotationName,
+            Class<? extends Annotation> annotationClass
+    ) {
+        try {
+            Field f = clazz.getField(field);
+            Annotation annotation = f.getAnnotation(annotationClass);
+            if (annotation == null) {
+                throw new IllegalStateException(format("No annotation: {} 
found. Class: {}, field: {}", annotationClass, clazz, field));
+            }
+            return ConfigurationTreeScanner.extractAnnotation(annotationName, 
annotation);
+        } catch (NoSuchFieldException e) {
+            throw new IllegalStateException(e);
+        }
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/DefaultAnnotationCompatibilityValidator.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/DefaultAnnotationCompatibilityValidator.java
new file mode 100644
index 00000000000..57dfdb4a38b
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/DefaultAnnotationCompatibilityValidator.java
@@ -0,0 +1,56 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 
org.apache.ignite.internal.configuration.compatibility.framework.annotations;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.AnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotation;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotationValue;
+
+/**
+ * Default validator. All changes made to an annotation are incompatible.
+ */
+public class DefaultAnnotationCompatibilityValidator implements 
AnnotationCompatibilityValidator {
+    @Override
+    public void validate(ConfigAnnotation candidate, ConfigAnnotation current, 
List<String> errors) {
+        Map<String, ConfigAnnotationValue> candidateValues = 
candidate.properties();
+        Map<String, ConfigAnnotationValue> currentValues = 
current.properties();
+
+        Set<String> changedValues = new TreeSet<>();
+
+        for (Map.Entry<String, ConfigAnnotationValue> e : 
candidateValues.entrySet()) {
+            ConfigAnnotationValue value = currentValues.get(e.getKey());
+            if (value == null) {
+                // Handled by base validator.
+                continue;
+            }
+
+            if (!Objects.equals(e.getValue(), value)) {
+                changedValues.add(e.getKey());
+            }
+        }
+
+        if (!changedValues.isEmpty()) {
+            errors.add(candidate.name() + " changed values " + changedValues);
+        }
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/ExceptKeysAnnotationCompatibilityValidator.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/ExceptKeysAnnotationCompatibilityValidator.java
new file mode 100644
index 00000000000..ed47ef1a9c5
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/ExceptKeysAnnotationCompatibilityValidator.java
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 
org.apache.ignite.internal.configuration.compatibility.framework.annotations;
+
+import java.util.List;
+import org.apache.ignite.configuration.validation.ExceptKeys;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.AnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotation;
+
+/**
+ * Validator for the {@link ExceptKeys} annotation.
+ */
+public class ExceptKeysAnnotationCompatibilityValidator implements 
AnnotationCompatibilityValidator {
+    /** {@inheritDoc} */
+    @Override
+    public void validate(ConfigAnnotation candidate, ConfigAnnotation current, 
List<String> errors) {
+        List<String> candidateKeys = 
AnnotationCompatibilityValidator.getValue(candidate, "value", (v) -> 
(List<String>) v.value());
+        List<String> currentKeys = 
AnnotationCompatibilityValidator.getValue(current, "value", (v) -> 
(List<String>) v.value());
+
+        if (!candidateKeys.containsAll(currentKeys)) {
+            errors.add("ExceptKeys: changed keys from " + candidateKeys + " to 
" + currentKeys);
+        }
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/ExceptKeysValidatorTest.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/ExceptKeysValidatorTest.java
new file mode 100644
index 00000000000..a6acbcc488f
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/ExceptKeysValidatorTest.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 
org.apache.ignite.internal.configuration.compatibility.framework.annotations;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.apache.ignite.configuration.validation.ExceptKeys;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.AnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotation;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests for {@link ExceptKeysAnnotationCompatibilityValidator}.
+ */
+public class ExceptKeysValidatorTest extends 
BaseAnnotationCompatibilityValidatorSelfTest {
+
+    @ParameterizedTest
+    @MethodSource("exceptKeysRules")
+    public void exceptKeysAnnotation(String f1, String f2, List<String> 
expectedErrors) {
+        class SomeClass {
+            @ExceptKeys({"a", "b", "c"})
+            @SuppressWarnings("unused")
+            public String base;
+
+            @ExceptKeys("a")
+            @SuppressWarnings("unused")
+            public String removeKeys;
+
+            @ExceptKeys({"a", "b", "c", "d", "e"})
+            @SuppressWarnings("unused")
+            public String addKeys;
+        }
+
+        ConfigAnnotation candidate = getAnnotation(SomeClass.class, f1, 
ExceptKeys.class.getName(), ExceptKeys.class);
+        ConfigAnnotation current = getAnnotation(SomeClass.class, f2, 
ExceptKeys.class.getName(), ExceptKeys.class);
+
+        AnnotationCompatibilityValidator validator = new 
ExceptKeysAnnotationCompatibilityValidator();
+
+        List<String> errors = new ArrayList<>();
+        validator.validate(candidate, current, errors);
+
+        assertEquals(Set.copyOf(expectedErrors), Set.copyOf(errors));
+    }
+
+    private static Stream<Arguments> exceptKeysRules() {
+        return Stream.of(
+                Arguments.of("base", "base", List.of()),
+                Arguments.of("base", "removeKeys", List.of()),
+                Arguments.of("base", "addKeys", List.of("ExceptKeys: changed 
keys from [a, b, c] to [a, b, c, d, e]"))
+        );
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/OneOfAnnotationCompatibilityValidator.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/OneOfAnnotationCompatibilityValidator.java
new file mode 100644
index 00000000000..9332e22369b
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/OneOfAnnotationCompatibilityValidator.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 
org.apache.ignite.internal.configuration.compatibility.framework.annotations;
+
+import java.util.List;
+import org.apache.ignite.configuration.validation.OneOf;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.AnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotation;
+
+/**
+ * Validator for the {@link OneOf} annotation.
+ */
+public class OneOfAnnotationCompatibilityValidator implements 
AnnotationCompatibilityValidator {
+    /** {@inheritDoc} */
+    @Override
+    public void validate(ConfigAnnotation candidate, ConfigAnnotation current, 
List<String> errors) {
+        List<String> candidateKeys = 
AnnotationCompatibilityValidator.getValue(candidate, "value", (v) -> 
(List<String>) v.value());
+        List<String> currentKeys = 
AnnotationCompatibilityValidator.getValue(current, "value", (v) -> 
(List<String>) v.value());
+
+        if (!currentKeys.containsAll(candidateKeys)) {
+            errors.add("OneOf: changed keys from " + candidateKeys + " to " + 
currentKeys);
+        }
+
+        boolean candidateCaseSensitive = 
AnnotationCompatibilityValidator.getValue(candidate, "caseSensitive", (v) -> 
(Boolean) v.value());
+        boolean currentCaseSensitive = 
AnnotationCompatibilityValidator.getValue(current, "caseSensitive", (v) -> 
(Boolean) v.value());
+
+        if (candidateCaseSensitive && !currentCaseSensitive) {
+            errors.add("OneOf: case-insensitive validation can' become 
case-sensitive, which is more restrictive");
+        }
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/OneOfValidatorTest.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/OneOfValidatorTest.java
new file mode 100644
index 00000000000..fada75fa6e9
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/OneOfValidatorTest.java
@@ -0,0 +1,96 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 
org.apache.ignite.internal.configuration.compatibility.framework.annotations;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Stream;
+import org.apache.ignite.configuration.validation.OneOf;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.AnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotation;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests for {@link OneOfAnnotationCompatibilityValidator}.
+ */
+public class OneOfValidatorTest extends 
BaseAnnotationCompatibilityValidatorSelfTest {
+
+    @ParameterizedTest
+    @MethodSource("oneOfRules")
+    public void oneOfAnnotation(String f1, String f2, List<String> 
expectedErrors) {
+        class SomeClass {
+            @OneOf({"a", "b", "c"})
+            @SuppressWarnings("unused")
+            public String base;
+
+            @OneOf("a")
+            @SuppressWarnings("unused")
+            public String removeKeys;
+
+            @OneOf({"a", "b", "c", "d", "e"})
+            @SuppressWarnings("unused")
+            public String addKeys;
+
+            @OneOf(value = {"a", "b", "c"}, caseSensitive = true)
+            @SuppressWarnings("unused")
+            public String caseSensitive;
+
+            @OneOf({"a", "b", "c"})
+            @SuppressWarnings("unused")
+            public String caseInsensitive;
+
+            @OneOf({"a", "b"})
+            @SuppressWarnings("unused")
+            public String caseInsensitiveRemoveKeys;
+        }
+
+        ConfigAnnotation candidate = getAnnotation(SomeClass.class, f1, 
OneOf.class.getName(), OneOf.class);
+        ConfigAnnotation current = getAnnotation(SomeClass.class, f2, 
OneOf.class.getName(), OneOf.class);
+
+        AnnotationCompatibilityValidator validator = new 
OneOfAnnotationCompatibilityValidator();
+
+        List<String> errors = new ArrayList<>();
+        validator.validate(candidate, current, errors);
+
+        assertEquals(expectedErrors, errors);
+    }
+
+    private static Stream<Arguments> oneOfRules() {
+        return Stream.of(
+                Arguments.of("base", "base", List.of()),
+                Arguments.of("base", "removeKeys", List.of("OneOf: changed 
keys from [a, b, c] to [a]")),
+                Arguments.of("base", "addKeys", List.of()),
+                Arguments.of("caseInsensitive", "caseSensitive", List.of()),
+                Arguments.of("caseSensitive", "caseInsensitive", 
+                        List.of(
+                                "OneOf: case-insensitive validation can' 
become case-sensitive, which is more restrictive"
+                        )
+                ),
+                Arguments.of("caseSensitive", "caseInsensitiveRemoveKeys",
+                        List.of(
+                                "OneOf: changed keys from [a, b, c] to [a, b]",
+                                "OneOf: case-insensitive validation can' 
become case-sensitive, which is more restrictive"
+                        )
+                )
+        );
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/RangeAnnotationCompatibilityValidator.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/RangeAnnotationCompatibilityValidator.java
new file mode 100644
index 00000000000..e75655a7c14
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/RangeAnnotationCompatibilityValidator.java
@@ -0,0 +1,46 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 
org.apache.ignite.internal.configuration.compatibility.framework.annotations;
+
+import java.util.List;
+import org.apache.ignite.configuration.validation.Range;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.AnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotation;
+
+/**
+ * Validator for the {@link Range} annotation.
+ */
+public class RangeAnnotationCompatibilityValidator implements 
AnnotationCompatibilityValidator {
+    /** {@inheritDoc} */
+    @Override
+    public void validate(ConfigAnnotation candidate, ConfigAnnotation current, 
List<String> errors) {
+        long candidateMin = 
AnnotationCompatibilityValidator.getValue(candidate, "min", (v) -> (Long) 
v.value());
+        long candidateMax = 
AnnotationCompatibilityValidator.getValue(candidate, "max", (v) -> (Long) 
v.value());
+
+        long currentMin = AnnotationCompatibilityValidator.getValue(current, 
"min", (v) -> (Long) v.value());
+        long currentMax = AnnotationCompatibilityValidator.getValue(current, 
"max", (v) -> (Long) v.value());
+
+        if (currentMin > candidateMin) {
+            errors.add("Range: min changed from " + candidateMin + " to " + 
currentMin);
+        }
+
+        if (currentMax < candidateMax) {
+            errors.add("Range: max changed from " + candidateMax + " to " + 
currentMax);
+        }
+    }
+}
diff --git 
a/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/RangeValidatorTest.java
 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/RangeValidatorTest.java
new file mode 100644
index 00000000000..420b3035e61
--- /dev/null
+++ 
b/modules/runner/src/test/java/org/apache/ignite/internal/configuration/compatibility/framework/annotations/RangeValidatorTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package 
org.apache.ignite.internal.configuration.compatibility.framework.annotations;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Stream;
+import org.apache.ignite.configuration.validation.Range;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.AnnotationCompatibilityValidator;
+import 
org.apache.ignite.internal.configuration.compatibility.framework.ConfigAnnotation;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+/**
+ * Tests for {@link RangeAnnotationCompatibilityValidator}.
+ */
+public class RangeValidatorTest extends 
BaseAnnotationCompatibilityValidatorSelfTest {
+
+    @ParameterizedTest
+    @MethodSource("rangeAnnotationRules")
+    public void rangeAnnotation(String f1, String f2, Set<String> 
expectedErrors) {
+        class SomeClass {
+            @Range(min = 10)
+            @SuppressWarnings("unused")
+            public int minBase;
+            @Range(min = 5)
+            @SuppressWarnings("unused")
+            public int minDecreased;
+            @Range(min = 12)
+            @SuppressWarnings("unused")
+            public int minIncreased;
+
+            @Range(max = 20)
+            @SuppressWarnings("unused")
+            public int maxBase;
+            @Range(max = 15)
+            @SuppressWarnings("unused")
+            public int maxDecreased;
+            @Range(max = 22)
+            @SuppressWarnings("unused")
+            public int maxIncreased;
+
+            @Range
+            @SuppressWarnings("unused")
+            public int base;
+            @Range(max = Long.MAX_VALUE)
+            @SuppressWarnings("unused")
+            public int maxToDefault;
+            @Range(min = Long.MIN_VALUE)
+            @SuppressWarnings("unused")
+            public int minToDefault;
+        }
+
+        ConfigAnnotation candidate = getAnnotation(SomeClass.class, f1, 
Range.class.getName(), Range.class);
+        ConfigAnnotation current = getAnnotation(SomeClass.class, f2, 
Range.class.getName(), Range.class);
+
+        AnnotationCompatibilityValidator validator = new 
RangeAnnotationCompatibilityValidator();
+
+        List<String> errors = new ArrayList<>();
+        validator.validate(candidate, current, errors);
+
+        assertEquals(Set.copyOf(expectedErrors), Set.copyOf(errors));
+    }
+
+    private static Stream<Arguments> rangeAnnotationRules() {
+        return Stream.of(
+                Arguments.of("minBase", "minBase", Set.of()),
+                Arguments.of("minBase", "minDecreased", Set.of()),
+                Arguments.of("minBase", "minIncreased", Set.of("Range: min 
changed from 10 to 12")),
+
+                Arguments.of("maxBase", "maxBase", Set.of()),
+                Arguments.of("maxBase", "maxIncreased", Set.of()),
+                Arguments.of("maxBase", "maxDecreased", Set.of("Range: max 
changed from 20 to 15")),
+
+                Arguments.of("base", "maxToDefault", Set.of()),
+                Arguments.of("maxToDefault", "base", Set.of()),
+
+                Arguments.of("base", "minToDefault", Set.of()),
+                Arguments.of("minToDefault", "base", Set.of())
+        );
+    }
+}
diff --git 
a/modules/runner/src/test/resources/compatibility/configuration/snapshot.bin 
b/modules/runner/src/test/resources/compatibility/configuration/snapshot.bin
index a527bbbc4d4..a0ed7892a13 100644
Binary files 
a/modules/runner/src/test/resources/compatibility/configuration/snapshot.bin 
and 
b/modules/runner/src/test/resources/compatibility/configuration/snapshot.bin 
differ

Reply via email to