@ConditionalOnClass is a Spring Boot feature but it relies on the fact that the ConditionEvaluator in Framework can read the metadata from it without having to load the classes it refers to. With the ASM use case, we use a MergedAnnotationsCollection. Which uses AnnotationTypeMappings which delegates to AnnotationsScanner.getDeclaredAnnotations(), and that ends up swallowing the exception and removes the @ConditionalOnClass(X.class) annotation from the mappings, because the X type cannot be loaded.

The code that filters out @ConditionalOnClass is actually in AttributeMethods.canLoad() - this method returns "false" when the type to be tested is not present.

Comment From: sbrannen

Steps to Reproduce

In case anyone wants to take a quick look at this in action, you can use the grpc-server sample project.

Then introduce the following test class.

class ConditionalOnClassTests {

    @Test
    void test() throws Exception {
        String resourcePath = GrpcServerFactoryAutoConfiguration.GrpcServletConfiguration.class.getName().replace(".", "/") + ".class";
        SimpleMetadataReaderFactory factory = new SimpleMetadataReaderFactory();
        MetadataReader reader = factory.getMetadataReader(new ClassPathResource(resourcePath));
        AnnotationMetadata metadata = reader.getAnnotationMetadata();

        MultiValueMap<String, @Nullable Object> attributes =
                metadata.getAllAnnotationAttributes(Conditional.class.getName(), true);

        // Names of Conditions found in @Conditional annotations
        List<String> conditionNames = attributes.values().stream()
                .flatMap(List::stream)
                .map(Object[].class::cast)
                .flatMap(Arrays::stream)
                .map(String::valueOf)
                .toList();

        // The following are the conditions declared in @Conditional annotations that
        // are meta-present on @ConditionalOnGrpcServletServer which is directly present
        // on GrpcServletConfiguration.
        //
        // This test fails because OnClassCondition is not found, since AttributeMethods.canLoad()
        // fails to load the io.grpc.servlet.jakarta.GrpcServlet type, which results in
        // AnnotationsScanner.getDeclaredAnnotations() removing the @ConditionalOnClass(GrpcServlet.class)
        // annotation from the MergedAnnotation TypeMappings.
        assertThat(conditionNames).containsExactly(
            "org.springframework.boot.autoconfigure.condition.OnWebApplicationCondition",
            "org.springframework.boot.autoconfigure.condition.OnClassCondition",
            "org.springframework.boot.autoconfigure.condition.OnPropertyCondition"
        );
    }

}

The following is the stack trace for the swallowed exception.

java.lang.TypeNotPresentException: Type io.grpc.servlet.jakarta.GrpcServlet not present
    at java.base/sun.reflect.annotation.TypeNotPresentExceptionProxy.generateException(TypeNotPresentExceptionProxy.java:47)
    at java.base/sun.reflect.annotation.AnnotationInvocationHandler.invoke(AnnotationInvocationHandler.java:89)
    at jdk.proxy2/jdk.proxy2.$Proxy30.value(Unknown Source)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at java.base/jdk.internal.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:77)
    at java.base/jdk.internal.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.base/java.lang.reflect.Method.invoke(Method.java:568)
    at org.springframework.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:281)
    at org.springframework.util.ReflectionUtils.invokeMethod(ReflectionUtils.java:266)
    at org.springframework.core.annotation.AnnotationUtils.invokeAnnotationMethod(AnnotationUtils.java:1078)
    at org.springframework.core.annotation.AttributeMethods.canLoad(AttributeMethods.java:102)
    at org.springframework.core.annotation.AnnotationsScanner.getDeclaredAnnotations(AnnotationsScanner.java:446)
    at org.springframework.core.annotation.AnnotationTypeMappings.addMetaAnnotationsToQueue(AnnotationTypeMappings.java:91)
    at org.springframework.core.annotation.AnnotationTypeMappings.addAllMappings(AnnotationTypeMappings.java:86)
    at org.springframework.core.annotation.AnnotationTypeMappings.<init>(AnnotationTypeMappings.java:74)
    at org.springframework.core.annotation.AnnotationTypeMappings$Cache.createMappings(AnnotationTypeMappings.java:281)
    at org.springframework.core.annotation.AnnotationTypeMappings$Cache.lambda$get$0(AnnotationTypeMappings.java:276)
    at org.springframework.util.ConcurrentReferenceHashMap$6.execute(ConcurrentReferenceHashMap.java:380)
    at org.springframework.util.ConcurrentReferenceHashMap$Segment.doTask(ConcurrentReferenceHashMap.java:667)
    at org.springframework.util.ConcurrentReferenceHashMap.doTask(ConcurrentReferenceHashMap.java:551)
    at org.springframework.util.ConcurrentReferenceHashMap.computeIfAbsent(ConcurrentReferenceHashMap.java:374)
    at org.springframework.core.annotation.AnnotationTypeMappings$Cache.get(AnnotationTypeMappings.java:276)
    at org.springframework.core.annotation.AnnotationTypeMappings.forAnnotationType(AnnotationTypeMappings.java:229)
    at org.springframework.core.annotation.AnnotationTypeMappings.forAnnotationType(AnnotationTypeMappings.java:192)
    at org.springframework.core.annotation.AnnotationTypeMappings.forAnnotationType(AnnotationTypeMappings.java:178)
    at org.springframework.core.annotation.TypeMappedAnnotation.of(TypeMappedAnnotation.java:601)
    at org.springframework.core.annotation.MergedAnnotation.of(MergedAnnotation.java:609)
    at org.springframework.core.type.classreading.MergedAnnotationReadingVisitor.visitEnd(MergedAnnotationReadingVisitor.java:96)
    at org.springframework.asm.ClassReader.readElementValues(ClassReader.java:3027)
    at org.springframework.asm.ClassReader.accept(ClassReader.java:615)
    at org.springframework.asm.ClassReader.accept(ClassReader.java:431)
    at org.springframework.core.type.classreading.SimpleMetadataReader.<init>(SimpleMetadataReader.java:48)
    at org.springframework.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(SimpleMetadataReaderFactory.java:62)
    at org.springframework.grpc.sample.ConditionalOnClassTests.grpc(ConditionalOnClassTests.java:22)

Comment From: sbrannen

I believe this may potentially be a regression that was introduced in Spring Framework 5.2, but I would need to run some tests against older Framework and Boot versions to verify that.

  • See #22884

Comment From: sbrannen

After further investigation, it appears that @ConditionalOnClass use cases that use class references instead of fully-qualified class names only fail if the @ConditionalOnClass annotation is meta-present.

In other words, the standard usage of @ConditionalOnClass(value = ...) works if @ConditionalOnClass is directly present on the source class.

The following tests demonstrate that.

conditionalOnClassAsDirectAnnotation() passes, because I'm looking up composed @Conditional annotations directly on @ConditionalOnGrpcServletServer.

However, as demonstrated in my previous comment, conditionalOnClassAsMetaAnnotation() fails, because @Conditional is meta-present on GrpcServletConfiguration,

@Test
void conditionalOnClassAsDirectAnnotation() throws Exception {
    Class<?> sourceClass = ConditionalOnGrpcServletServer.class;
    findComposedConditionalAnnotations(sourceClass);
}

@Test
void conditionalOnClassAsMetaAnnotation() throws Exception {
    Class<?> sourceClass = GrpcServerFactoryAutoConfiguration.GrpcServletConfiguration.class;

    // This test fails because OnClassCondition is not found, since AttributeMethods.canLoad()
    // fails to load the io.grpc.servlet.jakarta.GrpcServlet type, which results in
    // AnnotationsScanner.getDeclaredAnnotations() removing the @ConditionalOnClass(GrpcServlet.class)
    // annotation from the MergedAnnotation TypeMappings.
    findComposedConditionalAnnotations(sourceClass);
}

private void findComposedConditionalAnnotations(Class<?> sourceClass) throws IOException {
    String resourcePath = sourceClass.getName().replace(".", "/") + ".class";

    SimpleMetadataReaderFactory factory = new SimpleMetadataReaderFactory();
    MetadataReader reader = factory.getMetadataReader(new ClassPathResource(resourcePath));
    AnnotationMetadata metadata = reader.getAnnotationMetadata();

    MultiValueMap<String, @Nullable Object> attributes =
            metadata.getAllAnnotationAttributes(Conditional.class.getName(), true);

    // Names of Conditions found in @Conditional annotations
    List<String> conditionNames = attributes.values().stream()
            .flatMap(List::stream)
            .map(Object[].class::cast)
            .flatMap(Arrays::stream)
            .map(String::valueOf)
            .toList();

    // The following are the conditions declared in @Conditional annotations that
    // are meta-present on @ConditionalOnGrpcServletServer which is directly present
    // on GrpcServletConfiguration.
    assertThat(conditionNames).containsExactly(
        "org.springframework.boot.autoconfigure.condition.OnWebApplicationCondition",
        "org.springframework.boot.autoconfigure.condition.OnClassCondition",
        "org.springframework.boot.autoconfigure.condition.OnPropertyCondition"
    );
}

Thus, I'm not positive that this is a regression. It may have always worked like this for meta-annotations in ASM-based lookups. We use ASM for directly present annotations, but we use standard reflection via the MergedAnnotations API for meta-annotations.

Comment From: dsyer

I see. It still seems like a bug to me, but at least that probably explains why we never saw it before (and how hard it was to reproduce).

Comment From: sbrannen

Actually, this is explicitly documented in the Javadoc for @ConditionalOnClass(value).

The classes that must be present. Since this annotation is parsed by loading class bytecode, it is safe to specify classes here that may ultimately not be on the classpath, only if this annotation is directly on the affected component and not if this annotation is used as a composed, meta-annotation. In order to use this annotation as a meta-annotation, only use the name attribute.

Granted, the class-level Javadoc for @ConditionalOnClass does not spell out that caveat.

Comment From: sbrannen

@dsyer, for @ConditionalOnGrpcServletServer, switching to @ConditionalOnClass(name = "io.grpc.servlet.jakarta.GrpcServlet") should work for this particular use case.

I see. It still seems like a bug to me,

I certainly agree that it's non-intuitive, but it may be difficult to "fix". In any case, we'll discuss it within the team.

but at least that probably explains why we never saw it before (and how hard it was to reproduce).

Indeed. 👍

Comment From: sbrannen

@dsyer, for @ConditionalOnGrpcServletServer, switching to @ConditionalOnClass(name = "io.grpc.servlet.jakarta.GrpcServlet") should work for this particular use case.

I see you made the switch in https://github.com/spring-projects/spring-grpc/commit/1e1a8e4684f3c65ed343c11ed04730111011cfc8. 👍

Comment From: sbrannen

This is effectively a duplicate of:

  • 19874

And it's related to:

  • https://github.com/spring-projects/spring-boot/pull/8185
  • https://github.com/spring-projects/spring-boot/issues/1069
  • https://github.com/spring-projects/spring-boot/issues/1065
  • https://github.com/spring-projects/spring-boot/issues/15578
  • https://github.com/spring-projects/spring-framework/issues/16493

Comment From: sbrannen

Team Decision: Log a warning whenever a meta-annotation is ignored due to types not being present in the classpath — for example, when accessing the meta-annotation's attributes results in a TypeNotPresentException.