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.
Describe the bug
SolverFactorycreation fails with a misleading error whenever the user's domain classes are reachable through thetimefold-solver-coreJAR's classloader (i.e. aboveGizmoClassLoader.getParent()). Under Spring Boot DevTools every startup hits this; isolated test classloaders and JPMS layers can hit it too.The class named is in the listed problem facts. Two
Class<?>instances exist for the same canonical name, loaded by different classloaders.Expected behavior
SolverFactoryis created successfully, regardless of whether user classes are reachable through both the parent and the TCCL ofGizmoClassLoader.Actual behavior
SolverFactory.create(solverConfig)throwsIllegalArgumentException: Cannot use class ... in a constraint streamfor the first problem-fact class joined in a constraint.Root cause
GizmoClassLoader's parent isGizmoClassLoader.class.getClassLoader()(line 37). The standardClassLoader.loadClassis parent-first, so when a Gizmo-generatedMemberAccessorresolves itsdeclaringClassconstant orfield.getGenericType()forList<MyProblemFact>, the JVM callsloadClass, which delegates to the parent before reachingfindClass(lines 50–51). Whenever user classes are reachable through that parent, the parent wins and the TCCL fallback infindClassis dead code. The user'sConstraintProviderwas loaded by TCCL (e.g.RestartClassLoader), so itsMyProblemFact.classliteral resolves via TCCL — differentClassidentity,assertValidFromTyperejects it.To Reproduce
Spring Boot 4 +
timefold-solver-spring-boot-starter:2.0.0+spring-boot-devtoolson the classpath, with any constraint that does.join(SomeProblemFact.class, ...). The same app started viajava -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 tagv2.0.0)Output of
java -version:Output of
uname -a:Darwin 25.3.0 arm64Other relevant context:
4.0.6spring-boot-devtools4.0.6on the classpath (optional dependency)solverConfig.xmlwith<solutionClass>,<entityClass>,<constraintProviderClass>. Auto-discovery exhibits the same bug.Suggested fix
Override
GizmoClassLoader.loadClassso non-Gizmo classes are looked up through the thread context classloader before delegating to the parent: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 secondson therestartedMainthread, no exception.Additional information
I instrumented
assertValidFromTypeand dumped eachfactType's loader: the planning entity (registered throughsolverConfig.entityClassList) was onRestartClassLoader, but every@ProblemFactCollectionPropertyelement type was onAppClassLoader, whilefromTypefrom the constraint provider wasRestartClassLoader. The patch above made all problem-factClassinstances match the constraint provider's loader.Happy to send a PR with the patch and a regression test (AssertJ) that loads a class through
GizmoClassLoaderwhile a child TCCL contains a same-named class, assertingloadClassreturns the TCCL's instance.