Skip to content

[WP7] Mapper hot-path allocation: eager AST build + always-on DISTINCT #184

Closed as not planned

Description

@shihyuho

此內容由 AI 產生(specification-mapper 全庫審查 2026-07-16)。

Work package WP7 · findings: PERF-03, PERF-04 · worst severity: medium

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-03 — AST debug tree is fully built and stringified on every toSpec call even when DEBUG is off

  • Category / severity: performance / medium · effort M · confidence high
  • Location: mapper/src/main/java/tw/com/softleader/data/jpa/spec/SpecMapper.java:89
  • Evidence: SpecMapper.toSpec unconditionally creates SpecAST and calls ast.add (String.format per node) for the root, plus preVisit/postVisit per field-resolver pair (ASTNode.java:41-67); postVisit formats the resolved Specification via %s, which triggers SimpleSpecification/CompoundSpecification.toString recursively. SpecAST.write (SpecAST.java:61-63) then concatenates "--- Spec AST ---\n" + print() (a full join of all nodes) before Slf4jDebugWriter.write finally checks logger.isDebugEnabled() (ASTWriterFactory.java:66) and discards everything.
  • Failure scenario: A production service with DEBUG disabled calls repo.findBySpec(pojo) at high QPS; every query pays dozens of String.format calls, recursive toString of the whole spec tree (including all values), list growth, and a full string join — pure garbage allocation and CPU on the hot mapping path that is thrown away at the last line.
  • Suggested fix: Decide up front whether AST logging is active (e.g., let ASTWriterFactory expose an isEnabled(rootObject) check, or have SpecMapper query the target logger's debug level before mapping) and substitute a no-op AST when disabled, so add()/print() cost is only paid when the output will be written.
  • Verification: [CONFIRMED] Traced the full path. SpecMapper.toSpec (SpecMapper.java:85-98) unconditionally instantiates SpecAST, calls ast.add for the root (String.format), and always executes the try-with-resources ast.write(writer). resolveSpec (lines 137-139) always invokes preVisit/postVisit for every field-resolver pair; ASTNode.postVisit (ASTNode.java:59-67) formats the resolved Specification with %s. CompoundSpecification.toString (line 57-61, "specs=" + specs) and SimpleSpecification.toString (line 109-114, path/value) are overridden and recurse, so formatting the root spec at SpecMapper.java:95 recursively stringifies the entire spec tree including all values. SpecAST.write (SpecAST.java:61-63) co…

PERF-04 — distinct=true default forces SELECT DISTINCT on every joined query

  • Category / severity: performance / low · effort S · confidence high
  • Location: mapper/src/main/java/tw/com/softleader/data/jpa/spec/annotation/Join.java:72
  • Evidence: @Join and @JoinFetch (annotation/JoinFetch.java:71) both default distinct() to true, and domain/Join.java:84 / domain/JoinFetch.java:83 call query.distinct(distinct) unconditionally in toPredicate, so any query using a join annotation without explicitly setting distinct=false runs SELECT DISTINCT.
  • Failure scenario: A POJO joins a many-to-one association (which cannot produce duplicate roots) over a wide entity with LOB/large columns: Hibernate 6 pushes DISTINCT into the SQL, forcing the database to sort/hash-dedup the full result set of every such query — measurable extra DB CPU and slower queries for zero semantic benefit; on some databases DISTINCT over LOB columns fails outright.
  • Suggested fix: Keep the default only for collection joins if detectable, or document that distinct=false should be set for to-one joins; alternatively only call query.distinct(true) when distinct is requested instead of always overwriting the query flag.
  • Verification: [CONFIRMED] Traced all four cited locations. annotation/Join.java:72 and annotation/JoinFetch.java:71 both declare boolean distinct() default true;. domain/Join.java toPredicate does if (query != null) query.distinct(distinct); unconditionally. domain/JoinFetch.java toPredicate does the same after a count/delete short-circuit, and delegatePredicateToJoin passes distinct through to Join. So a join annotation left at the default forces SELECT DISTINCT on the query — the described behavior is accurate. The failure_scenario (to-one join gaining a useless DB-side DISTINCT, worse over LOB columns) is a genuine performance cost. However, this is a deliberate and javadoc-documented default ("Def…

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: On the object-to-Specification mapping hot path, the debug AST is fully built and stringified on every call regardless of log level, and every join annotation forces SELECT DISTINCT by default — both cost CPU/allocation (or DB work) with no benefit unless the maintainer settles two open design/policy calls.

    Current behavior

    • PERF-03 (AST always built): SpecMapper.toSpec(Object) unconditionally constructs a SpecAST, records the root node (SpecAST.add, which runs String.format), and for every field/resolver pair invokes ASTNode.preVisit/postVisit. postVisit formats the resolved Specification with %s, and the root node likewise formats the whole resulting spec with %s. Because CompoundSpecification.toString emits "specs=" + specs and SimpleSpecification.toString emits its path/value, that %s recursively stringifies the entire spec tree, including all bound values. SpecAST.write then joins every node into one string. The only check on whether logging is actually enabled lives at the very end, in Slf4jDebugWriter.write, which calls logger.isDebugEnabled() and discards everything when DEBUG is off. So with debug disabled the full tree walk, formatting, list growth, and string join are all paid and thrown away.
    • PERF-04 (always-on DISTINCT): @Join and @JoinFetch both declare boolean distinct() default true. domain/Join.toPredicate and domain/JoinFetch.toPredicate call query.distinct(distinct) unconditionally (JoinFetch after its count/delete short-circuit, which delegates to Join passing distinct through). Any POJO using a join annotation without explicitly setting distinct=false therefore emits SELECT DISTINCT, even for to-one joins that cannot produce duplicate roots — extra DB sort/hash-dedup, and outright failure on some databases when DISTINCT spans LOB columns. This default is deliberate and documented in the annotation Javadoc ("Defaults to true to prevent duplicate records").

    Desired behavior

    • PERF-03: When AST/debug output will not be consumed (target logger DEBUG disabled), the mapper must not perform the AST string formatting or the recursive Specification.toString walk. When DEBUG is enabled, the emitted AST log must be byte-for-byte identical to today's output.
    • PERF-04: A join on a to-one association should not be forced to pay a DB-side DISTINCT it cannot benefit from — via a changed/narrowed default, a detection mechanism, or clearer guidance — without silently reintroducing duplicate rows for collection joins.

    Key interfaces

    • SpecMapper.toSpec(Object) and SpecMapper.toSpec(Context, Object); SpecMapper.resolveSpec(...).
    • SpecAST.add(int, String, Object...), SpecAST.print(), SpecAST.write(Writer); the AST interface.
    • ASTNode.preVisit(SpecInvocation) / postVisit(SpecInvocation, Specification).
    • ASTWriterFactory (public SPI: createWriter(Object, Specification), static factories domain() and impersonation()); package-private Slf4jDebugWriter.
    • CompoundSpecification.toString(), SimpleSpecification.toString().
    • Annotations tw.com.softleader.data.jpa.spec.annotation.Join / JoinFetch (distinct()); domain/Join.toPredicate / domain/JoinFetch.toPredicate.

    Acceptance criteria

    • With the target logger at a level above DEBUG, mapping an object does not invoke toString() on the resolved Specification tree (verifiable via a resolver that returns a Specification whose toString increments a counter / throws) and does not build the AST string.
    • With DEBUG enabled, the logged AST output is unchanged from current behavior (existing AST-logging tests still pass).
    • For PERF-04: the chosen resolution is implemented consistently across @Join, @JoinFetch, domain/Join, and domain/JoinFetch, with tests covering both the distinct and non-distinct query paths, and collection-join de-duplication behavior is preserved (or the behavior change is explicitly accepted and documented).
    • No change to the public API surface beyond what the maintainer approves for the chosen approach.

    Out of scope

    • Reworking the resolver ordering or the broader SpecMapper traversal algorithm.
    • Changing the AST log format or wording.

    Needs human because:

    1. PERF-03 requires a public-SPI / API-shape decision. The clean fix needs the mapper to learn up front whether output will be written, but enablement depends on the logger the ASTWriterFactory chooses (domain() → SpecMapper's logger; impersonation() → the mapped object's logger), and the current terminal gate (isDebugEnabled) is buried inside the package-private Slf4jDebugWriter behind the public Writer return type. ASTWriterFactory is a public SPI (users supply custom factories via SpecMapperBuilder.astWriterFactory(...)). Every viable fix — adding an isEnabled(rootObject) method to the SPI, or having SpecMapper probe the target logger — changes or extends that public contract. The reviewer's own suggested fix offered these as an unresolved "or". A maintainer should choose the SPI shape (and whether/how existing lambda factories domain()/impersonation() are reshaped).
    2. PERF-04 is a documented-default / breaking-change policy decision. distinct=true is a deliberate, Javadoc-documented default that prevents duplicate roots for collection joins. Narrowing or flipping it (default to false, auto-detect to-one vs collection cardinality, or only set the flag when explicitly requested) risks reintroducing duplicate rows and is a behavioral/breaking change whose acceptability is a maintainer call. The reviewer explicitly noted this is "a deliberate and javadoc-documented default." An agent cannot pick the default policy without that judgment.

    An agent could safely implement PERF-03 once the SPI approach is fixed, and PERF-04 once the default policy is decided; both blockers are design/policy sign-offs rather than infrastructure or access limitations.

  3. shihyuho commented on Jul 20, 2026

    @shihyuho
    MemberAuthor

    Superseded by #201 in the execution map #204, which carries the same findings (PERF-03, PERF-04). 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