spring-cloud / spring-cloud/spring-cloud-gateway

CachingRouteLocator.updateCache does not work as intended (synchronized method performs async subscribe)

Open
#3,956 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

waiting-for-triage
Dominant language
Java
Stars
4.9k
Forks
3.5k
Avg merge
20h 57m
Merged PRs (30d)
8

Description

Describe the bug

Spring Cloud Version: 4.3.0
Module: spring-cloud-gateway-server-webflux

CachingRouteLocator.updateCache is declared synchronized, but the cache refresh is triggered via an asynchronous subscribe() on a Flux<Route> (from onApplicationEvent), so the actual cache population completes after the synchronized method returns. This means the synchronized boundary does not protect the real update.

Under concurrent or back-to-back refreshes, an older (slower) refresh can finish after a newer (faster) one and persist a stale/empty cache, causing the cache to regress. For example, when a new route appears, a slow refresh that observed “no routes yet” may complete after a fast refresh that already saw the new route and then overwrite the cache with an older/empty state.

Note: Finishing later doesn’t guarantee newer data. When refreshes overlap, you may need to run updateCache again or ensure only the newest snapshot is kept (e.g., sequence/timestamp).

Sample

import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.cloud.gateway.event.RefreshRoutesEvent;
import org.springframework.cloud.gateway.event.RefreshRoutesResultEvent;
import org.springframework.cloud.gateway.route.CachingRouteLocator;
import org.springframework.cloud.gateway.route.Route;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.context.ApplicationEventPublisher;
import reactor.core.publisher.Flux;

import java.time.Duration;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;

import static org.assertj.core.api.Assertions.assertThat;

public class CachingRouteLocatorEmptyCacheRaceTest {
    @DisplayName("latest refresh should win when a new route appears")
    @Test
    void shouldUpdateCache() throws Exception {
        Route newRoute = Route.async()
                .id("new")
                .uri("http://localhost/new")
                .order(0)
                .predicate(exchange -> true)
                .build();

        RouteLocator delegate = new RouteLocator() {
            int calls = 0;

            @Override
            public Flux<Route> getRoutes() {
                int n = calls++;
                if (n == 0) {
                    return Flux.<Route>empty().delaySubscription(Duration.ofMillis(600));
                }
                return Flux.just(newRoute).delaySubscription(Duration.ofMillis(40));
            }
        };

        CachingRouteLocator locator = new CachingRouteLocator(delegate);

        CountDownLatch cdl = new CountDownLatch(2);
        ApplicationEventPublisher publisher = event -> {
            if (event instanceof RefreshRoutesResultEvent) {
                cdl.countDown();
            }
        };
        locator.setApplicationEventPublisher(publisher);

        locator.onApplicationEvent(new RefreshRoutesEvent(this));
        locator.onApplicationEvent(new RefreshRoutesEvent(this));

        boolean finished = cdl.await(3, TimeUnit.SECONDS);
        assertThat(finished)
                .withFailMessage("Refresh did not complete within the timeout; adjust delays or timeout to proceed.")
                .isTrue();

        List<Route> finalRoutes = locator.getRoutes().collectList().block(Duration.ofSeconds(2));
        assertThat(finalRoutes)
                .withFailMessage("Cache should reflect the latest refresh result (non-empty expected when a new route appears). Actual: %s", finalRoutes)
                .isNotEmpty();
    }
}

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reading CachingRouteLocator, especially onApplicationEvent and updateCache, in the spring-cloud-gateway-server-webflux module. Run the supplied concurrent refresh regression test, then verify that overlapping refreshes cannot leave the locator with an older or empty cache and that both refresh result events still complete.

Written by the indexing model from the issue text.

Assessment

Tech stack
java
Domain
api, backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Stale
Clarity
Mostly clear
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.