Skip to content

[WP2] @JoinFetch on paged queries forces Hibernate in-memory pagination #174

Closed as not planned

Description

@shihyuho

此內容由 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).

Activity

  1. added theissue type on Jul 16, 2026
  2. shihyuho commented on Jul 16, 2026

    @shihyuho
    MemberAuthor

    This was generated by AI during triage.

    Triage Notes - needs human

    Category: bug (performance)

    Summary: When a @JoinFetch-carrying spec is executed through the paged findBySpec(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 via shouldDelegateToJoin(CriteriaQuery), which returns true only when Number.class.isAssignableFrom(query.getResultType()) (count / delete queries). For a paged findAll content query the result type is the entity class, so shouldDelegateToJoin is false and fetch(root) calls root.fetch(pathToFetch, joinType). The starter path tw.com.softleader.data.jpa.spec.starter.repository.support.QueryBySpecExecutorAdapter.findBySpec(Object, Pageable) delegates straight to JpaSpecificationExecutor.findAll(spec, pageable), so firstResult/maxResults are combined with a collection fetch. JoinFetch never receives the Pageable, and the JPA CriteriaQuery API exposes no first/max-result signal, so toPredicate cannot detect that pagination is in play.

    Desired behavior:
    Paging a @JoinFetch query 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 @JoinFetch annotation. Which of these is acceptable is the open decision (see below).

    Key interfaces:

    • tw.com.softleader.data.jpa.spec.domain.JoinFetch<T> (implements Specification<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 impl QueryBySpecExecutorAdapter — findBySpec(Object, Pageable), the only place the Pageable is 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):

    • Executing a @JoinFetch (collection association) spec via findBySpec(pojo, PageRequest.of(...)) no longer produces Hibernate HHH90003004 in-memory pagination for the content query.
    • Returned Page content, total count, distinct semantics, and the eagerly-fetched association remain correct for the requested page.
    • Existing @JoinFetch behavior for non-paged findBySpec, findAll(spec), count, and exists/delete queries is unchanged.
    • A JUnit test in the existing suite reproduces the paged-collection-fetch scenario and asserts the chosen fix holds (e.g. captures/asserts absence of the in-memory-pagination path, or the id-page-then-fetch execution).

    Out of scope:

    • Redesigning the non-paged @Join / @JoinFetch resolution.
    • Changing the public shape of QueryBySpecExecutor beyond 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:

    1. JoinFetch.toPredicate has no reliable way to know pagination is active (no Pageable, and CriteriaQuery exposes no first/max-result). So any real fix must change the execution strategy in the starter's paged findBySpec path (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.
    2. Silently downgrading the fetch to a plain join under pagination is a breaking behavior change: callers relying on the eager fetch would newly hit LazyInitializationException or N+1. Whether that trade is acceptable is a maintainer call.
    3. The id-page-then-fetch approach changes query execution semantics and count/distinct handling and needs a public-behavior sign-off.
      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.
  3. shihyuho commented on Jul 20, 2026

    @shihyuho
    MemberAuthor

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions