Skip to content

feat(server): server-side response cache for proxied actuator endpoints#5266

Open
mmorel-35 wants to merge 1 commit intocodecentric:masterfrom
mmorel-35:actuator-endpoints-caching
Open

feat(server): server-side response cache for proxied actuator endpoints#5266
mmorel-35 wants to merge 1 commit intocodecentric:masterfrom
mmorel-35:actuator-endpoints-caching

Conversation

@mmorel-35
Copy link
Copy Markdown

Expensive, mostly-static actuator endpoints (mappings, beans, configprops, etc.) are re-fetched from every monitored app on every SBA UI request. In clustered deployments each SBA node independently re-fetches the same data. This adds a server-side response cache for proxied actuator GET requests, with an optional Hazelcast-backed shared store for multi-node deployments.

Cache core (web/cache/)

  • CacheEntry — immutable serializable snapshot of a proxied response (status code, defensively-copied headers, body bytes, timestamp); sensitive headers are stripped in InstanceWebProxy before the entry is created. Exposes a zero-copy getBodyRef() for internal use so cache hits avoid a per-hit defensive clone.
  • ActuatorResponseCache — interface: get / put / invalidateAllForInstance / invalidateEndpointForInstance / shouldCache / getMaxPayloadSize
  • CacheKeyBuilder — hashes the instanceId with SHA-256 before concatenating the endpoint path, producing an unambiguous key that is safe even when instanceId values contain : (e.g. CloudFoundry applicationId:instanceId format). Both InMemoryActuatorResponseCache and HazelcastActuatorResponseCache delegate all key construction and prefix matching to this helper.
  • InMemoryActuatorResponseCacheConcurrentHashMap with lazy TTL eviction (default, single-node)
  • HazelcastActuatorResponseCacheIMap with native per-entry TTL; invalidation uses server-side Predicates + IMap.removeAll to avoid full in-JVM key scans; shared across all cluster nodes when Hazelcast is present
  • CacheInvalidationTrigger — event-driven invalidation on InstanceDeregisteredEvent, InstanceRegistrationUpdatedEvent, InstanceEndpointsDetectedEvent

Configuration (AdminServerProperties.EndpointCacheProperties)

New spring.boot.admin.endpoint-cache.* properties:

spring.boot.admin.endpoint-cache:
  enabled: true
  default-ttl: 5m
  ttl:
    mappings: 10m   # per-endpoint override
  endpoints:        # cached endpoint ids (safe GET only)
    - mappings
    - configprops
    - beans
    - conditions
    - sbom
    - startup
  max-payload-size: 10485760  # skip caching oversized responses

Also adds spring.boot.admin.hazelcast.response-cache to name the Hazelcast IMap (default: spring-boot-admin-actuator-response-cache), consistent with the existing event-store and sent-notifications Hazelcast properties.

Proxy integration (InstanceWebProxy)

Cache logic lives entirely inside InstanceWebProxy as an optional internal collaborator. When an ActuatorResponseCache (and companion HttpHeaderFilter) is supplied via the second constructor InstanceWebProxy(InstanceWebClient, ActuatorResponseCache, HttpHeaderFilter) (both must be non-null; constructor enforces this), the proxy transparently:

  1. Cache hit — returns the stored entry directly without any upstream call; uses zero-copy body access to avoid reallocating the payload on every hit
  2. Cache miss — forwards as before; buffers and stores 2xx responses that fit within max-payload-size
  3. Post-mutation invalidation — after a successful POST/PUT/PATCH/DELETE to a configured endpoint path, evicts that endpoint's cache entries so the next GET returns fresh data
  4. Fan-out calls (Flux<Instance>) — always forwarded upstream, never cached

All blocking cache operations (get, put, invalidateEndpointForInstance) are offloaded to Schedulers.boundedElastic() so they never stall Netty event-loop threads. Cache read failures are treated as a cache miss (logged as a warning with full stack trace, request continues upstream) so transient Hazelcast network/serialization errors never fail a proxied request. Cache write failures (put, invalidate) are also handled with .onErrorResume(...) — a warning is logged but the response still reaches the client.

Only responses with a known Content-Length that fits within max-payload-size are cached. Responses where Content-Length is absent (chunked/streamed) are forwarded as-is without buffering, avoiding any unbounded memory allocation. Responses with a known Content-Length exceeding max-payload-size are also forwarded directly.

The InstanceId used as the cache key is derived from the already-resolved Instance internally — no change to the public forward(Mono<Instance>, ForwardRequest, Function) API. Non-GET methods on unconfigured endpoint ids, and error responses bypass the cache entirely.

Wiring

AdminServerWebConfiguration constructs HttpHeaderFilter and InstanceWebProxy (with the optional cache collaborator) and injects them into both proxy controllers. The controller constructors are (String adminContextPath, HttpHeaderFilter, InstanceRegistry, InstanceWebProxy) — they receive all collaborators and build nothing themselves.

Both reactive/InstancesProxyController and servlet/InstancesProxyController are pure routing/response-writing layers with no cache logic and no knowledge of how InstanceWebProxy is instantiated.

  • AdminServerWebConfiguration — registers InMemoryActuatorResponseCache (@ConditionalOnMissingBean + @ConditionalOnProperty) and CacheInvalidationTrigger beans; builds HttpHeaderFilter + InstanceWebProxy and injects them into both proxy controllers
  • AdminServerHazelcastAutoConfiguration — registers HazelcastActuatorResponseCache (@ConditionalOnMissingBean + @ConditionalOnProperty) when a HazelcastInstance is present, taking precedence over the in-memory default

Cache beans are not created at all when spring.boot.admin.endpoint-cache.enabled=false, following standard Spring Boot @ConditionalOnProperty patterns.

Tests

  • InMemoryActuatorResponseCacheTest — key isolation, TTL expiry (using Awaitility, no Thread.sleep), per-endpoint TTL overrides, instance invalidation, single-endpoint invalidation (exact match, sub-path, and query-string variants), disabled mode, shouldCache guards (13 tests); all getBytes()/new String(bytes) calls use explicit StandardCharsets.UTF_8
  • HazelcastActuatorResponseCacheTest — embedded Hazelcast validates key matching (exact, sub-path, query-string variants), invalidateAllForInstance, invalidateEndpointForInstance, shouldCache guards, and per-endpoint TTL behavior
  • CacheInvalidationTriggerTest — event-driven invalidation; non-invalidating events leave cache intact; uses TestPublisher with subscription-await to eliminate FAIL_ZERO_SUBSCRIBER race; @AfterEach stops the trigger to prevent thread/subscription leaks across tests (4 tests)
  • AbstractInstancesProxyControllerIntegrationTest — two new tests covering cache hit (WireMock receives exactly 1 upstream call across 2 proxy requests) and non-default endpoint bypass; WireMock Content-Length stub header computed from UTF-8 bytes; run in both reactive and servlet variants

Signed-off-by: Matthieu MOREL <matthieu.morel35@gmail.com>
@mmorel-35 mmorel-35 requested a review from a team as a code owner April 15, 2026 06:06
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant