From af379811c9c8913796702f2eb284686eb378aff5 Mon Sep 17 00:00:00 2001 From: Sotatek-HenryDo Date: Thu, 9 Jul 2026 15:32:42 +0700 Subject: [PATCH 1/2] fix: cache stampede and remove verifySyncStatus from block endpoints --- .../api/block/controller/BlockApiImpl.java | 2 - .../network/service/SyncStatusService.java | 7 +- ...SyncStatusServiceCacheConcurrencyTest.java | 114 ++++++++++++++++++ 3 files changed, 120 insertions(+), 3 deletions(-) create mode 100644 api/src/test/java/org/cardanofoundation/rosetta/api/network/service/SyncStatusServiceCacheConcurrencyTest.java diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImpl.java b/api/src/main/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImpl.java index fed62cb70b..c1adc6ba27 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImpl.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImpl.java @@ -40,7 +40,6 @@ public ResponseEntity block(@RequestBody BlockRequest blockReques throw ExceptionFactory.notSupportedInOfflineMode(); } networkService.verifyNetworkRequest(blockRequest.getNetworkIdentifier()); - networkService.verifySyncStatus(); if (blockRequest.getBlockIdentifier().getIndex() != null && blockRequest.getBlockIdentifier().getIndex() < 0) { throw ExceptionFactory.invalidBlockIdentifier(blockRequest.getBlockIdentifier().getIndex()); @@ -66,7 +65,6 @@ public ResponseEntity blockTransaction( throw ExceptionFactory.notSupportedInOfflineMode(); } networkService.verifyNetworkRequest(blockReq.getNetworkIdentifier()); - networkService.verifySyncStatus(); if (blockReq.getBlockIdentifier().getIndex() != null && blockReq.getBlockIdentifier().getIndex() < 0) { throw ExceptionFactory.invalidBlockIdentifier(blockReq.getBlockIdentifier().getIndex()); } diff --git a/api/src/main/java/org/cardanofoundation/rosetta/api/network/service/SyncStatusService.java b/api/src/main/java/org/cardanofoundation/rosetta/api/network/service/SyncStatusService.java index ff4e64b9c6..598cd3cc89 100644 --- a/api/src/main/java/org/cardanofoundation/rosetta/api/network/service/SyncStatusService.java +++ b/api/src/main/java/org/cardanofoundation/rosetta/api/network/service/SyncStatusService.java @@ -148,10 +148,15 @@ public Optional calculateSyncStatus(BlockIdentifierExtended latestBl /** * Gets the sync status of the indexer. This is cached for 5 seconds to prevent * redundant database checks on every request. + *

+ * {@code sync = true} ensures that when multiple concurrent requests miss the cache + * at the same time (e.g. right after eviction, or under load-test level concurrency), + * only one thread computes the value and queries the DB while the others block and then + * read the cached result, instead of every thread independently querying the DB. * * @return an Optional containing the SyncStatus of the indexer if available, empty otherwise */ - @Cacheable(value = "syncStatusCache", unless = "#result == null") + @Cacheable(value = "syncStatusCache", sync = true) public Optional getSyncStatus() { log.info("[SyncStatusService] Cache miss - querying sync status from DB"); BlockIdentifierExtended latestBlock = ledgerBlockService.findLatestBlockIdentifier(); diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/network/service/SyncStatusServiceCacheConcurrencyTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/network/service/SyncStatusServiceCacheConcurrencyTest.java new file mode 100644 index 0000000000..fd2e01e27d --- /dev/null +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/network/service/SyncStatusServiceCacheConcurrencyTest.java @@ -0,0 +1,114 @@ +package org.cardanofoundation.rosetta.api.network.service; + +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.stream.IntStream; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import org.cardanofoundation.rosetta.api.block.model.domain.BlockIdentifierExtended; +import org.cardanofoundation.rosetta.api.block.service.LedgerBlockService; +import org.cardanofoundation.rosetta.common.time.OfflineSlotService; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Regression test for a cache-stampede bug: under concurrent requests hitting + * {@link SyncStatusService#getSyncStatus()} at the same time (e.g. right after the 5s cache + * eviction, or under load-test level concurrency), every thread independently missed the cache + * and queried the DB, instead of exactly one thread computing the value while the rest waited + * for and reused the cached result. + *

+ * Fixed by adding {@code sync = true} to the {@code @Cacheable} annotation. + */ +@SpringBootTest +@EnableCaching +class SyncStatusServiceCacheConcurrencyTest { + + private static final String SYNC_STATUS_CACHE = "syncStatusCache"; + private static final int CONCURRENT_REQUESTS = 50; + + @MockitoBean + private LedgerBlockService ledgerBlockService; + + @MockitoBean + private OfflineSlotService offlineSlotService; + + @Autowired + private SyncStatusService syncStatusService; + + @Autowired + private CacheManager cacheManager; + + @BeforeEach + void setUp() { + Cache cache = cacheManager.getCache(SYNC_STATUS_CACHE); + if (cache != null) { + cache.clear(); + } + } + + @Test + void concurrentCacheMissesShouldOnlyQueryDbOnce() throws Exception { + long currentSlot = 1000L; + long latestBlockSlot = 990L; + BlockIdentifierExtended latestBlock = BlockIdentifierExtended.builder() + .slot(latestBlockSlot) + .build(); + + when(ledgerBlockService.findLatestBlockIdentifier()).thenReturn(latestBlock); + when(offlineSlotService.getCurrentSlotBasedOnTime()).thenReturn(Optional.of(currentSlot)); + + // A latch ensures all threads are alive and waiting before any of them calls + // getSyncStatus(), maximising the probability of a simultaneous cache miss. + CountDownLatch startGate = new CountDownLatch(1); + ExecutorService executor = Executors.newFixedThreadPool(CONCURRENT_REQUESTS); + try { + List>> futures = + IntStream.range(0, CONCURRENT_REQUESTS) + .mapToObj(i -> executor.submit(() -> { + startGate.await(); + return syncStatusService.getSyncStatus(); + })) + .toList(); + + startGate.countDown(); // release all threads simultaneously + + for (Future> future : futures) { + // Timeout guards against a deadlock hanging the CI pipeline indefinitely. + assertThat(future.get(5, TimeUnit.SECONDS)) + .isPresent() + .hasValueSatisfying(status -> { + // Assert content consistency: every thread must see the same + // correct result, not a stale or partially-computed value. + assertThat(status.getSynced()).isTrue(); + assertThat(status.getCurrentIndex()).isEqualTo(latestBlockSlot); + assertThat(status.getTargetIndex()).isEqualTo(currentSlot); + }); + } + } finally { + executor.shutdown(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + + // With sync = true, only one of the CONCURRENT_REQUESTS threads should have missed + // the cache and queried the DB; the rest must have waited and reused that result. + verify(ledgerBlockService, times(1)).findLatestBlockIdentifier(); + } +} From 15c289cb007515d19fb1e9c45ab9f03bdd51d035 Mon Sep 17 00:00:00 2001 From: Sotatek-HenryDo Date: Thu, 9 Jul 2026 17:22:00 +0700 Subject: [PATCH 2/2] test: remove obsolete indexer readiness tests for block endpoints --- .../api/block/controller/BlockApiImplTest.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/api/src/test/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImplTest.java b/api/src/test/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImplTest.java index 5d06913fcc..383e298f85 100644 --- a/api/src/test/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImplTest.java +++ b/api/src/test/java/org/cardanofoundation/rosetta/api/block/controller/BlockApiImplTest.java @@ -68,19 +68,7 @@ void blockInvalidIndex() { assertThrows(ExceptionFactory.invalidBlockIdentifier(-1L).getClass(), () -> blockApi.block(blockRequest)); } - @Test - void blockIndexerNotReadyTest() { - BlockRequest blockRequest = newBlockRequest(); - Mockito.doThrow(ExceptionFactory.indexerNotReady()).when(networkService).verifySyncStatus(); - assertThrows(ExceptionFactory.indexerNotReady().getClass(), () -> blockApi.block(blockRequest)); - } - @Test - void blockTransactionIndexerNotReadyTest() { - BlockTransactionRequest blockTransactionRequest = newBlockTransactionRequest(); - Mockito.doThrow(ExceptionFactory.indexerNotReady()).when(networkService).verifySyncStatus(); - assertThrows(ExceptionFactory.indexerNotReady().getClass(), () -> blockApi.blockTransaction(blockTransactionRequest)); - } @Test void blockOfflineModeTest() throws NoSuchFieldException, IllegalAccessException {