Skip to content

feat(apt): detect circular Q-class references and warn at compile time#1739

Open
o54711254 wants to merge 1 commit into
OpenFeign:masterfrom
o54711254:feat/warn-circular-qclass-reference
Open

feat(apt): detect circular Q-class references and warn at compile time#1739
o54711254 wants to merge 1 commit into
OpenFeign:masterfrom
o54711254:feat/warn-circular-qclass-reference

Conversation

@o54711254
Copy link
Copy Markdown

Problem Description

When two JPA entities have a bidirectional association (e.g., @OneToOne), QueryDSL APT generates Q-classes that contain circular static field references. If two threads simultaneously access these Q-classes for the first time, a class initialization deadlock occurs — all worker threads hang indefinitely without any exceptions or error logs.

This issue has been a long-standing pain point since 2012 (querydsl/querydsl#146) and 2018 (querydsl/querydsl#2334). While it is mentioned as a known risk in the documentation, developers often have no way to detect it until their application hangs in production.

Proposed Solution

I have added a compile-time detection mechanism in AbstractQuerydslProcessor to identify potential deadlocks before they reach runtime.

Key Features:

  • Cycle Detection via DFS: Implements a Depth-First Search traversal to find cycles in entity relationships.
  • Zero-Overhead for Production: Uses a globalVisited set to ensure O(V+E) time complexity, preventing significant build-time slowdowns even in large projects.
  • Smart Filtering (Anti-False Positive):
    • Direct References Only: Skips collection types (LIST, SET, MAP) because they are generated as ListPath, etc., which do not trigger the target Q-class loading during the <clinit> phase.
    • Configuration Aware: Only runs when createDefaultVariable is true, as disabling this option effectively prevents the deadlock.
  • Actionable Feedback: Provides a clear warning message with three specific remediation strategies.

Example Warning Output:
image
The screenshot above demonstrates how the warning is presented during the Gradle build process.

Testing

Added comprehensive test cases in QuerydslAnnotationProcessorCompileTest using google/compile-testing:

  • circularQClassReference_producesWarning: Validates detection of direct bidirectional cycles.
  • indirectCircularReference_producesWarning: Validates detection of multi-level cycles (A → B → C → A).
  • unidirectionalReference_noWarning: Ensures no false warnings for safe, unidirectional paths.
  • collectionReference_noWarning: Crucial Test - Confirms that @OneToMany (collections) do not trigger warnings, as they are safe from <clinit> deadlocks.

Why This Location in processAnnotations()

The detection runs just before context.clean(), after typeFactory.extendTypes(). At this point, context.entityTypes is fully populated with all entity properties, ensuring accurate cycle detection. Running it earlier would risk missing properties that haven't been resolved yet.

Adds compile-time cycle detection in AbstractQuerydslProcessor using DFS
traversal over entity type relationships. Emits a WARNING when circular
static field references are found between Q-classes, which can cause
class initialization deadlock in multi-threaded environments.

- Only flags direct entity references (not collections) to avoid false positives
- Only runs when createDefaultVariable is true
- Reports all detected cycles with actionable remediation steps
@soidisant
Copy link
Copy Markdown

Nice work on this — that deadlock has been a quiet menace for over a decade and a compile-time warning is the right escape hatch. 👍

A bit of cross-PR context: I'm working on #1728 which reshapes the KSP-generated Q-classes (querydsl-ksp-codegen). Every object reference there is emitted as by lazy, which means a Q-class's static initializer never triggers another's transitively. Kotlin's delegated properties happen to get a lot of mileage out of two keywords — what PathInits.DIRECT2 and your DFS detector achieve carefully on the Java side, by lazy shrugs off for free. (Not a knock on APT; it's a fair trade-off given plain Java's lack of a built-in lazy primitive.)

The deadlock is structurally absent on the KSP side, pinned by two dynamic tests:

  • bidirectionalEntities_loadConcurrentlyWithoutDeadlock — compiles your canonical case (two entities with mutual @ManyToOne refs), races two threads to touch QFoo.foo and QBar.bar simultaneously, asserts neither hangs.
  • inheritedObjectReference_doesNotRecurseAtConstruction — covers the trickier @MappedSuperclass variant (Auditable.createdBy : User with User extends Auditable) by reflectively loading QUser.user and asserting no StackOverflowError.

So this detector would never need to fire for KSP-generated output. Whether that matters for your PR is up to the maintainer — just figured it's worth flagging in case the warning machinery ever lands somewhere that processes both pipelines.

And for anyone reading this PR who is on Kotlin (or considering it): if your stack allows, switching codegen to querydsl-ksp-codegen once #1728 lands sidesteps this whole category of issue without needing the warning at all. The APT half of the codebase definitely still benefits from your work though — most consumers won't be switching.

@o54711254
Copy link
Copy Markdown
Author

@soidisant Thanks for the context and kind words!

Good to know that KSP handles this structurally via by lazy.
The detection in this PR only runs within AbstractQuerydslProcessor,
so it won't affect KSP-generated output.

Looking forward to seeing #1728 land!

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.

2 participants