From 41872ad7571c7f3f4efc99b3b613fcd46b581ed7 Mon Sep 17 00:00:00 2001 From: Alexander Dinauer Date: Tue, 10 Mar 2026 16:13:44 +0100 Subject: [PATCH] feat(spring7): Add retrieve() overrides to SentryCacheWrapper Adds support for Spring 6.1+ async cache operations (CompletableFuture and Mono/Flux). Without these overrides, @Cacheable on reactive return types crashes with UnsupportedOperationException. Co-Authored-By: Claude Opus 4.6 --- sentry-spring-7/api/sentry-spring-7.api | 2 + .../spring7/cache/SentryCacheWrapper.java | 72 ++++++++ .../spring7/cache/SentryCacheWrapperTest.kt | 168 ++++++++++++++++++ 3 files changed, 242 insertions(+) diff --git a/sentry-spring-7/api/sentry-spring-7.api b/sentry-spring-7/api/sentry-spring-7.api index b37747cf765..71a8a022bf6 100644 --- a/sentry-spring-7/api/sentry-spring-7.api +++ b/sentry-spring-7/api/sentry-spring-7.api @@ -129,6 +129,8 @@ public final class io/sentry/spring7/cache/SentryCacheWrapper : org/springframew public fun invalidate ()Z public fun put (Ljava/lang/Object;Ljava/lang/Object;)V public fun putIfAbsent (Ljava/lang/Object;Ljava/lang/Object;)Lorg/springframework/cache/Cache$ValueWrapper; + public fun retrieve (Ljava/lang/Object;)Ljava/util/concurrent/CompletableFuture; + public fun retrieve (Ljava/lang/Object;Ljava/util/function/Supplier;)Ljava/util/concurrent/CompletableFuture; } public abstract interface annotation class io/sentry/spring7/checkin/SentryCheckIn : java/lang/annotation/Annotation { diff --git a/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java index 63e4ef67745..181e9f398da 100644 --- a/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java +++ b/sentry-spring-7/src/main/java/io/sentry/spring7/cache/SentryCacheWrapper.java @@ -7,7 +7,9 @@ import io.sentry.SpanStatus; import java.util.Arrays; import java.util.concurrent.Callable; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; import org.jetbrains.annotations.ApiStatus; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -104,6 +106,76 @@ public SentryCacheWrapper(final @NotNull Cache delegate, final @NotNull IScopes } } + @Override + public @Nullable CompletableFuture retrieve(final @NotNull Object key) { + final ISpan span = startSpan("cache.get", key); + if (span == null) { + return delegate.retrieve(key); + } + final CompletableFuture result; + try { + result = delegate.retrieve(key); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + span.finish(); + throw e; + } + if (result == null) { + span.setData(SpanDataConvention.CACHE_HIT_KEY, false); + span.setStatus(SpanStatus.OK); + span.finish(); + return null; + } + return result.whenComplete( + (value, throwable) -> { + if (throwable != null) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(throwable); + } else { + span.setData(SpanDataConvention.CACHE_HIT_KEY, value != null); + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } + + @Override + public CompletableFuture retrieve( + final @NotNull Object key, final @NotNull Supplier> valueLoader) { + final ISpan span = startSpan("cache.get", key); + if (span == null) { + return delegate.retrieve(key, valueLoader); + } + final AtomicBoolean loaderInvoked = new AtomicBoolean(false); + final CompletableFuture result; + try { + result = + delegate.retrieve( + key, + () -> { + loaderInvoked.set(true); + return valueLoader.get(); + }); + } catch (Throwable e) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(e); + span.finish(); + throw e; + } + return result.whenComplete( + (value, throwable) -> { + if (throwable != null) { + span.setStatus(SpanStatus.INTERNAL_ERROR); + span.setThrowable(throwable); + } else { + span.setData(SpanDataConvention.CACHE_HIT_KEY, !loaderInvoked.get()); + span.setStatus(SpanStatus.OK); + } + span.finish(); + }); + } + @Override public void put(final @NotNull Object key, final @Nullable Object value) { final ISpan span = startSpan("cache.put", key); diff --git a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheWrapperTest.kt b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheWrapperTest.kt index dbd3d50c2af..9e20fe21554 100644 --- a/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheWrapperTest.kt +++ b/sentry-spring-7/src/test/kotlin/io/sentry/spring7/cache/SentryCacheWrapperTest.kt @@ -7,6 +7,8 @@ import io.sentry.SpanDataConvention import io.sentry.SpanStatus import io.sentry.TransactionContext import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture +import java.util.function.Supplier import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals @@ -137,6 +139,172 @@ class SentryCacheWrapperTest { assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) } + // -- retrieve(Object key) -- + + @Test + fun `retrieve creates span with cache hit true when future resolves with value`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve("myKey")).thenReturn(CompletableFuture.completedFuture("value")) + + val result = wrapper.retrieve("myKey") + + assertEquals("value", result!!.get()) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals("cache.get", span.operation) + assertEquals("myKey", span.description) + assertEquals(SpanStatus.OK, span.status) + assertEquals(true, span.getData(SpanDataConvention.CACHE_HIT_KEY)) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve creates span with cache hit false when future resolves with null`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve("myKey")).thenReturn(CompletableFuture.completedFuture(null)) + + val result = wrapper.retrieve("myKey") + + assertNull(result!!.get()) + assertEquals(1, tx.spans.size) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + assertTrue(tx.spans.first().isFinished) + } + + @Test + fun `retrieve creates span with cache hit false when delegate returns null`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve("myKey")).thenReturn(null) + + val result = wrapper.retrieve("myKey") + + assertNull(result) + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(false, span.getData(SpanDataConvention.CACHE_HIT_KEY)) + assertEquals(SpanStatus.OK, span.status) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve sets error status when future completes exceptionally`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("async cache error") + whenever(delegate.retrieve("myKey")) + .thenReturn(CompletableFuture().also { it.completeExceptionally(exception) }) + + val result = wrapper.retrieve("myKey") + + assertFailsWith { result!!.get() } + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve sets error status when delegate throws synchronously`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("sync error") + whenever(delegate.retrieve("myKey")).thenThrow(exception) + + assertFailsWith { wrapper.retrieve("myKey") } + + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve does not create span when tracing is disabled`() { + options.isEnableCacheTracing = false + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve("myKey")).thenReturn(CompletableFuture.completedFuture("value")) + + wrapper.retrieve("myKey") + + verify(delegate).retrieve("myKey") + assertEquals(0, tx.spans.size) + } + + // -- retrieve(Object key, Supplier>) -- + + @Test + fun `retrieve with loader creates span with cache hit true when loader not invoked`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + // Simulate cache hit: delegate returns value without invoking the loader + whenever(delegate.retrieve(eq("myKey"), any>>())) + .thenReturn(CompletableFuture.completedFuture("cached")) + + val result = wrapper.retrieve("myKey") { CompletableFuture.completedFuture("loaded") } + + assertEquals("cached", result.get()) + assertEquals(1, tx.spans.size) + assertEquals(true, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + assertTrue(tx.spans.first().isFinished) + } + + @Test + fun `retrieve with loader creates span with cache hit false when loader invoked`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + // Simulate cache miss: delegate invokes the loader supplier + whenever(delegate.retrieve(eq("myKey"), any>>())) + .thenAnswer { invocation -> + val loader = invocation.getArgument>>(1) + loader.get() + } + + val result = wrapper.retrieve("myKey") { CompletableFuture.completedFuture("loaded") } + + assertEquals("loaded", result.get()) + assertEquals(1, tx.spans.size) + assertEquals(false, tx.spans.first().getData(SpanDataConvention.CACHE_HIT_KEY)) + assertTrue(tx.spans.first().isFinished) + } + + @Test + fun `retrieve with loader sets error status when future completes exceptionally`() { + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + val exception = RuntimeException("async loader error") + whenever(delegate.retrieve(eq("myKey"), any>>())) + .thenReturn(CompletableFuture().also { it.completeExceptionally(exception) }) + + val result = wrapper.retrieve("myKey") { CompletableFuture.completedFuture("loaded") } + + assertFailsWith { result.get() } + assertEquals(1, tx.spans.size) + val span = tx.spans.first() + assertEquals(SpanStatus.INTERNAL_ERROR, span.status) + assertEquals(exception, span.throwable) + assertTrue(span.isFinished) + } + + @Test + fun `retrieve with loader does not create span when tracing is disabled`() { + options.isEnableCacheTracing = false + val tx = createTransaction() + val wrapper = SentryCacheWrapper(delegate, scopes) + whenever(delegate.retrieve(eq("myKey"), any>>())) + .thenReturn(CompletableFuture.completedFuture("cached")) + + wrapper.retrieve("myKey") { CompletableFuture.completedFuture("loaded") } + + verify(delegate).retrieve(eq("myKey"), any>>()) + assertEquals(0, tx.spans.size) + } + // -- put -- @Test