Repository navigation
[WP2] @JoinFetch on paged queries forces Hibernate in-memory pagination #174
Copy link
Copy link
Closed as not planned
Closed as not planned
Copy link
Labels
status: pending-design-work動工前需要更多的設計或研究動工前需要更多的設計或研究type: bugSomething isn't workingSomething isn't working
Description
此內容由 AI 產生(specification-mapper 全庫審查 2026-07-16)。
Work package WP2 · findings: PERF-01 · worst severity: high
From the 2026-07-16 whole-codebase review (branch jakarta, modules mapper/ + starter/). Each finding below is CONFIRMED by independent adversarial verification. File paths and line numbers reflect the review snapshot and may drift — treat them as starting points, not exact coordinates.
PERF-01 — Fetch join executes on paged content queries, forcing Hibernate in-memory pagination
- Category / severity: performance / high · effort M · confidence high
- Location:
mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/JoinFetch.java:91 - Evidence: JoinFetch.toPredicate only delegates to a plain Join when shouldDelegateToJoin() is true, which checks Number.class.isAssignableFrom(query.getResultType()) (count/exists queries). For the content query of a paged findAll, the result type is the entity class, so fetch(root) runs root.fetch(pathToFetch, joinType). The starter path starter/src/main/java/tw/com/softleader/data/jpa/spec/starter/repository/support/QueryBySpecExecutorAdapter.java:74 passes that spec straight to findAll(spec, pageable), so firstResult/maxResults are combined with a collection fetch.
- Failure scenario: A query POJO annotated with @JoinFetch on a collection association is executed via repo.findBySpec(pojo, PageRequest.of(0, 20)) against a table with 1M rows: Hibernate logs HHH90003004 ('firstResult/maxResults specified with collection fetch; applying in memory') and loads the entire joined result set into the JVM before slicing the page — latency and heap blow up linearly with table size, potentially OOM.
- Suggested fix: Detect paged execution (or document loudly) — e.g. skip the fetch and fall back to a plain join when the query is the content query of a paged request, or split into an id-page query + fetch-by-ids; at minimum emit a warning in JoinFetch when pagination is in play and document the hazard on the @JoinFetch annotation.
- Verification: [CONFIRMED] Traced the exact path. JoinFetch.toPredicate (mapper/src/main/java/tw/com/softleader/data/jpa/spec/domain/JoinFetch.java:80-92) only delegates to a plain Join when query.getResultType() is a Number (line 91 verbatim matches the evidence; comment at line 89 says it targets count/delete). For a paged content query, Spring Data's SimpleJpaRepository builds CriteriaQuery and applies firstResult/maxResults, so fetch(root) executes root.fetch(...) (lines 100-125); JoinFetch never sees the Pageable and has no pagination guard. QueryBySpecExecutorAdapter.findBySpec(spec, pageable) (starter/.../QueryBySpecExecutorAdapter.java:74) passes the spec straight to findAll(spec, pageable)…
Full report: see the codebase-review-2026-07-16 branch (linked on the parent tracking issue).
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
status: pending-design-work動工前需要更多的設計或研究動工前需要更多的設計或研究type: bugSomething isn't workingSomething isn't working
Activity
shihyuho commentedon Jul 16, 2026
Triage Notes - needs human
Category: bug (performance)
Summary: When a
@JoinFetch-carrying spec is executed through the pagedfindBySpec(Object, Pageable)path, the fetch join runs on the content query, so Hibernate applies pagination in memory (HHH90003004) — latency and heap grow with table size, up to OOM.Current behavior:
tw.com.softleader.data.jpa.spec.domain.JoinFetch.toPredicate(Root, CriteriaQuery, CriteriaBuilder)decides whether to fetch or to fall back to a plain join solely viashouldDelegateToJoin(CriteriaQuery), which returns true only whenNumber.class.isAssignableFrom(query.getResultType())(count / delete queries). For a pagedfindAllcontent query the result type is the entity class, soshouldDelegateToJoinis false andfetch(root)callsroot.fetch(pathToFetch, joinType). The starter pathtw.com.softleader.data.jpa.spec.starter.repository.support.QueryBySpecExecutorAdapter.findBySpec(Object, Pageable)delegates straight toJpaSpecificationExecutor.findAll(spec, pageable), sofirstResult/maxResultsare combined with a collection fetch.JoinFetchnever receives thePageable, and the JPACriteriaQueryAPI exposes no first/max-result signal, sotoPredicatecannot detect that pagination is in play.Desired behavior:
Paging a
@JoinFetchquery over a collection association must not trigger Hibernate in-memory pagination. The page must be resolved at the database level (e.g. paginate entity ids first, then fetch the associations for that page), or the fetch must be safely downgraded/avoided for the paged content query, or — at minimum — the hazard must be surfaced loudly (a warning when pagination is combined with a collection fetch) and documented on the@JoinFetchannotation. Which of these is acceptable is the open decision (see below).Key interfaces:
tw.com.softleader.data.jpa.spec.domain.JoinFetch<T>(implementsSpecification<T>) —toPredicate,shouldDelegateToJoin,delegatePredicateToJoin,fetch.tw.com.softleader.data.jpa.spec.domain.Join<T>— the plain-join fallback already used for count/delete.tw.com.softleader.data.jpa.spec.starter.repository.QueryBySpecExecutor/ its default implQueryBySpecExecutorAdapter—findBySpec(Object, Pageable), the only place thePageableis visible.tw.com.softleader.data.jpa.spec.annotation.JoinFetch— the annotation whose Javadoc would carry any documented hazard.Acceptance criteria (once a strategy is chosen):
@JoinFetch(collection association) spec viafindBySpec(pojo, PageRequest.of(...))no longer produces Hibernate HHH90003004 in-memory pagination for the content query.Pagecontent, total count, distinct semantics, and the eagerly-fetched association remain correct for the requested page.@JoinFetchbehavior for non-pagedfindBySpec,findAll(spec), count, and exists/delete queries is unchanged.Out of scope:
@Join/@JoinFetchresolution.QueryBySpecExecutorbeyond what the chosen strategy strictly requires.Needs human because:
The fix is not fully specified — it turns on a behavior/API policy decision that an autonomous agent must not make on its own:
JoinFetch.toPredicatehas no reliable way to know pagination is active (noPageable, andCriteriaQueryexposes no first/max-result). So any real fix must change the execution strategy in the starter's pagedfindBySpecpath (introspect the spec for a fetch and switch to an id-page + fetch-by-ids execution) or change how/where the fetch is applied — both are architectural changes touching the public query-execution contract.LazyInitializationExceptionor N+1. Whether that trade is acceptable is a maintainer call.A human must pick the strategy (warn-and-document vs. downgrade-fetch vs. id-page-then-fetch) and accept the associated behavior/API trade-off before this is safe to implement autonomously.
shihyuho commentedon Jul 20, 2026
Superseded by #192 in the execution map #204, which carries PERF-01 plus the related COR-08. This 2026-07-16 static plan is being retired in favour of that map.