Skip to content

perf(api): make relation inclusion opt-in on entity list endpoint (fixes #130) - #135

Open
mmornati wants to merge 2 commits into
mainfrom
perf/p0-opt-in-relations-list-endpoint
Open

perf(api): make relation inclusion opt-in on entity list endpoint (fixes #130)#135
mmornati wants to merge 2 commits into
mainfrom
perf/p0-opt-in-relations-list-endpoint

Conversation

@mmornati

@mmornati mmornati commented Aug 18, 2026

Copy link
Copy Markdown

What

GET /api/v1/entities/{templateIdentifier} now accepts an include_relations query parameter, defaulting to false. The default path returns properties only, skipping relation-graph resolution entirely. Passing include_relations=true preserves the exact previous (eager) behavior for callers that need per-item relations. Get-by-id (GET /api/v1/entities/{templateIdentifier}/{entityIdentifier}) and POST /api/v1/entities/search are unaffected by this PR.

Closes #130.

Why

Correction from review: the list endpoint already returned first-level relations before #90 — split into two sections, relations (outbound) and relations_as_target (inbound), populated via a dedicated paginated getRelationsAsTarget lookup. feat(api)!: update entity output for having relations grouped (#90) didn't newly introduce relations to list responses; it replaced that mechanism with the shared entityGraphService.getEntityGraphPageByTemplate(depth=1) graph-traversal service (the same one used by the dedicated graph endpoint), unifying the output into a single relations map. Functionally equivalent at depth 1, but with materially worse performance characteristics for the list use case — that's the actual regression, and the measured numbers below are unaffected by this correction, only the causal explanation is.

This was found while benchmarking IDP v2 against the app-referential POC for an AI/LLM-consumption use case: an agent enumerating/filtering entities by template only needs identifiers and properties for most of its calls, and fetching relations for every row of every page — regardless of whether the caller asked for them — showed up directly in that benchmark as the dominant list-latency and payload cost (see idp-v2-vs-app-referential-analysis.md, §12).

This is P0 of a 5-part prioritized performance plan derived from that analysis:

Priority Issue Status
P0 Make relation inclusion opt-in on list endpoint (this PR) #130 — fixed here
P1 Cut ORM/REST serialization overhead (10–30x the actual SQL cost) #131
P2 Shrink entity/relation payload size for token-cost efficiency #132
P3 EAV I/O tax — re-benchmark at 10x scale before hybrid columns #133
P4 Local-dev gaps: CSRF on /search, mock-auth flag disabled #134

P0 was picked first because it reverses a real regression (not a pre-existing architectural cost), is small/low-risk, and directly addresses the concurrency-ceiling root cause identified in the analysis's load testing (§13).

Evidence

Before (root cause) — from the original analysis, re-benchmarked 2026-08-17 after #90 landed

Query Before #90 After #90 Delta
subdomain=ORDER (18 of 953 products) ~28–30 ms ~38–52 ms ~30–70% slower
type=DECATHLON_API (1,042 of 3,972 components) ~77–90 ms ~120–207 ms ~1.5–2.3x slower
Graph traversal depth 1→3 flat ~27–37 ms regardless of depth scales with depth: ~29→45→48-61 ms new scaling problem

Payload for a single entity + 145 relations: 29,555 B → 39,323 B (+33%) after #90.

After this PR — measured against the same live seeded dataset (~3,972 entities / 6,501 relationships, mirroring app-referential's real data), warm requests

Scenario Before (always-on relations) After (default, this PR) Δ
High-selectivity list (type=DECATHLON_API, 200 rows) ~60–100 ms, 133 KB ~40 ms, 76 KB ~2x faster, -43% payload
Low-selectivity list (subdomain=ORDER) ~36–56 ms, 48.8 KB ~17 ms, 8.1 KB ~2-3x faster, -83% payload
Plain unfiltered list ~62–80 ms, 126 KB ~22 ms, 75 KB ~3x faster, -40% payload
20 concurrent requests (high-selectivity) 0.41s wall 0.25s wall ~1.65x
Get-by-id (unaffected path, sanity check) ~20–35 ms ~20–35 ms flat, as expected

include_relations=true reproduces the exact previous 133 KB payload for the high-selectivity case — confirms this is a true opt-in, not a behavior change for existing consumers that explicitly need relations.

Per-item relation overhead breakdown (why opt-in, not always-on)

Scenario Items Items w/ relations Payload w/o relations Payload w/ relations Relations overhead
Low selectivity (18 rows) 18 18 (100%) 8,661 B 51,270 B 42,609 B (83% of payload)
High selectivity (200 rows) 200 198 (99%) 81,307 B 141,118 B 59,811 B (42% of payload)
Plain unfiltered (200 rows) 200 187 (94%) 79,791 B 133,480 B 53,689 B (40% of payload)

Nearly every listed entity has some relation, so this isn't "rare data" being skipped — it's that unconditionally attaching it to every row in every page multiplies cost with result-set size regardless of whether the caller needs it. Most list/search consumers (filtered UIs, LLM-driven lookups) only need identifiers/properties and can fetch relations for a specific entity via the by-id endpoint when actually required.

Testing

  • EntityControllerTest (120 tests) — updated 3 relation-specific list assertions to pass include_relations=true to preserve their intent; all pass.
  • Full entity/graph-related suite (EntityGraphServiceTest, EntityGraphControllerTest, EntityService*Test) — 200 tests, all pass.
  • Manual validation against a live app instance with the same dataset used in the original benchmark (see numbers above).
  • Re-ran the full targeted suite again after rebasing onto latest main and adding the doc updates below (154 tests, all pass).

Backward compatibility

This is additive: existing callers get a faster, smaller default response (properties only). Any caller that depends on relations being present in list responses must add include_relations=true — flagging this as a contract change for API consumers/frontends that currently rely on relations being present by default. As requested in review, this PR also updates the API contract artifacts for the new parameter:

  • docs/src/static/swagger.yaml — added include_relations (boolean, default false) to the GET /api/v1/entities/{templateIdentifier} operation, verified against the app's own generated /v3/api-docs/internal output.
  • docs/src/concepts/entities.md — documented the parameter and default/opt-in example response shapes under "List Entities by Template".

@github-code-quality

github-code-quality Bot commented Aug 18, 2026

Copy link
Copy Markdown

Code Coverage Overview

Languages: Java

Java / code-coverage/jacoco

The overall line coverage in commit 4f84a5f in the perf/p0-opt-in-relat... branch remains at 91%, unchanged from commit 2054615 in the main branch.

Show a line coverage summary of the most impacted files.
File main 2054615 perf/p0-opt-in-relat... 4f84a5f +/-
com/decathlon/i...aphService.java 88% 84% -4%
com/decathlon/i...NodeMapper.java 92% 91% -1%
com/decathlon/i...oOutMapper.java 87% 87% 0%
com/decathlon/i...Controller.java 100% 100% 0%

Updated September 08, 2026 07:42 UTC

@brandPittCode

Copy link
Copy Markdown
Collaborator

About this:

" feat(api)!: update entity output for having relations grouped (#90) caused every item returned by the list endpoint to eagerly resolve and serialize its full relation graph, not just its properties — previously this endpoint returned properties-only for list views. This is a measured regression, not a pre-existing gap, identified while benchmarking IDP v2 against the app-referential POC (see idp-v2-vs-app-referential-analysis.md, §12)."

The /api/v1/entities/{templateIdentifier} endpoint has always returned first-level relations. Prior to #90, relations were split into two sections: relations (outbound) and relation_as_target (inbound), but both were always included if they existed. Therefore, this wouldn't be a regression.

Following #90, we achieve the same result by using the new centralized entityGraphService.getEntityGraphPageByTemplate method with the depth set to 1. We are not resolving the full relation graph—only direct inbound and outbound relations. This approach replaced the paginated getRelationsAsTarget method.

As a result, the performance difference compared to the previous version likely stems from migrating to the Graph service from the paginated getRelationsAsTarget method. The migration was a deliberate trade-off in favor of simplicity and code factorization. More importantly, it lays the groundwork for supporting configurable relation depths in paginated responses.

Consequently, this PR introduces a flag to exclude relations from the response, avoiding unnecessary relations mapping after receiving entityTemplateService.getEntityTemplateByIdentifier(templateIdentifier) response. While this feature was not originally on our radar, given the valid use case and the significant performance improvements shown in benchmarks, it is worth discussing. Can you please give us more information about the use case that lead you to the finding?

By the way, for a change in the API contract, the PR would need to also update the docs/src/static/swagger.yaml and the user documentation. docs/src/concepts/entities.md

@RVANDO12 @evebrnd @foukou19 @etiennej70

@brandPittCode brandPittCode left a comment

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.

a change in the API contract needs to update the docs/src/static/swagger.yaml and the user documentation. `docs/src/concepts/entities.md

mmornati and others added 2 commits September 8, 2026 09:35
Fixes #130

feat(api)!: update entity output for having relations grouped (#90)
caused GET /api/v1/entities/{templateIdentifier} to eagerly resolve
and serialize the full relation graph for every item in a
paginated/filtered list, regardless of whether the caller needed
relations. This regressed list latency 1.5-2.3x and payload size
+33% (see idp-v2-vs-app-referential-analysis.md, section 12).

Add an include_relations query parameter (default false). The
default path now returns properties only, skipping relation-graph
resolution entirely via a new
EntityDtoOutMapper#fromEntitiesPageToDtoPageWithoutRelations.
include_relations=true preserves the previous eager-relations
behavior exactly for callers that need it. Get-by-id and /search
endpoints are unaffected.

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

Adds the include_relations boolean parameter to the checked-in OpenAPI
spec (docs/src/static/swagger.yaml) and to the entities concept guide
(docs/src/concepts/entities.md), matching the generated /v3/api-docs/internal
spec produced by the running app. Addresses PR review feedback requesting
these two files be kept in sync with the API contract change.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@mmornati
mmornati force-pushed the perf/p0-opt-in-relations-list-endpoint branch from a3eb4ba to 4f84a5f Compare September 8, 2026 07:38
@mmornati

mmornati commented Sep 8, 2026

Copy link
Copy Markdown
Author

Thanks for the correction — you're right, and I've updated the PR description to reflect it.

For the record, the corrected causal explanation: the list endpoint already returned first-level relations before #90, split into relations (outbound) / relations_as_target (inbound), populated via a dedicated paginated getRelationsAsTarget lookup. #90 didn't newly introduce relations to list responses — it replaced that mechanism with the shared entityGraphService.getEntityGraphPageByTemplate(depth=1) graph-traversal service (unifying the output into a single relations map). That's functionally equivalent at depth 1 but has worse performance characteristics for the list use case, which is the actual regression this PR addresses. The measured before/after numbers in the PR are unaffected by this correction — only the "why" narrative was wrong, not the data.

Use case/context: this came out of benchmarking IDP v2 against the app-referential POC for an AI/LLM-consumption scenario — an agent enumerating/filtering entities by template typically only needs identifiers and properties, and eagerly resolving relations for every row of every page showed up as the dominant list-latency and payload cost in that benchmark (idp-v2-vs-app-referential-analysis.md, §12).

Also done: docs/src/static/swagger.yaml and docs/src/concepts/entities.md are now updated in this PR for the new include_relations query parameter (verified the swagger param against the app's own generated /v3/api-docs/internal output).

@sonarqubecloud

sonarqubecloud Bot commented Sep 8, 2026

Copy link
Copy Markdown

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][P0] Make relation inclusion opt-in on entity list endpoint (reverse #90 regression)

2 participants