While trying to use projections in one of the projects that has R2DBC together with Blockhound we are facing a blockhound.BlockingOperationError: Blocking call! in our test module.
Stack trace
reactor.blockhound.BlockingOperationError: Blocking call! java.io.FileInputStream#readBytes
at java.io.FileInputStream.read
... ASM ClassReader ...
at o.s.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(...)
at o.s.data.projection.DefaultProjectionInformation$PropertyDescriptorSource.getMetadata(...)
at o.s.data.projection.DefaultProjectionInformation$PropertyDescriptorSource.(...)
at o.s.data.projection.DefaultProjectionInformation.(...)
at o.s.data.projection.ProxyProjectionFactory.createProjectionInformation(...)
at o.s.data.projection.ProxyProjectionFactory.getProjectionInformation(...)
at o.s.data.projection.EntityProjectionIntrospector.introspect(...)
at o.s.data.relational.core.conversion.MappingRelationalConverter.introspectProjection(...)
at o.s.data.r2dbc.core.R2dbcEntityTemplate.getSelectProjection(...) // (also getRowsFetchSpec)
... on thread reactor-http-nio-*
Root cause
The blocking call is DefaultProjectionInformation.PropertyDescriptorSource.getMetadata(...), which uses MetadataReaderFactory / ASM ClassReader to read the projection interface's .class file. Its only purpose is to recover declared method order (getMethodOrder), since Class.getDeclaredMethods() order is JVM-unspecified. This read is genuine, unavoidable classpath I/O.
ProxyProjectionFactory caches the resulting ProjectionInformation per type via computeIfAbsent, so the read happens once per type, per factory instance. The problem is that there are two different factory instances in play:
- At bootstrap (safe thread). Repository initialization eagerly resolves every query method: QueryExecutorMethodInterceptor → resolveQuery → new QueryMethod → new ResultProcessor → ReturnedType.of(...) → factoryA.getProjectionInformation(EntityView). This performs the ASM read on the boot thread (harmless) and caches it in factory A — the repository's SpelAwareProxyProjectionFactory created by RepositoryFactorySupport.getProjectionFactory().
- At the first request (event loop). R2dbcEntityTemplate → MappingRelationalConverter.introspectProjection → EntityProjectionIntrospector.introspect → factoryB.getProjectionInformation(EntityView). Factory B is the private final SpelAwareProxyProjectionFactory created inside MappingRelationalConverter — a different instance, with its own cold cache. So it performs the ASM read again, this time on the event loop.
The per-instance isolation is intentional (ReturnedType.CacheKey even includes projectionFactoryHashCode), so the bootstrap read cannot prime the converter's cache. Net effect: the class-file read runs twice, and read no. 2 lands on a non-blocking thread.
Proposed fix
Two complementary changes; the first is the durable one.
- Shared, factory-independent cache of the metadata read (spring-data-commons)
The blocking sub-step — reading AnnotationMetadata for method ordering — is purely type-derived and factory-independent (only the resulting ProjectionInformation is legitimately per-factory). Cache it below the per-factory layer, in DefaultProjectionInformation.getMetadata, keyed by type:
private static final Map<Class<?>, Optional<AnnotationMetadata>> METADATA_CACHE =
new ConcurrentReferenceHashMap<>(256, ReferenceType.WEAK); // weak keys: no classloader pinning
private static Optional<AnnotationMetadata> getMetadata(Class<?> type) {
return METADATA_CACHE.computeIfAbsent(type, DefaultProjectionInformation::readMetadata);
}
Effect: the harmless bootstrap read (1) populates the shared cache; when the converter's factory later builds its own DefaultProjectionInformation on the event loop (2), getMetadata is a cache hit — no FileInputStream. The remaining construction work (BeanUtils.getPropertyDescriptors, sorting) is pure reflection/CPU. Zero semantic change (same metadata, same ordering, ProjectionInformation still per-factory). This fixes the reported flat/inherited-projection case for every reactive module at once.
- Eager full introspection warm-up at repository init (spring-data-r2dbc)
The commons cache relies on a bootstrap read existing for the type. That holds for a query's top-level return type and its super-interfaces, but not for nested projection properties (only reached by the introspector at request time) or dynamic projections (Class passed at call time). To cover the full projection graph, warm the converter's introspection on the boot thread when resolving queries — e.g. in R2dbcQueryLookupStrategy.resolveQuery:
ReturnedType rt = queryMethod.getResultProcessor().getReturnedType();
try {
converter.introspectProjection(rt.getReturnedType(), rt.getDomainType()); // deep walk, boot thread
} catch (RuntimeException ex) {
// best-effort: warm-up must never break repository bootstrap
}
Using introspectProjection (not just getProjectionInformation) is deliberate — it walks nested closed projections, so it primes the shared cache for the entire graph.
Is it something we can plan to fix?
While trying to use projections in one of the projects that has R2DBC together with Blockhound we are facing a blockhound.BlockingOperationError: Blocking call! in our test module.
Stack trace
reactor.blockhound.BlockingOperationError: Blocking call! java.io.FileInputStream#readBytes
at java.io.FileInputStream.read
... ASM ClassReader ...
at o.s.core.type.classreading.SimpleMetadataReaderFactory.getMetadataReader(...)
at o.s.data.projection.DefaultProjectionInformation$PropertyDescriptorSource.getMetadata(...)
at o.s.data.projection.DefaultProjectionInformation$PropertyDescriptorSource.(...)
at o.s.data.projection.DefaultProjectionInformation.(...)
at o.s.data.projection.ProxyProjectionFactory.createProjectionInformation(...)
at o.s.data.projection.ProxyProjectionFactory.getProjectionInformation(...)
at o.s.data.projection.EntityProjectionIntrospector.introspect(...)
at o.s.data.relational.core.conversion.MappingRelationalConverter.introspectProjection(...)
at o.s.data.r2dbc.core.R2dbcEntityTemplate.getSelectProjection(...) // (also getRowsFetchSpec)
... on thread reactor-http-nio-*
Root cause
The blocking call is DefaultProjectionInformation.PropertyDescriptorSource.getMetadata(...), which uses MetadataReaderFactory / ASM ClassReader to read the projection interface's .class file. Its only purpose is to recover declared method order (getMethodOrder), since Class.getDeclaredMethods() order is JVM-unspecified. This read is genuine, unavoidable classpath I/O.
ProxyProjectionFactory caches the resulting ProjectionInformation per type via computeIfAbsent, so the read happens once per type, per factory instance. The problem is that there are two different factory instances in play:
The per-instance isolation is intentional (ReturnedType.CacheKey even includes projectionFactoryHashCode), so the bootstrap read cannot prime the converter's cache. Net effect: the class-file read runs twice, and read no. 2 lands on a non-blocking thread.
Proposed fix
Two complementary changes; the first is the durable one.
The blocking sub-step — reading AnnotationMetadata for method ordering — is purely type-derived and factory-independent (only the resulting ProjectionInformation is legitimately per-factory). Cache it below the per-factory layer, in DefaultProjectionInformation.getMetadata, keyed by type:
Effect: the harmless bootstrap read (1) populates the shared cache; when the converter's factory later builds its own DefaultProjectionInformation on the event loop (2), getMetadata is a cache hit — no FileInputStream. The remaining construction work (BeanUtils.getPropertyDescriptors, sorting) is pure reflection/CPU. Zero semantic change (same metadata, same ordering, ProjectionInformation still per-factory). This fixes the reported flat/inherited-projection case for every reactive module at once.
The commons cache relies on a bootstrap read existing for the type. That holds for a query's top-level return type and its super-interfaces, but not for nested projection properties (only reached by the introspector at request time) or dynamic projections (Class passed at call time). To cover the full projection graph, warm the converter's introspection on the boot thread when resolving queries — e.g. in R2dbcQueryLookupStrategy.resolveQuery:
Using introspectProjection (not just getProjectionInformation) is deliberate — it walks nested closed projections, so it primes the shared cache for the entire graph.
Is it something we can plan to fix?