Skip to content

perf(core): cut ORM/REST serialization overhead for entity template and graph endpoints - #136

Open
mmornati wants to merge 1 commit into
mainfrom
perf-p1-cut-orm-serialization-overhead
Open

perf(core): cut ORM/REST serialization overhead for entity template and graph endpoints#136
mmornati wants to merge 1 commit into
mainfrom
perf-p1-cut-orm-serialization-overhead

Conversation

@mmornati

@mmornati mmornati commented Aug 18, 2026

Copy link
Copy Markdown

Why

Closes #131.

Profiling flagged that entity-template lookups and the entity-graph endpoint were spending far more time in ORM hydration / mapping than in the actual SQL, which was identified as the root cause of the concurrency ceiling described in #131 (10–30x the real SQL cost).

Two concrete hotspots were found and fixed:

  1. EntityTemplateService.getEntityTemplateByIdentifier had no caching. Templates are read-heavy and rarely change, but every call re-fetched and re-mapped the full entity/attribute graph from the DB.
  2. EntityGraphService.getEntityGraph hydrated the full root Entity aggregate (all attributes, relations, audit fields, etc.) just to read 4 identity fields (id, templateIdentifier, identifier, name) needed to build the graph response.

What changed

  • Added spring-boot-starter-cache + caffeine, and a new CacheConfiguration (infrastructure layer) defining the ENTITY_TEMPLATES_CACHE.
  • Annotated EntityTemplateService with @Cacheable/@CacheEvict/@Caching so template reads are served from cache, with eviction wired into create/update/delete so callers never see stale data.
  • Added a new lightweight EntityIdentity record plus findIdentityByTemplateIdentifierAndIdentifier on EntityRepositoryPort / JpaEntityRepository (JPQL constructor expression selecting only identity columns) / PostgresEntityAdapter.
  • Updated EntityGraphService.getEntityGraph to fetch the graph root via the new lightweight identity query instead of the full Entity hydration path.
  • Updated EntityGraphServiceTest accordingly (24 stubs migrated to the new port method); no other tests required changes — everything else mocks at a layer this doesn't touch.

No API/contract changes — this is a pure internal performance optimization. Scope intentionally kept to these two low-risk wins only (does not include the separate work from PR #135; this branch was built directly off main).

Test coverage

  • Full non-integration suite: 811/811 passing.
  • 6 pre-existing @SpringBootTest integration classes fail locally for an unrelated, pre-existing environment reason (Testcontainers/Ryuk cannot access the Docker socket under Rancher Desktop on this machine — reproducible on main too, unaffected by this change).

Performance validation

Built two apps against the same already-seeded local Postgres (3,972 entities / 3,632 relations): one from clean origin/main (baseline), one from this branch (template cache + lightweight root fetch). Ran identical k6 scripts against each, back-to-back.

Get-by-id (GET /entities/{template}/{id}), VU=10, 30s

depth baseline avg / p95 perf-fix avg / p95 Δ avg throughput
1 21.9ms / 32.7ms 18.3ms / 28.4ms -16% 456 → 546 req/s (+20%)
3 46.0ms / 75.4ms 43.4ms / 70.3ms -6% 217 → 230 req/s (+6%)

Graph traversal (/graph?depth=N), VU=10, 15-30s

depth baseline avg perf-fix avg Δ
1 20.2ms 19.4ms -4%
2 35.1ms 34.0ms -3%
3 48.2ms 44.5ms (repeat run) -8%
4-6 75.7 / 103.4 / 120.7ms 64.5 / 75.5 / 83.4ms -15% to -31%

Zero request failures in any run (0% http_req_failed).

Caveat: shared, non-dedicated test machine

System load average swung from ~6 to ~15 during testing (confirmed by re-running the same baseline scenario twice and getting 269 req/s vs. 111 req/s purely from ambient load). So:

  • The depth-1/2/3 numbers above were run close together under moderate, comparable load — these are the most trustworthy signal: consistent, real, moderate wins from removing root-entity over-hydration + template caching.
  • The depth-4-6 "large" gains and the initial 50-VU concurrent-load numbers were noisier and shouldn't be over-interpreted precisely, though no regression appeared anywhere. A later concurrent-load re-run at matched system load (~4.2-5.7 load avg on both sides) confirmed -9% avg latency / +9% throughput, consistent with the shallower-depth results.

Bottom line: real, positive, low-risk improvement, most pronounced on the common case (shallow get-by-id) — no regressions, no test failures.

Additional check: real OAuth2/JWT validation overhead

Since all the above used the app's local mock-security bypass, we also validated request-level auth overhead by running the same suite against this branch with real OAuth2 resource-server JWT validation enabled (production JWKS, RS256), to make sure it doesn't mask or amplify the improvement in production-like conditions:

Scenario Mock security avg Real OAuth2 avg Delta
get-by-id, depth 1 18.27ms 20.49ms +12%
get-by-id, depth 3 43.36ms 45.40ms +5%
graph depth 1-6 -4% to +9% (noise, no consistent direction)
concurrent mixed load (50 VU, matched system load) 57.71ms / 306.5 req/s 58.27ms / 303.6 req/s ~0% (statistically identical)

JWT signature verification adds a small, consistent ~2ms on cheap single-request calls, but is within noise once request cost grows or under concurrency — confirms this PR's gains hold up with real auth enabled, and that auth overhead isn't a factor for this optimization's scope.


🤖 Generated with the help of GitHub Copilot CLI.

…nd graph endpoints

- Cache entity templates (Caffeine) in EntityTemplateService to avoid
  redundant DB round-trips + full-object rehydration/mapping on every
  request; cache is evicted on template create/update/delete.
- Add a lightweight EntityIdentity projection (JPQL constructor
  expression) and use it in EntityGraphService.getEntityGraph for the
  graph root, instead of hydrating the full Entity aggregate when only
  identity fields (id, templateIdentifier, identifier, name) are
  needed to build the graph response.

Closes #131

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@sonarqubecloud

Copy link
Copy Markdown

@github-code-quality

Copy link
Copy Markdown

Code Coverage Overview

Languages: Java

Java / code-coverage/jacoco

The overall coverage in commit cc9d144 in the perf-p1-cut-orm-seri... branch remains at 90%, unchanged from commit 8de7fd7 in the main branch.

Show a code coverage summary of the most impacted files.
File main 8de7fd7 perf-p1-cut-orm-seri... cc9d144 +/-
com/decathlon/i...ityAdapter.java 81% 81% 0%
com/decathlon/i...figuration.java 0% 100% +100%
com/decathlon/i...tyIdentity.java 0% 100% +100%

@mmornati mmornati self-assigned this Aug 18, 2026
@brandPittCode

brandPittCode commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator

About the EntityTemplateService.getEntityTemplateByIdentifier caching:

Caffeine is an in-memory, per-instance cache, which means it cannot guarantee consistency across a horizontally scaled, stateless deployment like we intend for IDP-Core.

For example:

Instance A caches template-a.

An admin updates template-a through Instance B.

Instance B evicts its local entry.

Instance A continues serving the stale template-a until its TTL expires or the pod restarts.

To support caching reliably, we would need to evaluate a distributed cache (e.g., Redis) or a messaging mechanism (e.g., Pub/Sub) to broadcast invalidation events. We also need to ensure the architecture remains compatible with dynamic platforms like Kubernetes and serverless managed services like Cloud Run.

@mmornati

mmornati commented Sep 2, 2026

Copy link
Copy Markdown
Author

About the EntityTemplateService.getEntityTemplateByIdentifier caching:

Caffeine is an in-memory, per-instance cache, which means it cannot guarantee consistency across a horizontally scaled, stateless deployment like we intend for IDP-Core.

For example:

Instance A caches template-a.

An admin updates template-a through Instance B.

Instance B evicts its local entry.

Instance A continues serving the stale template-a until its TTL expires or the pod restarts.

To support caching reliably, we would need to evaluate a distributed cache (e.g., Redis) or a messaging mechanism (e.g., Pub/Sub) to broadcast invalidation events. We also need to ensure the architecture remains compatible with dynamic platforms like Kubernetes and serverless managed services like Cloud Run.

Agree. For the moment it is a single instance in the test I made (both for the KG and for Identity Access). As it is only a cache, in a Hexagonal way, we can propose both drivers (or even more).
I think we can at least propose a single instance app (not all the users will need a big infrastructure) and caffeine is good; and then a second service that can be used to synchronize several instance. Redis you are proposing is good to start.
Once this part is added it will be easy to add, on demand, several other caching systems.

@RVANDO12

RVANDO12 commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Proposal of ADR to share with team

  • Status: Proposed

  • Deciders:

  • maintainers team: Marco MORNATI

  • Consulted: Architecture & Core Platform Teams

  • Informed: Project Contributors

  • Date: 2026-09-07

Technical Story: Resolves concurrency ceiling and latency issues on read-heavy template endpoints (Issue #131 / PR #135).

Context and Problem Statement

Profiling of IDP v2 revealed that the REST API latency is 10–30x higher than its raw SQL cost, creating a direct bottleneck for concurrency and throughput. For example, getting an entity by ID takes ~17–20ms at the API level compared to ~0.17ms for the raw SQL.

The root cause was identified as heavy ORM (Hibernate) hydration and mapping overhead.

To mitigate this, a PR introduced caching on EntityTemplateService.getEntityTemplateByIdentifier using Caffeine, an in-memory, per-instance cache. While this successfully reduced average latency by 16% and improved throughput by 20% in single-instance benchmarks, Caffeine's per-instance nature introduces critical data consistency issues in a horizontally scaled, stateless deployment ( Kubernetes, Cloud Run ).
If Instance A caches a template, and an admin updates it via Instance B, Instance A will continue serving stale data until its TTL expires or the pod restarts.

We need to define a robust caching architecture that accommodates lightweight single-instance setups as well as highly available multi-instance deployments without compromising data consistency.

Decision Drivers

  • Performance & Latency: The primary goal is to bypass heavy ORM hydration for read-heavy, rarely changing data.
  • Data Consistency: Ensuring all instances serve the most up-to-date templates, avoiding stale data in horizontally scaled environments.
  • Complexity of implementation: Keeping the setup maintainable and easy to deploy for different user scales.
  • FinOps / Infrastructure overhead: Managing the cost and operational burden of requiring external caching components.
  • Configurability: Providing a seamless way to enable/disable or swap cache engines based on the deployment target.

Considered Options

  1. Use Caffeine Cache (In-Memory)
  2. Use Redis (Distributed Cache)
  3. Implement a Pluggable Cache Architecture via Spring Boot properties

Decision Outcome

Chosen option: option 3, "Implement a Pluggable Cache Architecture via Spring Boot properties," because it allows us to propose Redis as the standard distributed cache for both mono and multi-instance deployments to guarantee consistency, while keeping the cache engine strictly configurable (toggleable between redis, caffeine, or none) directly via standard Spring Boot 4.1.0 application.yml parameters.

Positive Consequences

  • Absolute Consistency: Using Redis in a multi-instance setup ensures that when a template is updated or evicted, the invalidation is global. No user will receive stale data regardless of which pod serves their request.
  • Scalability: The architecture natively supports Kubernetes and serverless platforms like Cloud Run.
  • Flexibility: Small teams or local developers who do not want the infrastructure overhead of Redis can explicitly fallback to Caffeine or disable the cache entirely using standard configuration flags.

Negative Consequences

  • Increased Infrastructure Complexity (Redis): Adopting Redis requires provisioning, monitoring, and maintaining a Redis cluster or managed service (e.g., Memorystore/ElastiCache).
  • Network Latency: Unlike in-memory Caffeine (which has ~0ms latency), fetching from Redis introduces network I/O and serialization/deserialization overhead.
  • Security & Auth Overheads: Setting up Redis securely (TLS, auth) adds operational steps for deployment teams.

Pros and Cons of the Options

1. Use Caffeine Cache (In-Memory)

Caffeine is a high-performance, near-optimal caching library for Java.

  • Good, because it requires zero external infrastructure, making local development and lightweight single-instance deployments extremely simple. (Complexity of implementation, FinOps)
  • Good, because it offers the fastest possible retrieval times since data remains in the local JVM memory without network trips. (Performance)
  • Bad, because in a multi-instance deployment, it cannot guarantee cache consistency. Cache invalidation on one node does not propagate to others, leading to split-brain scenarios where users see stale entity templates. (Data Consistency)
  • Bad, because memory usage scales linearly with the number of instances, as every pod must maintain its own duplicated copy of the cache. (FinOps)

2. Use Redis (Distributed Cache)

Redis is an open-source, in-memory data structure store, used as a distributed caching engine.

  • Good, because it provides a single source of truth for both mono and multi-instance deployments. Cache evictions are immediate and system-wide. (Data Consistency)
  • Good, because it offloads memory consumption from the application pods to a dedicated optimized storage layer, keeping IDP-Core's memory footprint smaller and more predictable. (Scalability)
  • Good, because it survives application restarts. If an IDP-Core pod crashes and restarts, the cache is still warm, preventing sudden latency spikes (cache stampedes) on the database. (Performance)
  • Bad, because of the difficulty and impact of implementation:
  • For mono-instance, requiring Redis is often seen as overkill. It forces users to spin up a secondary container/service just to run the application, damaging the "plug and play" developer experience.
  • For multi-instance, it requires setting up High Availability (HA) Redis, handling connection pooling, managing TLS/Security, and monitoring network latency.
  • It introduces serialization/deserialization costs (e.g., mapping entities to JSON/byte arrays back and forth), which is slightly slower than referencing local JVM objects. (Complexity of implementation)

3. Implement a Pluggable Cache Architecture via Spring Boot properties

Leveraging Spring Boot's caching abstraction (@EnableCaching, @Cacheable) allows the underlying provider to be injected at runtime based on application properties.

  • Good, because it solves the mono vs multi-instance dilemma by letting the infrastructure team decide the best engine per environment. (User experience)
  • Good, because Spring Boot 4.1.0 natively supports switching cache types via the spring.cache.type property without changing a single line of business code. (Maintainability)
  • Bad, because we must ensure all cached objects (EntityTemplate, etc.) implement Serializable to support Redis, which imposes a minor constraint on the domain models. (Complexity of implementation)

More Information

To support this decision, the cache must be strictly configurable via standard Spring Boot properties. The activation, deactivation, and engine selection will be done via application.yml or environment variables:

Example configuration in application.yml:

spring:
  cache:
    # Can be set to 'redis' (recommended for production/multi-instance), 
    # 'caffeine' (for local/mono-instance), or 'none' (to disable caching entirely).
    type: ${CACHE_TYPE:redis} 
    cache-names: ENTITY_TEMPLATES_CACHE
    redis:
      time-to-live: 3600000 # 1 hour TTL
      cache-null-values: false

By providing this parameterization, we respect the flexible nature of IDP-Core: users can scale to massive multi-instance deployments safely with Redis, or stay lean with local memory for simple setups.

/// identity here and then loads its relationship graph via a dedicated batch
/// query — the properties/relations on the root [Entity] would otherwise be
/// discarded, so fetching them was pure overhead (perf #131).
public record EntityIdentity(UUID id, String templateIdentifier, String identifier, String name) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we already have an EntitySummary Record wit this information. There is just the technical id missing.

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.

[Perf][P1] Cut ORM/REST serialization overhead (10–30x the actual SQL cost; root cause of concurrency ceiling)

3 participants