Repository navigation
[WP7] Mapper hot-path allocation: eager AST build + always-on DISTINCT #184
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 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 doesif (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).
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: On the object-to-
Specificationmapping hot path, the debug AST is fully built and stringified on every call regardless of log level, and every join annotation forcesSELECT DISTINCTby default — both cost CPU/allocation (or DB work) with no benefit unless the maintainer settles two open design/policy calls.Current behavior
SpecMapper.toSpec(Object)unconditionally constructs aSpecAST, records the root node (SpecAST.add, which runsString.format), and for every field/resolver pair invokesASTNode.preVisit/postVisit.postVisitformats the resolvedSpecificationwith%s, and the root node likewise formats the whole resulting spec with%s. BecauseCompoundSpecification.toStringemits"specs=" + specsandSimpleSpecification.toStringemits its path/value, that%srecursively stringifies the entire spec tree, including all bound values.SpecAST.writethen joins every node into one string. The only check on whether logging is actually enabled lives at the very end, inSlf4jDebugWriter.write, which callslogger.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.@Joinand@JoinFetchboth declareboolean distinct() default true.domain/Join.toPredicateanddomain/JoinFetch.toPredicatecallquery.distinct(distinct)unconditionally (JoinFetch after its count/delete short-circuit, which delegates toJoinpassingdistinctthrough). Any POJO using a join annotation without explicitly settingdistinct=falsetherefore emitsSELECT 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
Specification.toStringwalk. When DEBUG is enabled, the emitted AST log must be byte-for-byte identical to today's output.Key interfaces
SpecMapper.toSpec(Object)andSpecMapper.toSpec(Context, Object);SpecMapper.resolveSpec(...).SpecAST.add(int, String, Object...),SpecAST.print(),SpecAST.write(Writer); theASTinterface.ASTNode.preVisit(SpecInvocation)/postVisit(SpecInvocation, Specification).ASTWriterFactory(public SPI:createWriter(Object, Specification), static factoriesdomain()andimpersonation()); package-privateSlf4jDebugWriter.CompoundSpecification.toString(),SimpleSpecification.toString().tw.com.softleader.data.jpa.spec.annotation.Join/JoinFetch(distinct());domain/Join.toPredicate/domain/JoinFetch.toPredicate.Acceptance criteria
toString()on the resolvedSpecificationtree (verifiable via a resolver that returns aSpecificationwhosetoStringincrements a counter / throws) and does not build the AST string.@Join,@JoinFetch,domain/Join, anddomain/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).Out of scope
SpecMappertraversal algorithm.Needs human because:
ASTWriterFactorychooses (domain()→SpecMapper's logger;impersonation()→ the mapped object's logger), and the current terminal gate (isDebugEnabled) is buried inside the package-privateSlf4jDebugWriterbehind the publicWriterreturn type.ASTWriterFactoryis a public SPI (users supply custom factories viaSpecMapperBuilder.astWriterFactory(...)). Every viable fix — adding anisEnabled(rootObject)method to the SPI, or havingSpecMapperprobe 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 factoriesdomain()/impersonation()are reshaped).distinct=trueis a deliberate, Javadoc-documented default that prevents duplicate roots for collection joins. Narrowing or flipping it (default tofalse, 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.
shihyuho commentedon Jul 20, 2026
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.