Skip to content

Commit 1dde0ab

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

6 files changed

Lines changed: 50 additions & 9 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/event/SolverEventSupport.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,10 @@ public void fireBestSolutionChanged(SolverScope<Solution_> solverScope, Solution
2424
var timeMillisSpent = solverScope.getBestSolutionTimeMillisSpent();
2525
var bestScore = solverScope.getBestScore();
2626
if (it.hasNext()) {
27-
var event = new BestSolutionChangedEvent<>(solver, timeMillisSpent, newBestSolution, bestScore.raw(),
27+
// We need to clone the new best solution, or we may share the same instance with user consumers.
28+
// Reusing the instance can lead to inconsistent states if intermediary consumers change the solution.
29+
var newBestSolutionCloned = solverScope.getScoreDirector().cloneSolution(newBestSolution);
30+
var event = new BestSolutionChangedEvent<>(solver, timeMillisSpent, newBestSolutionCloned, bestScore.raw(),
2831
bestScore.fullyAssigned());
2932
do {
3033
it.next().bestSolutionChanged(event);

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -131,11 +131,12 @@ public void updateBestSolutionAndFireIfInitialized(SolverScope<Solution_> solver
131131
private void updateBestSolutionAndFire(SolverScope<Solution_> solverScope, InnerScore<?> bestScore,
132132
Solution_ bestSolution) {
133133
updateBestSolutionWithoutFiring(solverScope, bestScore, bestSolution);
134-
solverEventSupport.fireBestSolutionChanged(solverScope, solverScope.getBestSolution());
134+
solverEventSupport.fireBestSolutionChanged(solverScope, bestSolution);
135135
}
136136

137137
@SuppressWarnings({ "unchecked", "rawtypes" })
138138
private void updateBestSolutionWithoutFiring(SolverScope<Solution_> solverScope) {
139+
// We clone the existing working solution to set it as the best current solution
139140
var newBestSolution = solverScope.getScoreDirector().cloneWorkingSolution();
140141
var newBestScore = solverScope.getSolutionDescriptor().<Score> getScore(newBestSolution);
141142
var innerScore = InnerScore.withUnassignedCount(newBestScore, -solverScope.getScoreDirector().getWorkingInitScore());
@@ -144,11 +145,10 @@ private void updateBestSolutionWithoutFiring(SolverScope<Solution_> solverScope)
144145

145146
private void updateBestSolutionWithoutFiring(SolverScope<Solution_> solverScope, InnerScore<?> bestScore,
146147
Solution_ bestSolution) {
147-
if (bestScore.fullyAssigned()) {
148-
if (!solverScope.isBestSolutionInitialized()) {
149-
solverScope.setStartingInitializedScore(bestScore.raw());
150-
}
148+
if (bestScore.fullyAssigned() && !solverScope.isBestSolutionInitialized()) {
149+
solverScope.setStartingInitializedScore(bestScore.raw());
151150
}
151+
152152
solverScope.setBestSolution(bestSolution);
153153
solverScope.setBestScore(bestScore);
154154
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/constructionheuristic/DefaultConstructionHeuristicPhaseTest.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,8 @@ void solveWithInitializedSolution() {
9191
new TestdataEntity("e3", v3)));
9292

9393
var solution = PlannerTestUtils.solve(solverConfig, inputProblem, false);
94-
assertThat(inputProblem).isSameAs(solution);
94+
assertThat(inputProblem.getEntityList().stream().map(TestdataEntity::getValue).toList())
95+
.isEqualTo(solution.getEntityList().stream().map(TestdataEntity::getValue).toList());
9596
}
9697

9798
@Test

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

Lines changed: 33 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,36 @@ 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+
// Add a new entity to the cloned solution
209+
intermediate.getEntityList().add(new TestdataEntity("Bad Entity 3"));
210+
countDownLatch.countDown();
211+
})
212+
.withConfigOverride(new SolverConfigOverride<TestdataSolution>()
213+
.withTerminationConfig(new TerminationConfig().withMoveCountLimit(100L)))
214+
.run()
215+
.getFinalBestSolution();
216+
countDownLatch.await();
217+
// At this point, the events generated by the best solution consumer should not add any new entities
218+
assertThat(solution.getEntityList()).hasSize(2);
219+
}
220+
}
221+
189222
private record RecordedFuture(int id, CompletableFuture<Void> future) {
190223

191224
boolean isDone() {

0 commit comments

Comments
 (0)