diff --git a/benchmark/src/main/resources/benchmark.xsd b/benchmark/src/main/resources/benchmark.xsd index 9ac3474560..d60a861541 100644 --- a/benchmark/src/main/resources/benchmark.xsd +++ b/benchmark/src/main/resources/benchmark.xsd @@ -446,6 +446,9 @@ + + + diff --git a/core/constraint-streams/src/main/java/ai/timefold/solver/constraint/streams/bavet/BavetConstraintStreamScoreDirectorFactoryService.java b/core/constraint-streams/src/main/java/ai/timefold/solver/constraint/streams/bavet/BavetConstraintStreamScoreDirectorFactoryService.java index 6855b8a3a4..242abbb61b 100644 --- a/core/constraint-streams/src/main/java/ai/timefold/solver/constraint/streams/bavet/BavetConstraintStreamScoreDirectorFactoryService.java +++ b/core/constraint-streams/src/main/java/ai/timefold/solver/constraint/streams/bavet/BavetConstraintStreamScoreDirectorFactoryService.java @@ -14,6 +14,7 @@ import ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig; import ai.timefold.solver.core.config.solver.EnvironmentMode; import ai.timefold.solver.core.config.util.ConfigUtils; +import ai.timefold.solver.core.enterprise.TimefoldSolverEnterpriseService; import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; import ai.timefold.solver.core.impl.score.director.AbstractScoreDirectorFactory; import ai.timefold.solver.core.impl.score.director.ScoreDirectorType; @@ -41,9 +42,19 @@ public Supplier> buildScoreDirec "The constraintProviderClass (" + config.getConstraintProviderClass() + ") does not implement " + ConstraintProvider.class.getSimpleName() + "."); } + final Class constraintProviderClass; + if (Boolean.TRUE.equals(config.getConstraintStreamAutomaticNodeSharing())) { + TimefoldSolverEnterpriseService enterpriseService = + TimefoldSolverEnterpriseService + .loadOrFail(TimefoldSolverEnterpriseService.Feature.AUTOMATIC_NODE_SHARING); + constraintProviderClass = + enterpriseService.buildLambdaSharedConstraintProvider(config.getConstraintProviderClass()); + } else { + constraintProviderClass = config.getConstraintProviderClass(); + } return () -> { ConstraintProvider constraintProvider = ConfigUtils.newInstance(config, - "constraintProviderClass", config.getConstraintProviderClass()); + "constraintProviderClass", constraintProviderClass); ConfigUtils.applyCustomProperties(constraintProvider, "constraintProviderClass", config.getConstraintProviderCustomProperties(), "constraintProviderCustomProperties"); return buildScoreDirectorFactory(solutionDescriptor, constraintProvider, environmentMode); diff --git a/core/core-impl/src/build/revapi-differences.json b/core/core-impl/src/build/revapi-differences.json index 3013dd35f4..b6872627ec 100644 --- a/core/core-impl/src/build/revapi-differences.json +++ b/core/core-impl/src/build/revapi-differences.json @@ -69,8 +69,19 @@ "new": "class ai.timefold.solver.core.config.heuristic.selector.move.generic.list.kopt.KOptListMoveSelectorConfig", "superClass": "ai.timefold.solver.core.config.heuristic.selector.move.NearbyAutoConfigurationMoveSelectorConfig", "justification": "Adding support for Nearby Selection autoconfiguration" + }, + { + "ignore": true, + "code": "java.annotation.attributeValueChanged", + "old": "class ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig", + "new": "class ai.timefold.solver.core.config.score.director.ScoreDirectorFactoryConfig", + "annotationType": "jakarta.xml.bind.annotation.XmlType", + "attribute": "propOrder", + "oldValue": "{\"easyScoreCalculatorClass\", \"easyScoreCalculatorCustomProperties\", \"constraintProviderClass\", \"constraintProviderCustomProperties\", \"constraintStreamImplType\", \"incrementalScoreCalculatorClass\", \"incrementalScoreCalculatorCustomProperties\", \"scoreDrlList\", \"initializingScoreTrend\", \"assertionScoreDirectorFactory\"}", + "newValue": "{\"easyScoreCalculatorClass\", \"easyScoreCalculatorCustomProperties\", \"constraintProviderClass\", \"constraintProviderCustomProperties\", \"constraintStreamImplType\", \"constraintStreamAutomaticNodeSharing\", \"incrementalScoreCalculatorClass\", \"incrementalScoreCalculatorCustomProperties\", \"scoreDrlList\", \"initializingScoreTrend\", \"assertionScoreDirectorFactory\"}", + "justification": "Add support for automatic constraint stream node sharing outside of Quarkus for Timefold Solver Enterprise edition users." } ] } } -] \ No newline at end of file +] diff --git a/core/core-impl/src/main/java/ai/timefold/solver/core/config/score/director/ScoreDirectorFactoryConfig.java b/core/core-impl/src/main/java/ai/timefold/solver/core/config/score/director/ScoreDirectorFactoryConfig.java index fbac8d1e59..4c161d6d02 100644 --- a/core/core-impl/src/main/java/ai/timefold/solver/core/config/score/director/ScoreDirectorFactoryConfig.java +++ b/core/core-impl/src/main/java/ai/timefold/solver/core/config/score/director/ScoreDirectorFactoryConfig.java @@ -23,6 +23,7 @@ "constraintProviderClass", "constraintProviderCustomProperties", "constraintStreamImplType", + "constraintStreamAutomaticNodeSharing", "incrementalScoreCalculatorClass", "incrementalScoreCalculatorCustomProperties", "scoreDrlList", @@ -41,6 +42,7 @@ public class ScoreDirectorFactoryConfig extends AbstractConfig constraintProviderCustomProperties = null; protected ConstraintStreamImplType constraintStreamImplType; + protected Boolean constraintStreamAutomaticNodeSharing; protected Class incrementalScoreCalculatorClass = null; @@ -101,6 +103,14 @@ public void setConstraintStreamImplType(ConstraintStreamImplType constraintStrea this.constraintStreamImplType = constraintStreamImplType; } + public Boolean getConstraintStreamAutomaticNodeSharing() { + return constraintStreamAutomaticNodeSharing; + } + + public void setConstraintStreamAutomaticNodeSharing(Boolean constraintStreamAutomaticNodeSharing) { + this.constraintStreamAutomaticNodeSharing = constraintStreamAutomaticNodeSharing; + } + public Class getIncrementalScoreCalculatorClass() { return incrementalScoreCalculatorClass; } @@ -187,6 +197,11 @@ public ScoreDirectorFactoryConfig withConstraintStreamImplType(ConstraintStreamI return this; } + public ScoreDirectorFactoryConfig withConstraintStreamAutomaticNodeSharing(Boolean constraintStreamAutomaticNodeSharing) { + this.constraintStreamAutomaticNodeSharing = constraintStreamAutomaticNodeSharing; + return this; + } + public ScoreDirectorFactoryConfig withIncrementalScoreCalculatorClass(Class incrementalScoreCalculatorClass) { this.incrementalScoreCalculatorClass = incrementalScoreCalculatorClass; @@ -246,6 +261,8 @@ public ScoreDirectorFactoryConfig inherit(ScoreDirectorFactoryConfig inheritedCo constraintProviderCustomProperties, inheritedConfig.getConstraintProviderCustomProperties()); constraintStreamImplType = ConfigUtils.inheritOverwritableProperty( constraintStreamImplType, inheritedConfig.getConstraintStreamImplType()); + constraintStreamAutomaticNodeSharing = ConfigUtils.inheritOverwritableProperty(constraintStreamAutomaticNodeSharing, + inheritedConfig.getConstraintStreamAutomaticNodeSharing()); incrementalScoreCalculatorClass = ConfigUtils.inheritOverwritableProperty( incrementalScoreCalculatorClass, inheritedConfig.getIncrementalScoreCalculatorClass()); incrementalScoreCalculatorCustomProperties = ConfigUtils.inheritMergeableMapProperty( diff --git a/core/core-impl/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java b/core/core-impl/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java index 29a4b8201b..7896a017b3 100644 --- a/core/core-impl/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java +++ b/core/core-impl/src/main/java/ai/timefold/solver/core/enterprise/TimefoldSolverEnterpriseService.java @@ -3,6 +3,7 @@ import java.util.ServiceLoader; import java.util.function.BiFunction; +import ai.timefold.solver.core.api.score.stream.ConstraintProvider; import ai.timefold.solver.core.api.solver.SolverFactory; import ai.timefold.solver.core.config.heuristic.selector.common.SelectionCacheType; import ai.timefold.solver.core.config.heuristic.selector.common.SelectionOrder; @@ -57,6 +58,9 @@ static TimefoldSolverEnterpriseService loadOrFail(Feature feature) { return service; } + Class + buildLambdaSharedConstraintProvider(Class originalConstraintProvider); + ConstructionHeuristicDecider buildConstructionHeuristic(int moveThreadCount, Termination termination, ConstructionHeuristicForager forager, EnvironmentMode environmentMode, HeuristicConfigPolicy configPolicy); @@ -90,7 +94,9 @@ DestinationSelector applyNearbySelection(DestinationSelec enum Feature { MULTITHREADED_SOLVING("Multi-threaded solving", "remove moveThreadCount from solver configuration"), PARTITIONED_SEARCH("Partitioned search", "remove partitioned search phase from solver configuration"), - NEARBY_SELECTION("Nearby selection", "remove nearby selection from solver configuration"); + NEARBY_SELECTION("Nearby selection", "remove nearby selection from solver configuration"), + AUTOMATIC_NODE_SHARING("Automatic node sharing", + "remove automatic node sharing from solver configuration"); private final String name; private final String workaround; diff --git a/core/core-impl/src/main/resources/solver.xsd b/core/core-impl/src/main/resources/solver.xsd index fc2058d7f1..99d3b5169e 100644 --- a/core/core-impl/src/main/resources/solver.xsd +++ b/core/core-impl/src/main/resources/solver.xsd @@ -107,6 +107,8 @@ + + diff --git a/docs/src/modules/ROOT/pages/enterprise-edition/enterprise-edition.adoc b/docs/src/modules/ROOT/pages/enterprise-edition/enterprise-edition.adoc index ca19a17c4c..7bd4bc156c 100644 --- a/docs/src/modules/ROOT/pages/enterprise-edition/enterprise-edition.adoc +++ b/docs/src/modules/ROOT/pages/enterprise-edition/enterprise-edition.adoc @@ -89,7 +89,8 @@ The following features are only available in Timefold Solver Enterprise Edition: * <>, * <>, -* and <>. +* <>, +* and <>. [#nearbySelection] @@ -759,5 +760,194 @@ the host is likely to hang or freeze, unless there is an OS specific policy in place to avoid Timefold Solver from hogging all the CPU processors. ==== +[#automaticNodeSharing] +=== Automatic node sharing +[NOTE] +==== +This feature is a commercial feature of Timefold Solver Enterprise Edition. +It is not available in the Community Edition. +==== + +When a `ConstraintProvider` does an operation for multiple constraints (such as finding all shifts corresponding to an employee), that work can be shared. +This can significantly improve score calculation speed if the repeated operation is computationally expensive. + +==== Configuration + +[tabs] +====== +Plain Java:: + +* Add `true` in your `solverConfig.xml`: ++ +[source,xml,options="nowrap"] +---- + + + org.acme.MyConstraintProvider + true + + +---- + +Spring Boot:: + +Set the property `timefold.solver.constraint-stream-automatic-node-sharing` to `true` in `application.properties`: ++ +[source,properties,options="nowrap"] +---- +timefold.solver.constraint-stream-automatic-node=true +---- + +Quarkus:: + +Set the property `quarkus.timefold.solver.constraint-stream-automatic-node-sharing` to `true` in `application.properties`: ++ +[source,properties,options="nowrap"] +---- +quarkus.timefold.solver.constraint-stream-automatic-node-sharing=true +---- +====== + +[IMPORTANT] +==== +Debugging breakpoints put inside your constraints will not be respected, because the `ConstraintProvider` class will be transformed when this feature is enabled. +==== + +==== What is node sharing? + +When using xref:constraints-and-score/score-calculation.adoc#constraintStreams[constraint streams], each xref:constraints-and-score/score-calculation.adoc#constraintStreamsBuildingBlocks[building block] forms a node in the score calculation network. +When two building blocks are functionally equivalent, they can share the same node in the network. +Sharing nodes allows the operation to be performed only once instead of multiple times, improving the performance of the solver. +To be functionally equivalent, the following must be true: + +* The building blocks must represent the same operation. + +* The building blocks must have functionally equivalent parent building blocks. + +* The building blocks must have functionally equivalent inputs. + +For example, the building blocks below are functionally equivalent: + +[source,java,options="nowrap"] +---- +Predicate predicate = shift -> shift.getEmployee().getName().equals("Ann"); + +var a = factory.forEach(Shift.class) + .filter(predicate); + +var b = factory.forEach(Shift.class) + .filter(predicate); +---- + +Whereas these building blocks are not functionally equivalent: + +[source,java,options="nowrap"] +---- +Predicate predicate1 = shift -> shift.getEmployee().getName().equals("Ann"); +Predicate predicate2 = shift -> shift.getEmployee().getName().equals("Bob"); + +// Different parents +var a = factory.forEach(Shift.class) + .filter(predicate2); + +var b = factory.forEach(Shift.class) + .filter(predicate1) + .filter(predicate2); + +// Different operations +var a = factory.forEach(Shift.class) + .ifExists(Employee.class); + +var b = factory.forEach(Shift.class) + .ifNotExists(Employee.class); + +// Different inputs +var a = factory.forEach(Shift.class) + .filter(predicate1); + +var b = factory.forEach(Shift.class) + .filter(predicate2); +---- + +Counterintuitively, the building blocks produced by these (seemly) identical methods are not necessarily functionally equivalent: + +[source,java,options="nowrap"] +---- +UniConstraintStream a(ConstraintFactory constraintFactory) { + return factory.forEach(Shift.class) + .filter(shift -> shift.getEmployee().getName().equals("Ann")); +} + +UniConstraintStream b(ConstraintFactory constraintFactory) { + return factory.forEach(Shift.class) + .filter(shift -> shift.getEmployee().getName().equals("Ann")); +} +---- + +The Java Virtual Machine is free to (and often does) create different instances of functionally equivalent lambdas. +This severely limits the effectiveness of node sharing, since the only way to know two lambdas are equal is to compare their references. + +When automatic node sharing is used, the `ConstraintProvider` class is transformed so all lambdas are accessed via a static final field. +Consider the following input class: + +[source,java,options="nowrap"] +---- +public class MyConstraintProvider implements ConstraintProvider { + + public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { + return new Constraint[] { + a(constraintFactory), + b(constraintFactory) + }; + } + + Constraint a(ConstraintFactory constraintFactory) { + return factory.forEach(Shift.class) + .filter(shift -> shift.getEmployee().getName().equals("Ann")) + .penalize(SimpleScore.ONE) + .asConstraint("a"); + } + + Constraint b(ConstraintFactory constraintFactory) { + return factory.forEach(Shift.class) + .filter(shift -> shift.getEmployee().getName().equals("Ann")) + .penalize(SimpleScore.ONE) + .asConstraint("b"); + } +} +---- + +When automatic node sharing is enabled, the class will be transformed to look like this: + +[source,java,options="nowrap"] +---- +public class MyConstraintProvider implements ConstraintProvider { + private static final Predicate $predicate1 = shift -> shift.getEmployee().getName().equals("Ann"); + + public Constraint[] defineConstraints(ConstraintFactory constraintFactory) { + return new Constraint[] { + a(constraintFactory), + b(constraintFactory) + }; + } + + Constraint a(ConstraintFactory constraintFactory) { + return factory.forEach(Shift.class) + .filter($predicate1) + .penalize(SimpleScore.ONE) + .asConstraint("a"); + } + + Constraint b(ConstraintFactory constraintFactory) { + return factory.forEach(Shift.class) + .filter($predicate1) + .penalize(SimpleScore.ONE) + .asConstraint("b"); + } +} +---- + +This transformation means that debugging breakpoints placed inside the original `ConstraintProvider` will not be honored in the transformed `ConstraintProvider`. +From the above, you can see how this feature allows building blocks to share functionally equivalent parents, without needing the `ConstraintProvider` to be written in an awkward way. diff --git a/quarkus-integration/quarkus-benchmark/deployment/src/main/java/ai/timefold/solver/benchmark/quarkus/deployment/TimefoldBenchmarkProcessor.java b/quarkus-integration/quarkus-benchmark/deployment/src/main/java/ai/timefold/solver/benchmark/quarkus/deployment/TimefoldBenchmarkProcessor.java index 1696d29aba..12bc693912 100644 --- a/quarkus-integration/quarkus-benchmark/deployment/src/main/java/ai/timefold/solver/benchmark/quarkus/deployment/TimefoldBenchmarkProcessor.java +++ b/quarkus-integration/quarkus-benchmark/deployment/src/main/java/ai/timefold/solver/benchmark/quarkus/deployment/TimefoldBenchmarkProcessor.java @@ -44,7 +44,7 @@ BenchmarkConfigBuildItem registerAdditionalBeans(BuildProducer unremovableBeans, SolverConfigBuildItem solverConfigBuildItem) { // We don't support benchmarking for multiple solvers - if (solverConfigBuildItem.getSolvetConfigMap().size() > 1) { + if (solverConfigBuildItem.getSolverConfigMap().size() > 1) { throw new ConfigurationException(""" When defining multiple solvers, the benchmark feature is not enabled. Consider using separate instances for evaluating different solver configurations."""); diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/SolverConfigBuildItem.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/SolverConfigBuildItem.java index 9a33b5aad3..281646b80d 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/SolverConfigBuildItem.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/SolverConfigBuildItem.java @@ -1,11 +1,8 @@ package ai.timefold.solver.quarkus.deployment; import java.util.Map; -import java.util.Objects; -import java.util.Set; import ai.timefold.solver.core.config.solver.SolverConfig; -import ai.timefold.solver.quarkus.deployment.config.TimefoldBuildTimeConfig; import io.quarkus.builder.item.SimpleBuildItem; @@ -21,28 +18,7 @@ public SolverConfigBuildItem(Map solverConfig, GeneratedGi this.generatedGizmoClasses = generatedGizmoClasses; } - /** - * Returns the configuration of a given solver name. - * - * @param solverName never null, the solver name - */ - public SolverConfig getSolverConfig(String solverName) { - return this.solverConfigurations - .get(Objects.requireNonNull(solverName, "Invalid solverName (null) given to SolverConfigBuildItem.")); - } - - /** - * Returns the configuration of the default solver. - */ - public SolverConfig getSolverConfig() { - return this.solverConfigurations.get(TimefoldBuildTimeConfig.DEFAULT_SOLVER_NAME); - } - - public Set getSolverNames() { - return this.solverConfigurations.keySet(); - } - - public Map getSolvetConfigMap() { + public Map getSolverConfigMap() { return solverConfigurations; } diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java index 438d4d84bd..13e6d4a211 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/TimefoldProcessor.java @@ -575,7 +575,7 @@ void recordAndRegisterRuntimeBeans(TimefoldRecorder recorder, RecorderContext re // Using the same name for synthetic beans is impossible, even if they are different types. Therefore, we allow // only the injection of SolverManager, except for the default solver, which can inject all resources to be // retro-compatible. - solverConfigBuildItem.getSolvetConfigMap().forEach((key, value) -> { + solverConfigBuildItem.getSolverConfigMap().forEach((key, value) -> { if (timefoldBuildTimeConfig.isDefaultSolverConfig(key)) { // The two configuration resources are required for DefaultTimefoldBeanProvider produce all available // managed beans for the default solver @@ -635,7 +635,7 @@ public void recordAndRegisterDevUIBean( BuildProducer syntheticBeans) { syntheticBeans.produce(SyntheticBeanBuildItem.configure(DevUISolverConfig.class) .scope(ApplicationScoped.class) - .supplier(devUIRecorder.solverConfigSupplier(solverConfigBuildItem.getSolvetConfigMap(), runtimeConfig, + .supplier(devUIRecorder.solverConfigSupplier(solverConfigBuildItem.getSolverConfigMap(), runtimeConfig, GizmoMemberAccessorEntityEnhancer.getGeneratedGizmoMemberAccessorMap(recorderContext, solverConfigBuildItem .getGeneratedGizmoClasses().generatedGizmoMemberAccessorClassSet), diff --git a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/config/SolverBuildTimeConfig.java b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/config/SolverBuildTimeConfig.java index 29c6b4f33a..cb44d06dfc 100644 --- a/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/config/SolverBuildTimeConfig.java +++ b/quarkus-integration/quarkus/deployment/src/main/java/ai/timefold/solver/quarkus/deployment/config/SolverBuildTimeConfig.java @@ -48,6 +48,9 @@ public interface SolverBuildTimeConfig { Optional domainAccessType(); /** + * Note: this setting is only available + * for Timefold Solver + * Enterprise Edition. * Enable multithreaded solving for a single problem, which increases CPU consumption. * Defaults to {@value SolverConfig#MOVE_THREAD_COUNT_NONE}. * Other options include {@value SolverConfig#MOVE_THREAD_COUNT_AUTO}, a number @@ -73,4 +76,15 @@ public interface SolverBuildTimeConfig { @Deprecated(forRemoval = true, since = "1.4.0") Optional constraintStreamImplType(); + /** + * Note: this setting is only available + * for Timefold Solver + * Enterprise Edition. + * Enable rewriting the {@link ai.timefold.solver.core.api.score.stream.ConstraintProvider} class + * so nodes share lambdas when possible, improving performance. + * When enabled, breakpoints placed in the {@link ai.timefold.solver.core.api.score.stream.ConstraintProvider} + * will no longer be triggered. + * Defaults to "false". + */ + Optional constraintStreamAutomaticNodeSharing(); } diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java index e882c1ad23..70c5b93984 100644 --- a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfiguration.java @@ -61,6 +61,7 @@ import org.springframework.context.ApplicationContextAware; import org.springframework.context.EnvironmentAware; import org.springframework.context.annotation.Configuration; +import org.springframework.core.NativeDetector; import org.springframework.core.env.Environment; @Configuration(proxyBeanMethods = false) @@ -250,29 +251,42 @@ private void loadSolverConfig(IncludeAbstractClassesEntityScanner entityScanner, .collect(joining(", ")))); } } + timefoldProperties.getSolverConfig(solverName) + .ifPresentOrElse( + solverProperties -> applyScoreDirectorFactoryProperties(entityScanner, solverConfig, solverProperties), + () -> applyScoreDirectorFactoryProperties(entityScanner, solverConfig)); + } + + private void applyScoreDirectorFactoryProperties(IncludeAbstractClassesEntityScanner entityScanner, + SolverConfig solverConfig, SolverProperties solverProperties) { applyScoreDirectorFactoryProperties(entityScanner, solverConfig); - Optional solverProperties = timefoldProperties.getSolverConfig(solverName); - if (solverProperties.isPresent()) { - if (solverProperties.get().getEnvironmentMode() != null) { - solverConfig.setEnvironmentMode(solverProperties.get().getEnvironmentMode()); - } - if (solverProperties.get().getDomainAccessType() != null) { - solverConfig.setDomainAccessType(solverProperties.get().getDomainAccessType()); - } - if (solverProperties.get().getNearbyDistanceMeterClass() != null) { - solverConfig.setNearbyDistanceMeterClass(solverProperties.get().getNearbyDistanceMeterClass()); - } - if (solverProperties.get().getDaemon() != null) { - solverConfig.setDaemon(solverProperties.get().getDaemon()); - } - if (solverProperties.get().getMoveThreadCount() != null) { - solverConfig.setMoveThreadCount(solverProperties.get().getMoveThreadCount()); + if (solverProperties.getConstraintStreamAutomaticNodeSharing() != null + && solverProperties.getConstraintStreamAutomaticNodeSharing()) { + if (NativeDetector.inNativeImage()) { + throw new UnsupportedOperationException( + "Constraint stream automatic node sharing is unsupported in a Spring native image."); } - applyTerminationProperties(solverConfig, solverProperties.get().getTermination()); + solverConfig.getScoreDirectorFactoryConfig().setConstraintStreamAutomaticNodeSharing(true); + } + if (solverProperties.getEnvironmentMode() != null) { + solverConfig.setEnvironmentMode(solverProperties.getEnvironmentMode()); + } + if (solverProperties.getDomainAccessType() != null) { + solverConfig.setDomainAccessType(solverProperties.getDomainAccessType()); + } + if (solverProperties.getNearbyDistanceMeterClass() != null) { + solverConfig.setNearbyDistanceMeterClass(solverProperties.getNearbyDistanceMeterClass()); + } + if (solverProperties.getDaemon() != null) { + solverConfig.setDaemon(solverProperties.getDaemon()); + } + if (solverProperties.getMoveThreadCount() != null) { + solverConfig.setMoveThreadCount(solverProperties.getMoveThreadCount()); } + applyTerminationProperties(solverConfig, solverProperties.getTermination()); } - protected void applyScoreDirectorFactoryProperties(IncludeAbstractClassesEntityScanner entityScanner, + private void applyScoreDirectorFactoryProperties(IncludeAbstractClassesEntityScanner entityScanner, SolverConfig solverConfig) { if (solverConfig.getScoreDirectorFactoryConfig() == null) { solverConfig.setScoreDirectorFactoryConfig(defaultScoreDirectoryFactoryConfig(entityScanner)); diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperties.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperties.java index 598289bb18..186d3b7f98 100644 --- a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperties.java +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperties.java @@ -1,9 +1,7 @@ package ai.timefold.solver.spring.boot.autoconfigure.config; -import static java.util.stream.Collectors.joining; - import java.util.Map; -import java.util.Set; +import java.util.TreeSet; import ai.timefold.solver.core.api.domain.common.DomainAccessType; import ai.timefold.solver.core.api.score.stream.ConstraintStreamImplType; @@ -13,21 +11,6 @@ import org.springframework.boot.context.properties.NestedConfigurationProperty; public class SolverProperties { - - public static final String SOLVER_CONFIG_XML_PROPERTY_NAME = "solver-config-xml"; - public static final String ENVIRONMENT_MODE_PROPERTY_NAME = "environment-mode"; - public static final String DAEMON_PROPERTY_NAME = "daemon"; - public static final String MOVE_THREAD_COUNT_PROPERTY_NAME = "move-thread-count"; - public static final String DOMAIN_ACCESS_TYPE_PROPERTY_NAME = "domain-access-type"; - public static final String NEARBY_DISTANCE_METER_CLASS_PROPERTY_NAME = "nearby-distance-meter-class"; - public static final String CONSTRAINT_STREAM_IMPL_TYPE_PROPERTY_NAME = "constraint-stream-impl-type"; - public static final String TERMINATION_PROPERTY_NAME = "termination"; - public static final Set VALID_FIELD_NAMES_SET = - Set.of(SOLVER_CONFIG_XML_PROPERTY_NAME, ENVIRONMENT_MODE_PROPERTY_NAME, DAEMON_PROPERTY_NAME, - MOVE_THREAD_COUNT_PROPERTY_NAME, DOMAIN_ACCESS_TYPE_PROPERTY_NAME, - NEARBY_DISTANCE_METER_CLASS_PROPERTY_NAME, CONSTRAINT_STREAM_IMPL_TYPE_PROPERTY_NAME, - TERMINATION_PROPERTY_NAME); - /** * A classpath resource to read the specific solver configuration XML. * If this property isn't specified, that solverConfig.xml is optional. @@ -46,7 +29,11 @@ public class SolverProperties { * Defaults to "false". */ private Boolean daemon; + /** + * Note: this setting is only available + * for Timefold Solver + * Enterprise Edition. * Enable multithreaded solving for a single problem, which increases CPU consumption. * Defaults to "NONE". * Other options include "AUTO", a number or formula based on the available processor count. @@ -75,6 +62,18 @@ public class SolverProperties { @Deprecated(forRemoval = true, since = "1.4.0") private ConstraintStreamImplType constraintStreamImplType; + /** + * Note: this setting is only available + * for Timefold Solver + * Enterprise Edition. + * Enable rewriting the {@link ai.timefold.solver.core.api.score.stream.ConstraintProvider} class + * so nodes share lambdas when possible, improving performance. + * When enabled, breakpoints placed in the {@link ai.timefold.solver.core.api.score.stream.ConstraintProvider} + * will no longer be triggered. + * Defaults to "false". + */ + private Boolean constraintStreamAutomaticNodeSharing; + @NestedConfigurationProperty private TerminationProperties termination; @@ -146,6 +145,14 @@ public void setConstraintStreamImplType(ConstraintStreamImplType constraintStrea this.constraintStreamImplType = constraintStreamImplType; } + public Boolean getConstraintStreamAutomaticNodeSharing() { + return constraintStreamAutomaticNodeSharing; + } + + public void setConstraintStreamAutomaticNodeSharing(Boolean constraintStreamAutomaticNodeSharing) { + this.constraintStreamAutomaticNodeSharing = constraintStreamAutomaticNodeSharing; + } + public TerminationProperties getTermination() { return termination; } @@ -156,17 +163,15 @@ public void setTermination(TerminationProperties termination) { public void loadProperties(Map properties) { // Check if the keys are valid - String invalidKeys = properties.entrySet().stream() - .filter(e -> !VALID_FIELD_NAMES_SET.contains(e.getKey())) - .map(Map.Entry::getKey) - .collect(joining(", ")); + var invalidKeySet = new TreeSet<>(properties.keySet()); + invalidKeySet.removeAll(SolverProperty.getValidPropertyNames()); - if (!invalidKeys.isBlank()) { + if (!invalidKeySet.isEmpty()) { throw new IllegalStateException(""" The properties [%s] are not valid. Maybe try changing the property name to kebab-case. Here is the list of valid properties: %s""" - .formatted(invalidKeys, String.join(", ", VALID_FIELD_NAMES_SET))); + .formatted(invalidKeySet, String.join(", ", SolverProperty.getValidPropertyNames()))); } properties.forEach(this::loadProperty); } @@ -175,56 +180,8 @@ private void loadProperty(String key, Object value) { if (value == null) { return; } - switch (key) { - case SOLVER_CONFIG_XML_PROPERTY_NAME: - setSolverConfigXml(value.toString()); - break; - case ENVIRONMENT_MODE_PROPERTY_NAME: - setEnvironmentMode(EnvironmentMode.valueOf((String) value)); - break; - case DAEMON_PROPERTY_NAME: - setDaemon(Boolean.parseBoolean(value.toString())); - break; - case MOVE_THREAD_COUNT_PROPERTY_NAME: - setMoveThreadCount(value.toString()); - break; - case DOMAIN_ACCESS_TYPE_PROPERTY_NAME: - setDomainAccessType(DomainAccessType.valueOf((String) value)); - break; - case NEARBY_DISTANCE_METER_CLASS_PROPERTY_NAME: - try { - Class nearbyClass = Class.forName(value.toString(), false, - Thread.currentThread().getContextClassLoader()); - - if (!NearbyDistanceMeter.class.isAssignableFrom(nearbyClass)) { - throw new IllegalStateException( - "The Nearby Selection Meter class (%s) does not implement NearbyDistanceMeter." - .formatted(value.toString())); - } - setNearbyDistanceMeterClass((Class>) nearbyClass); - } catch (ClassNotFoundException e) { - throw new IllegalStateException( - "Cannot find the Nearby Selection Meter class (%s).".formatted(value.toString())); - } - break; - case CONSTRAINT_STREAM_IMPL_TYPE_PROPERTY_NAME: - setConstraintStreamImplType(ConstraintStreamImplType.valueOf((String) value)); - break; - case TERMINATION_PROPERTY_NAME: { - if (value instanceof TerminationProperties terminationProperties) { - setTermination(terminationProperties); - } else if (value instanceof Map) { - TerminationProperties terminationProperties = new TerminationProperties(); - terminationProperties.loadProperties((Map) value); - setTermination(terminationProperties); - } else { - throw new IllegalStateException("The termination value is not valid."); - } - break; - } - default: - throw new IllegalStateException("The property %s is not valid.".formatted(key)); - } + SolverProperty property = SolverProperty.forPropertyName(key); + property.update(this, value); } } diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperty.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperty.java new file mode 100644 index 0000000000..32547d3b2b --- /dev/null +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/SolverProperty.java @@ -0,0 +1,99 @@ +package ai.timefold.solver.spring.boot.autoconfigure.config; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import ai.timefold.solver.core.api.domain.common.DomainAccessType; +import ai.timefold.solver.core.api.score.stream.ConstraintStreamImplType; +import ai.timefold.solver.core.config.solver.EnvironmentMode; +import ai.timefold.solver.core.impl.heuristic.selector.common.nearby.NearbyDistanceMeter; + +public enum SolverProperty { + SOLVER_CONFIG_XML("solver-config-xml", SolverProperties::setSolverConfigXml, Object::toString), + ENVIRONMENT_MODE("environment-mode", SolverProperties::setEnvironmentMode, + value -> EnvironmentMode.valueOf(value.toString())), + DAEMON("daemon", SolverProperties::setDaemon, value -> Boolean.valueOf(value.toString())), + MOVE_THREAD_COUNT("move-thread-count", SolverProperties::setMoveThreadCount, Object::toString), + DOMAIN_ACCESS_TYPE("domain-access-type", SolverProperties::setDomainAccessType, + value -> DomainAccessType.valueOf(value.toString())), + NEARBY_DISTANCE_METER_CLASS("nearby-distance-meter-class", SolverProperties::setNearbyDistanceMeterClass, + value -> { + try { + @SuppressWarnings("rawtypes") + Class nearbyClass = Class.forName(value.toString(), false, + Thread.currentThread().getContextClassLoader()); + + if (!NearbyDistanceMeter.class.isAssignableFrom(nearbyClass)) { + throw new IllegalStateException( + "The Nearby Selection Meter class (%s) does not implement NearbyDistanceMeter." + .formatted(value.toString())); + } + return nearbyClass; + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "Cannot find the Nearby Selection Meter class (%s).".formatted(value.toString())); + } + }), + /** + * @deprecated No longer used. + */ + @Deprecated(forRemoval = true, since = "1.4.0") + CONSTRAINT_STREAM_IMPL_TYPE("constraint-stream-impl-type", SolverProperties::setConstraintStreamImplType, + value -> ConstraintStreamImplType.valueOf(value.toString())), + CONSTRAINT_STREAM_AUTOMATIC_NODE_SHARING("constraint-stream-automatic-node-sharing", + SolverProperties::setConstraintStreamAutomaticNodeSharing, value -> Boolean.valueOf(value.toString())), + TERMINATION("termination", SolverProperties::setTermination, value -> { + if (value instanceof TerminationProperties terminationProperties) { + return terminationProperties; + } else if (value instanceof Map map) { + TerminationProperties terminationProperties = new TerminationProperties(); + terminationProperties.loadProperties((Map) map); + return terminationProperties; + } else { + throw new IllegalStateException( + "The termination value (%s) is not valid. Expected an instance of %s or %s, but got an instance of %s." + .formatted(value, Map.class.getSimpleName(), TerminationProperties.class.getSimpleName(), + value.getClass().getName())); + } + }); + + private final String propertyName; + private final BiConsumer propertyUpdater; + private static final Set PROPERTY_NAMES = Stream.of(SolverProperty.values()) + .map(SolverProperty::getPropertyName) + .collect(Collectors.toCollection(TreeSet::new)); + + SolverProperty(String propertyName, BiConsumer propertySetter, + Function propertyConvertor) { + this.propertyName = propertyName; + this.propertyUpdater = (properties, object) -> propertySetter.accept(properties, propertyConvertor.apply(object)); + } + + public String getPropertyName() { + return propertyName; + } + + public void update(SolverProperties properties, Object value) { + propertyUpdater.accept(properties, value); + } + + public static Set getValidPropertyNames() { + return Collections.unmodifiableSet(PROPERTY_NAMES); + } + + public static SolverProperty forPropertyName(String propertyName) { + for (var property : values()) { + if (property.getPropertyName().equals(propertyName)) { + return property; + } + } + throw new IllegalArgumentException("No property with the name (%s). Valid properties are %s." + .formatted(propertyName, PROPERTY_NAMES)); + } +} diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TerminationProperties.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TerminationProperties.java index 199197bb94..de437ad5a5 100644 --- a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TerminationProperties.java +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TerminationProperties.java @@ -1,21 +1,11 @@ package ai.timefold.solver.spring.boot.autoconfigure.config; -import static java.util.stream.Collectors.joining; - import java.time.Duration; import java.util.Map; -import java.util.Set; - -import org.springframework.boot.convert.DurationStyle; +import java.util.TreeSet; public class TerminationProperties { - private static final String SPENT_LIMIT_PROERTY_NAME = "spent-limit"; - private static final String UNIMPROVED_SPENT_LIMIT_PROERTY_NAME = "unimproved-spent-limit"; - private static final String BEST_SCORE_LIMIT_PROERTY_NAME = "best-score-limit"; - private static final Set VALID_FIELD_NAMES_SET = - Set.of(SPENT_LIMIT_PROERTY_NAME, UNIMPROVED_SPENT_LIMIT_PROERTY_NAME, BEST_SCORE_LIMIT_PROERTY_NAME); - /** * How long the solver can run. * For example: "30s" is 30 seconds. "5m" is 5 minutes. "2h" is 2 hours. "1d" is 1 day. @@ -66,17 +56,15 @@ public void setBestScoreLimit(String bestScoreLimit) { public void loadProperties(Map properties) { // Check if the keys are valid - String invalidKeys = properties.entrySet().stream() - .filter(e -> !VALID_FIELD_NAMES_SET.contains(e.getKey())) - .map(Map.Entry::getKey) - .collect(joining(", ")); + var invalidKeySet = new TreeSet<>(properties.keySet()); + invalidKeySet.removeAll(TerminationProperty.getValidPropertyNames()); - if (!invalidKeys.isBlank()) { + if (!invalidKeySet.isEmpty()) { throw new IllegalStateException(""" The termination properties [%s] are not valid. Maybe try changing the property name to kebab-case. Here is the list of valid properties: %s""" - .formatted(invalidKeys, String.join(", ", VALID_FIELD_NAMES_SET))); + .formatted(invalidKeySet, String.join(", ", TerminationProperty.getValidPropertyNames()))); } properties.forEach(this::loadProperty); } @@ -85,19 +73,8 @@ private void loadProperty(String key, Object value) { if (value == null) { return; } - switch (key) { - case SPENT_LIMIT_PROERTY_NAME: - setSpentLimit(DurationStyle.detectAndParse((String) value)); - break; - case UNIMPROVED_SPENT_LIMIT_PROERTY_NAME: - setUnimprovedSpentLimit(DurationStyle.detectAndParse((String) value)); - break; - case BEST_SCORE_LIMIT_PROERTY_NAME: - setBestScoreLimit(value.toString()); - break; - default: - throw new IllegalStateException("The property %s is not valid.".formatted(key)); - } + var property = TerminationProperty.forPropertyName(key); + property.update(this, value); } } diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TerminationProperty.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TerminationProperty.java new file mode 100644 index 0000000000..0f7fb439de --- /dev/null +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TerminationProperty.java @@ -0,0 +1,53 @@ +package ai.timefold.solver.spring.boot.autoconfigure.config; + +import java.util.Collections; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.BiConsumer; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import org.springframework.boot.convert.DurationStyle; + +public enum TerminationProperty { + SPENT_LIMIT("spent-limit", TerminationProperties::setSpentLimit, + value -> DurationStyle.detectAndParse((String) value)), + UNIMPROVED_SPENT_LIMIT("unimproved-spent-limit", TerminationProperties::setUnimprovedSpentLimit, + value -> DurationStyle.detectAndParse((String) value)), + BEST_SCORE_LIMIT("best-score-limit", TerminationProperties::setBestScoreLimit, Object::toString); + + private final String propertyName; + private final BiConsumer propertyUpdater; + private static final Set PROPERTY_NAMES = Stream.of(TerminationProperty.values()) + .map(TerminationProperty::getPropertyName) + .collect(Collectors.toCollection(TreeSet::new)); + + TerminationProperty(String propertyName, BiConsumer propertySetter, + Function propertyConvertor) { + this.propertyName = propertyName; + this.propertyUpdater = (properties, object) -> propertySetter.accept(properties, propertyConvertor.apply(object)); + } + + public String getPropertyName() { + return propertyName; + } + + public void update(TerminationProperties properties, Object value) { + propertyUpdater.accept(properties, value); + } + + public static Set getValidPropertyNames() { + return Collections.unmodifiableSet(PROPERTY_NAMES); + } + + public static TerminationProperty forPropertyName(String propertyName) { + for (var property : values()) { + if (property.getPropertyName().equals(propertyName)) { + return property; + } + } + throw new IllegalArgumentException("No property with the name (%s). Valid properties are %s." + .formatted(propertyName, PROPERTY_NAMES)); + } +} diff --git a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TimefoldProperties.java b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TimefoldProperties.java index f73988dbf6..743964bfd8 100644 --- a/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TimefoldProperties.java +++ b/spring-integration/spring-boot-autoconfigure/src/main/java/ai/timefold/solver/spring/boot/autoconfigure/config/TimefoldProperties.java @@ -1,11 +1,10 @@ package ai.timefold.solver.spring.boot.autoconfigure.config; -import static ai.timefold.solver.spring.boot.autoconfigure.config.SolverProperties.VALID_FIELD_NAMES_SET; -import static java.util.stream.Collectors.joining; - import java.util.HashMap; import java.util.Map; import java.util.Optional; +import java.util.TreeSet; +import java.util.stream.Collectors; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.NestedConfigurationProperty; @@ -63,22 +62,23 @@ public void setSolver(Map solver) { // multiple solvers this.solver = new HashMap<>(); // Check if it is a single solver - if (VALID_FIELD_NAMES_SET.containsAll(solver.keySet())) { + if (SolverProperty.getValidPropertyNames().containsAll(solver.keySet())) { SolverProperties solverProperties = new SolverProperties(); solverProperties.loadProperties(solver); this.solver.put(DEFAULT_SOLVER_NAME, solverProperties); } else { // The values must be an instance of map - String invalidKeys = solver.entrySet().stream() + var invalidKeySet = solver.entrySet().stream() .filter(e -> e.getValue() != null && !(e.getValue() instanceof Map)) .map(Map.Entry::getKey) - .collect(joining(", ")); - if (!invalidKeys.isBlank()) { + .collect(Collectors.toCollection(TreeSet::new)); + if (!invalidKeySet.isEmpty()) { throw new IllegalStateException(""" - The properties [%s] are not valid. + Cannot use global solver properties with named solvers. + Expected all values to be maps, but values for key(s) %s are not map(s). Maybe try changing the property name to kebab-case. - Here is the list of valid properties: %s""" - .formatted(invalidKeys, String.join(", ", VALID_FIELD_NAMES_SET))); + Here is the list of valid global solver properties: %s""" + .formatted(invalidKeySet, String.join(", ", SolverProperty.getValidPropertyNames()))); } // Multiple solvers. We load the properties per key (or solver config) solver.forEach((key, value) -> { diff --git a/spring-integration/spring-boot-autoconfigure/src/test/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfigurationTest.java b/spring-integration/spring-boot-autoconfigure/src/test/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfigurationTest.java index 3229151916..2a1b78fda0 100644 --- a/spring-integration/spring-boot-autoconfigure/src/test/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfigurationTest.java +++ b/spring-integration/spring-boot-autoconfigure/src/test/java/ai/timefold/solver/spring/boot/autoconfigure/TimefoldSolverAutoConfigurationTest.java @@ -36,6 +36,7 @@ import ai.timefold.solver.spring.boot.autoconfigure.chained.domain.TestdataChainedSpringEntity; import ai.timefold.solver.spring.boot.autoconfigure.chained.domain.TestdataChainedSpringObject; import ai.timefold.solver.spring.boot.autoconfigure.chained.domain.TestdataChainedSpringSolution; +import ai.timefold.solver.spring.boot.autoconfigure.config.SolverProperty; import ai.timefold.solver.spring.boot.autoconfigure.config.TimefoldProperties; import ai.timefold.solver.spring.boot.autoconfigure.dummy.MultipleConstraintProviderSpringTestConfiguration; import ai.timefold.solver.spring.boot.autoconfigure.dummy.MultipleEasyScoreConstraintSpringTestConfiguration; @@ -63,10 +64,12 @@ import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.Test; +import org.mockito.Mockito; import org.springframework.boot.autoconfigure.AutoConfigurations; import org.springframework.boot.test.context.ConfigDataApplicationContextInitializer; import org.springframework.boot.test.context.FilteredClassLoader; import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.core.NativeDetector; import org.springframework.core.io.ClassPathResource; import org.springframework.test.context.TestExecutionListeners; @@ -75,6 +78,8 @@ class TimefoldSolverAutoConfigurationTest { private final ApplicationContextRunner contextRunner; private final ApplicationContextRunner emptyContextRunner; + private final ApplicationContextRunner fakeNativeWithNodeSharingContextRunner; + private final ApplicationContextRunner fakeNativeWithoutNodeSharingContextRunner; private final ApplicationContextRunner benchmarkContextRunner; private final ApplicationContextRunner noUserConfigurationContextRunner; private final ApplicationContextRunner chainedContextRunner; @@ -93,6 +98,18 @@ public TimefoldSolverAutoConfigurationTest() { .withConfiguration( AutoConfigurations.of(TimefoldSolverAutoConfiguration.class, TimefoldSolverBeanFactory.class)) .withUserConfiguration(EmptySpringTestConfiguration.class); + fakeNativeWithNodeSharingContextRunner = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(TimefoldSolverAutoConfiguration.class, TimefoldSolverBeanFactory.class)) + .withUserConfiguration(NormalSpringTestConfiguration.class) + .withPropertyValues("timefold.solver.%s=true" + .formatted(SolverProperty.CONSTRAINT_STREAM_AUTOMATIC_NODE_SHARING.getPropertyName())); + fakeNativeWithoutNodeSharingContextRunner = new ApplicationContextRunner() + .withConfiguration( + AutoConfigurations.of(TimefoldSolverAutoConfiguration.class, TimefoldSolverBeanFactory.class)) + .withUserConfiguration(NormalSpringTestConfiguration.class) + .withPropertyValues("timefold.solver.%s=false" + .formatted(SolverProperty.CONSTRAINT_STREAM_AUTOMATIC_NODE_SHARING.getPropertyName())); benchmarkContextRunner = new ApplicationContextRunner() .withConfiguration( AutoConfigurations.of(TimefoldSolverAutoConfiguration.class, TimefoldSolverBeanFactory.class, @@ -133,6 +150,43 @@ void noSolutionOrEntityClasses() { }); } + @Test + void nodeSharingFailFastInNativeImage() { + try (var nativeDetectorMock = Mockito.mockStatic(NativeDetector.class)) { + nativeDetectorMock.when(NativeDetector::inNativeImage).thenReturn(true); + fakeNativeWithNodeSharingContextRunner + .run(context -> { + Throwable startupFailure = context.getStartupFailure(); + assertThat(startupFailure) + .isInstanceOf(UnsupportedOperationException.class) + .hasMessageContainingAll("node sharing", "unsupported", "native"); + }); + } + + } + + @Test + void nodeSharingDisabledWorksInNativeImage() { + try (var nativeDetectorMock = Mockito.mockStatic(NativeDetector.class)) { + nativeDetectorMock.when(NativeDetector::inNativeImage).thenReturn(true); + fakeNativeWithoutNodeSharingContextRunner + .run(context -> { + SolverConfig solverConfig = context.getBean(SolverConfig.class); + assertThat(solverConfig).isNotNull(); + assertThat(solverConfig.getSolutionClass()).isEqualTo(TestdataSpringSolution.class); + assertThat(solverConfig.getEntityClassList()) + .isEqualTo(Collections.singletonList(TestdataSpringEntity.class)); + assertThat(solverConfig.getScoreDirectorFactoryConfig().getConstraintProviderClass()) + .isEqualTo(TestdataSpringConstraintProvider.class); + // Properties defined in solverConfig.xml + assertThat(solverConfig.getTerminationConfig().getSecondsSpentLimit().longValue()).isEqualTo(2L); + SolverFactory solverFactory = context.getBean(SolverFactory.class); + assertThat(solverFactory).isNotNull(); + assertThat(solverFactory.buildSolver()).isNotNull(); + }); + } + } + @Test void solverConfigXml_none() { contextRunner @@ -341,8 +395,10 @@ void invalidYaml() { .withSystemProperties( "spring.config.location=classpath:ai/timefold/solver/spring/boot/autoconfigure/single-solver/invalid-application.yaml") .run(context -> context.getBean(SolverConfig.class))) - .rootCause().message().contains("The properties", "solverConfigXml", "environmentMode", "moveThreadCount", - "domainAccessType", "are not valid", "Maybe try changing the property name to kebab-case"); + .rootCause().message().contains("Cannot use global solver properties with named solvers", "solverConfigXml", + "environmentMode", "moveThreadCount", + "domainAccessType", "Expected all values to be maps, but values for key(s)", + "Maybe try changing the property name to kebab-case"); } @Test