Skip to content

fix(core): GizmoClassLoader parent-first delegation breaks ConstraintProvider class identity under Spring Boot DevTools #2264

@janwillemkeizer

Description

@janwillemkeizer

Describe the bug

SolverFactory creation fails with a misleading error whenever the user's domain classes are reachable through the timefold-solver-core JAR's classloader (i.e. above GizmoClassLoader.getParent()). Under Spring Boot DevTools every startup hits this; isolated test classloaders and JPMS layers can hit it too.

java.lang.IllegalArgumentException: Cannot use class
(com.example.MyProblemFact) in a constraint stream
as it is neither the same as, nor a superclass or superinterface
of one of planning entities or problem facts.
Ensure that all from(), join(), ifExists() and ifNotExists() building
blocks only reference classes assignable from planning entities or
problem facts ([..., com.example.MyProblemFact, ...])
annotated on the planning solution (com.example.MySolution).

The class named is in the listed problem facts. Two Class<?> instances exist for the same canonical name, loaded by different classloaders.

Expected behavior

SolverFactory is created successfully, regardless of whether user classes are reachable through both the parent and the TCCL of GizmoClassLoader.

Actual behavior

SolverFactory.create(solverConfig) throws IllegalArgumentException: Cannot use class ... in a constraint stream for the first problem-fact class joined in a constraint.

Root cause

GizmoClassLoader's parent is GizmoClassLoader.class.getClassLoader() (line 37). The standard ClassLoader.loadClass is parent-first, so when a Gizmo-generated MemberAccessor resolves its declaringClass constant or field.getGenericType() for List<MyProblemFact>, the JVM calls loadClass, which delegates to the parent before reaching findClass (lines 50–51). Whenever user classes are reachable through that parent, the parent wins and the TCCL fallback in findClass is dead code. The user's ConstraintProvider was loaded by TCCL (e.g. RestartClassLoader), so its MyProblemFact.class literal resolves via TCCL — different Class identity, assertValidFromType rejects it.

To Reproduce

Spring Boot 4 + timefold-solver-spring-boot-starter:2.0.0 + spring-boot-devtools on the classpath, with any constraint that does .join(SomeProblemFact.class, ...). The same app started via java -jar (no DevTools, single classloader for project classes) starts cleanly. Happy to attach a minimal Gist reproducer if helpful.

Environment

Timefold Solver Version or Git ref: 2.0.0 (release tag v2.0.0)

Output of java -version:

openjdk version "25" 2025-09-16 LTS
OpenJDK Runtime Environment Temurin-25+36 (build 25+36-LTS)

Output of uname -a: Darwin 25.3.0 arm64

Other relevant context:

  • Spring Boot 4.0.6
  • spring-boot-devtools 4.0.6 on the classpath (optional dependency)
  • App uses an explicit solverConfig.xml with <solutionClass>, <entityClass>, <constraintProviderClass>. Auto-discovery exhibits the same bug.

Suggested fix

Override GizmoClassLoader.loadClass so non-Gizmo classes are looked up through the thread context classloader before delegating to the parent:

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        Class<?> loadedClass = findLoadedClass(name);
        if (loadedClass == null) {
            if (hasBytecodeFor(name)) {
                loadedClass = findClass(name);
            } else {
                ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
                if (contextClassLoader != null && contextClassLoader != this) {
                    try {
                        loadedClass = contextClassLoader.loadClass(name);
                    } catch (ClassNotFoundException ignored) {
                        // Fall through to parent delegation below.
                    }
                }
                if (loadedClass == null) {
                    loadedClass = super.loadClass(name, false);
                }
            }
        }
        if (resolve) {
            resolveClass(loadedClass);
        }
        return loadedClass;
    }
}

The constructor's parent setting (line 37) and the comment on lines 32–36 about the Quarkus MemberAccessor double-load invariant are not changed — only the delegation order. Under Quarkus, Gizmo-generated MemberAccessors are still resolved at step 2 (hasBytecodeFor) and never reach TCCL, so that invariant still holds. Under Spring Boot DevTools, user classes now hit step 3 (TCCL = RestartClassLoader) instead of being silently captured by the parent.

Native image: isGizmoSupported() already gates the Gizmo path, and TCCL on SubstrateVM is fixed at build time, so AOT behaviour is unchanged.

Verified locally on a Spring Boot 4 + TimeFold 2.0.0 + DevTools project: Started ... in 5.7 seconds on the restartedMain thread, no exception.

Additional information

I instrumented assertValidFromType and dumped each factType's loader: the planning entity (registered through solverConfig.entityClassList) was on RestartClassLoader, but every @ProblemFactCollectionProperty element type was on AppClassLoader, while fromType from the constraint provider was RestartClassLoader. The patch above made all problem-fact Class instances match the constraint provider's loader.

Happy to send a PR with the patch and a regression test (AssertJ) that loads a class through GizmoClassLoader while a child TCCL contains a same-named class, asserting loadClass returns the TCCL's instance.

Metadata

Metadata

Labels

No labels
No labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions