Skip to content

Commit ee79e93

Browse files
committed
fix: clone the solution before sending it to the consumer
1 parent fd4530a commit ee79e93

4 files changed

Lines changed: 48 additions & 7 deletions

File tree

core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolver.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ public void setMonitorTagMap(Map<String, String> monitorTagMap) {
204204
restartSolver = checkProblemFactChanges();
205205
}
206206
outerSolvingEnded(solverScope);
207-
return solverScope.getBestSolution();
207+
return solverScope.cloneBestSolution();
208208
}
209209

210210
public void outerSolvingStarted(SolverScope<Solution_> solverScope) {

core/src/main/java/ai/timefold/solver/core/impl/solver/recaller/BestSolutionRecaller.java

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -118,19 +118,26 @@ public <Score_ extends Score<Score_>> void processWorkingSolutionDuringMove(Inne
118118

119119
public void updateBestSolutionAndFire(SolverScope<Solution_> solverScope) {
120120
updateBestSolutionWithoutFiring(solverScope);
121-
solverEventSupport.fireBestSolutionChanged(solverScope, solverScope.getBestSolution());
121+
// We need to clone the solution again, or we may use the same instance employed by the solver scope,
122+
// which is restored between solver phases.
123+
// The operation won't block the solving process.
124+
solverEventSupport.fireBestSolutionChanged(solverScope, solverScope.cloneBestSolution());
122125
}
123126

124127
public void updateBestSolutionAndFireIfInitialized(SolverScope<Solution_> solverScope) {
125128
updateBestSolutionWithoutFiring(solverScope);
126129
if (solverScope.isBestSolutionInitialized()) {
127-
solverEventSupport.fireBestSolutionChanged(solverScope, solverScope.getBestSolution());
130+
// We need to clone the solution again, or we may use the same instance employed by the solver scope,
131+
// which is restored between solver phases.
132+
// The operation won't block the solving process.
133+
solverEventSupport.fireBestSolutionChanged(solverScope, solverScope.cloneBestSolution());
128134
}
129135
}
130136

131137
private void updateBestSolutionAndFire(SolverScope<Solution_> solverScope, InnerScore<?> bestScore,
132138
Solution_ bestSolution) {
133139
updateBestSolutionWithoutFiring(solverScope, bestScore, bestSolution);
140+
// There is no need to clone the solution because the caller has already done that
134141
solverEventSupport.fireBestSolutionChanged(solverScope, solverScope.getBestSolution());
135142
}
136143

@@ -144,11 +151,10 @@ private void updateBestSolutionWithoutFiring(SolverScope<Solution_> solverScope)
144151

145152
private void updateBestSolutionWithoutFiring(SolverScope<Solution_> solverScope, InnerScore<?> bestScore,
146153
Solution_ bestSolution) {
147-
if (bestScore.fullyAssigned()) {
148-
if (!solverScope.isBestSolutionInitialized()) {
154+
if (bestScore.fullyAssigned() && !solverScope.isBestSolutionInitialized()) {
149155
solverScope.setStartingInitializedScore(bestScore.raw());
150156
}
151-
}
157+
152158
solverScope.setBestSolution(bestSolution);
153159
solverScope.setBestScore(bestScore);
154160
solverScope.setBestSolutionTimeMillis(solverScope.getClock().millis());

core/src/main/java/ai/timefold/solver/core/impl/solver/scope/SolverScope.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,10 @@ public Solution_ getBestSolution() {
224224
return bestSolution.get();
225225
}
226226

227+
public Solution_ cloneBestSolution() {
228+
return scoreDirector.cloneSolution(bestSolution.get());
229+
}
230+
227231
/**
228232
* The {@link PlanningSolution best solution} must never be the same instance
229233
* as the {@link PlanningSolution working solution}, it should be a (un)changed clone.
@@ -332,7 +336,7 @@ public long getMoveEvaluationSpeed() {
332336

333337
public void setWorkingSolutionFromBestSolution() {
334338
// The workingSolution must never be the same instance as the bestSolution.
335-
scoreDirector.setWorkingSolution(scoreDirector.cloneSolution(getBestSolution()));
339+
scoreDirector.setWorkingSolution(cloneBestSolution());
336340
}
337341

338342
public SolverScope<Solution_> createChildThreadSolverScope(ChildThreadType childThreadType) {

core/src/test/java/ai/timefold/solver/core/impl/solver/BestSolutionHolderTest.java

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,17 @@
1313
import java.util.UUID;
1414
import java.util.concurrent.CompletableFuture;
1515
import java.util.concurrent.CountDownLatch;
16+
import java.util.concurrent.ExecutionException;
1617
import java.util.concurrent.Executors;
1718

1819
import ai.timefold.solver.core.api.solver.Solver;
20+
import ai.timefold.solver.core.api.solver.SolverConfigOverride;
1921
import ai.timefold.solver.core.api.solver.SolverManager;
2022
import ai.timefold.solver.core.api.solver.change.ProblemChange;
2123
import ai.timefold.solver.core.api.solver.change.ProblemChangeDirector;
2224
import ai.timefold.solver.core.config.solver.SolverConfig;
2325
import ai.timefold.solver.core.config.solver.SolverManagerConfig;
26+
import ai.timefold.solver.core.config.solver.termination.TerminationConfig;
2427
import ai.timefold.solver.core.testdomain.TestdataEasyScoreCalculator;
2528
import ai.timefold.solver.core.testdomain.TestdataEntity;
2629
import ai.timefold.solver.core.testdomain.TestdataSolution;
@@ -186,6 +189,34 @@ void problemChangeBarrageIntermediateBestSolutionConsumer() throws InterruptedEx
186189

187190
}
188191

192+
@Test
193+
void testCloningBetweenPhases() throws ExecutionException, InterruptedException {
194+
var solverConfig = new SolverConfig()
195+
.withSolutionClass(TestdataSolution.class)
196+
.withEntityClasses(TestdataEntity.class)
197+
.withEasyScoreCalculatorClass(TestdataEasyScoreCalculator.class);
198+
199+
try (var solverManager = SolverManager.<TestdataSolution, UUID> create(solverConfig, new SolverManagerConfig())) {
200+
var problem = TestdataSolution.generateSolution(2, 2);
201+
problem.getEntityList().forEach(entity -> entity.setValue(null));
202+
var countDownLatch = new CountDownLatch(1);
203+
var solution = solverManager.solveBuilder()
204+
.withProblemId(UUID.randomUUID())
205+
.withProblem(problem)
206+
.withBestSolutionConsumer(
207+
intermediate -> {
208+
intermediate.getEntityList().add(new TestdataEntity("Bad Entity 3"));
209+
countDownLatch.countDown();
210+
})
211+
.withConfigOverride(new SolverConfigOverride<TestdataSolution>()
212+
.withTerminationConfig(new TerminationConfig().withMoveCountLimit(100L)))
213+
.run()
214+
.getFinalBestSolution();
215+
countDownLatch.await();
216+
assertThat(solution.getEntityList()).hasSize(2);
217+
}
218+
}
219+
189220
private record RecordedFuture(int id, CompletableFuture<Void> future) {
190221

191222
boolean isDone() {

0 commit comments

Comments
 (0)