From 35a36056113607a85d69f5eecc73a2d1e17249a2 Mon Sep 17 00:00:00 2001 From: Cristiano Nicolai <570894+cristianonicolai@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:38:59 +1000 Subject: [PATCH 01/19] feat: Allow custom options in the RunConfiguration --- .../api/domain/RunConfiguration.java | 23 +-- .../api/domain/RunConfigurationTest.java | 150 ++++++++++++++++++ .../EmployeeScheduleResourceTest.java | 59 +++++++ 3 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 service/definition/src/test/java/ai/timefold/solver/service/definition/api/domain/RunConfigurationTest.java diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java index d0263ddb3ea..6b503a17df6 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java @@ -1,5 +1,6 @@ package ai.timefold.solver.service.definition.api.domain; +import java.util.Map; import java.util.Set; import jakarta.validation.constraints.Positive; @@ -11,7 +12,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; -@Schema(additionalProperties = Schema.False.class) public record RunConfiguration( @Schema(nullable = true, description = "Optional name to be given to the dataset. If not provided, the name will be generated.") @Size( @@ -21,13 +21,11 @@ public record RunConfiguration( description = "Optional maximum number of threads to be used for solving.", minimum = "1") @JsonInclude(JsonInclude.Include.NON_EMPTY) @Positive Integer maxThreadCount, @JsonInclude(JsonInclude.Include.NON_NULL) @Schema( - description = "Optional tags to be assigned to the dataset.") @Size(max = 100) Set tags) { + description = "Optional tags to be assigned to the dataset.") @Size(max = 100) Set tags, + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(hidden = true) Map options) { public RunConfiguration(String name, SolverTerminationConfig termination, Integer maxThreadCount, Set tags) { - this.name = name; - this.termination = termination; - this.tags = tags; - this.maxThreadCount = maxThreadCount; + this(name, termination, maxThreadCount, tags, null); } public RunConfiguration(String name, SolverTerminationConfig termination) { @@ -49,7 +47,7 @@ public RunConfiguration(String name) { * @return a copy of this instance with given termination, never null */ public RunConfiguration withTermination(SolverTerminationConfig termination) { - return new RunConfiguration(name(), termination, maxThreadCount(), tags()); + return new RunConfiguration(name(), termination, maxThreadCount(), tags(), options()); } public RunConfiguration override(RunConfiguration configuration) { @@ -57,6 +55,7 @@ public RunConfiguration override(RunConfiguration configuration) { SolverTerminationConfig finalTermination = termination; Integer finalMaxThreadCount = maxThreadCount; Set finalTags = tags; + Map finalOptions = options; if (configuration == null) { return this; @@ -70,16 +69,20 @@ public RunConfiguration override(RunConfiguration configuration) { finalMaxThreadCount = configuration.maxThreadCount(); } + if (finalOptions == null) { + finalOptions = configuration.options(); + } + if (finalTermination == null) { finalTermination = configuration.termination(); } else { finalTermination = finalTermination.override(configuration.termination()); } - if ((finalTags == null || !finalTags.isEmpty()) && configuration.tags() != null && !configuration.tags().isEmpty()) { - finalTags = configuration.tags; + if ((finalTags == null || finalTags.isEmpty()) && configuration.tags() != null && !configuration.tags().isEmpty()) { + finalTags = configuration.tags(); } - return new RunConfiguration(finalName, finalTermination, finalMaxThreadCount, finalTags); + return new RunConfiguration(finalName, finalTermination, finalMaxThreadCount, finalTags, finalOptions); } } diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/api/domain/RunConfigurationTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/domain/RunConfigurationTest.java new file mode 100644 index 00000000000..dcbbc1023a4 --- /dev/null +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/domain/RunConfigurationTest.java @@ -0,0 +1,150 @@ +package ai.timefold.solver.service.definition.api.domain; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.util.Map; +import java.util.Set; + +import ai.timefold.solver.service.definition.api.termination.SolverTerminationConfig; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +class RunConfigurationTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void convenienceConstructorsLeaveOptionsNull() { + assertThat(new RunConfiguration("dataset", null, 4, Set.of("a")).options()).isNull(); + assertThat(new RunConfiguration("dataset", null).options()).isNull(); + assertThat(new RunConfiguration(4, null).options()).isNull(); + assertThat(new RunConfiguration("dataset").options()).isNull(); + } + + @Test + void overrideFillsMissingOptionsFromFallback() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), null); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast")); + + RunConfiguration merged = primary.override(fallback); + + assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "fast")); + } + + @Test + void overrideKeepsPrimaryOptionsWhenPresent() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "accurate")); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast")); + + RunConfiguration merged = primary.override(fallback); + + // Options are replaced wholesale, never merged key-by-key. + assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "accurate")); + } + + @Test + void overrideKeepsPrimaryOptionsWhenPresentWithDisjointFallbackKeys() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "accurate")); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("logLevel", "debug")); + + RunConfiguration merged = primary.override(fallback); + + assertThat(merged.options()).containsExactlyEntriesOf(Map.of("solver", "accurate")); + } + + @Test + void overrideKeepsPrimaryEmptyOptionsInsteadOfInheriting() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of()); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of(), Map.of("solver", "fast")); + + RunConfiguration merged = primary.override(fallback); + + // Only a null options map inherits from the fallback; an empty one is a deliberate "no options". + assertThat(merged.options()).isEmpty(); + } + + @Test + void overrideWithNullConfigurationKeepsOptions() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), Map.of("solver", "fast")); + + assertThat(primary.override(null).options()).containsExactlyEntriesOf(Map.of("solver", "fast")); + } + + @Test + void withTerminationPreservesOptions() { + RunConfiguration configuration = + new RunConfiguration("dataset", null, 4, Set.of("nightly"), Map.of("solver", "fast")); + + RunConfiguration copy = configuration.withTermination(new SolverTerminationConfig(Duration.ofMinutes(1), null)); + + assertThat(copy.options()).containsExactlyEntriesOf(Map.of("solver", "fast")); + assertThat(copy.termination().spentLimit()).isEqualTo(Duration.ofMinutes(1)); + assertThat(copy.name()).isEqualTo("dataset"); + assertThat(copy.maxThreadCount()).isEqualTo(4); + assertThat(copy.tags()).containsExactly("nightly"); + } + + @Test + void deserializesOptionsFromJson() throws JsonProcessingException { + String json = """ + { + "name": "dataset", + "options": { + "solver": "fast", + "logLevel": "debug" + } + } + """; + + RunConfiguration configuration = mapper.readValue(json, RunConfiguration.class); + + assertThat(configuration.name()).isEqualTo("dataset"); + assertThat(configuration.options()) + .containsExactlyInAnyOrderEntriesOf(Map.of("solver", "fast", "logLevel", "debug")); + } + + @Test + void omitsNullOptionsFromJson() throws JsonProcessingException { + String json = mapper.writeValueAsString(new RunConfiguration("dataset")); + + assertThat(json).doesNotContain("options"); + } + + @Test + void serializesOptionsWhenPresent() throws JsonProcessingException { + RunConfiguration configuration = + new RunConfiguration(null, null, null, null, Map.of("solver", "fast")); + + String json = mapper.writeValueAsString(configuration); + + assertThat(json).contains("\"options\":{\"solver\":\"fast\"}"); + } + + @Test + void overrideFillsNullTagsFromFallback() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, null, null); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null); + + assertThat(primary.override(fallback).tags()).containsExactly("nightly"); + } + + @Test + void overrideFillsEmptyTagsFromFallback() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of(), null); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null); + + assertThat(primary.override(fallback).tags()).containsExactly("nightly"); + } + + @Test + void overrideKeepsPrimaryTagsWhenPresent() { + RunConfiguration primary = new RunConfiguration("dataset", null, null, Set.of("adhoc"), null); + RunConfiguration fallback = new RunConfiguration(null, null, null, Set.of("nightly"), null); + + assertThat(primary.override(fallback).tags()).containsExactly("adhoc"); + } +} diff --git a/service/test-model/src/test/java/ai/timefold/solver/service/testmodel/EmployeeScheduleResourceTest.java b/service/test-model/src/test/java/ai/timefold/solver/service/testmodel/EmployeeScheduleResourceTest.java index d4c4dcb90b4..6bf169861ca 100644 --- a/service/test-model/src/test/java/ai/timefold/solver/service/testmodel/EmployeeScheduleResourceTest.java +++ b/service/test-model/src/test/java/ai/timefold/solver/service/testmodel/EmployeeScheduleResourceTest.java @@ -12,6 +12,7 @@ import java.time.ZoneOffset; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; @@ -30,9 +31,11 @@ import ai.timefold.solver.core.api.score.HardMediumSoftScore; import ai.timefold.solver.service.definition.api.SolverModel; import ai.timefold.solver.service.definition.api.SolvingStatus; +import ai.timefold.solver.service.definition.api.domain.Configuration; import ai.timefold.solver.service.definition.api.domain.Metadata; import ai.timefold.solver.service.definition.api.domain.ModelRequest; import ai.timefold.solver.service.definition.api.domain.ModelResponse; +import ai.timefold.solver.service.definition.api.domain.RunConfiguration; import ai.timefold.solver.service.definition.api.rest.OperationOnPost; import ai.timefold.solver.service.definition.api.validation.IssueCode; import ai.timefold.solver.service.definition.api.validation.IssueSeverity; @@ -62,6 +65,10 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + import io.quarkus.test.common.QuarkusTestResource; import io.quarkus.test.common.http.TestHTTPResource; import io.quarkus.test.junit.QuarkusTest; @@ -88,6 +95,9 @@ public class EmployeeScheduleResourceTest { @Connector("smallrye-in-memory") InMemoryConnector connector; + @Inject + ObjectMapper objectMapper; + InMemorySink initSolutionSink; InMemorySink bestSolutionSink; InMemorySink finalBestSolutionSink; @@ -332,6 +342,55 @@ void getIssueTypeByCode() { }); } + @Test + void postAcceptsCustomRunOptions() { + RunConfiguration runConfiguration = + new RunConfiguration(null, null, null, Set.of("options-e2e"), Map.of("customOption", "customValue")); + ModelRequest modelRequest = + new ModelRequest(createInputEmployeeSchedule()) + .withConfiguration(Configuration. empty().withRun(runConfiguration)); + + // Options are hidden from the OpenAPI schema, so the generated JSON schema must not forbid them. + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(modelRequest) + .when() + .post("/schedules?operation=" + OperationOnPost.NONE.name()) + .then() + .log().ifError() + .statusCode(202); + + // Await the dataset so the asynchronous computation cannot leak into the next test. + await() + .atMost(TEST_AWAIT_TIMEOUT_DURATION) + .pollInterval(TEST_POLL_INTERVAL_MILLIS) + .until(() -> !datasetComputedSink.received().isEmpty()); + } + + @Test + void postRejectsUnknownRunConfigurationProperty() throws Exception { + ModelRequest modelRequest = + new ModelRequest(createInputEmployeeSchedule()) + .withConfiguration(Configuration. empty() + .withRun(new RunConfiguration("unknown-property-e2e"))); + + ObjectNode body = objectMapper.valueToTree(modelRequest); + JsonNode runNode = body.path(ModelRequest.ModelRequestAttribute.CONFIG.value()).path("run"); + assertThat(runNode.isObject()).as("run configuration should be serialized as an object").isTrue(); + ((ObjectNode) runNode).put("notARealOption", "boom"); + + // Permitting unknown properties in the schema must not weaken the strict Jackson mapping. + given() + .contentType(ContentType.JSON) + .accept(ContentType.JSON) + .body(objectMapper.writeValueAsString(body)) + .when() + .post("/schedules?operation=" + OperationOnPost.NONE.name()) + .then() + .statusCode(400); + } + private static EmployeeSchedule awaitFeasiblyAssigned(Metadata metadata) { await() .atMost(TEST_AWAIT_TIMEOUT_DURATION) From 2e651b5757959039e90080b0987a6b579733924d Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Tue, 1 Sep 2026 16:40:59 +0200 Subject: [PATCH 02/19] feat: store execution profile in metadata --- .../definition/api/domain/Metadata.java | 17 ++++++++++++ .../storage/inmemory/InMemoryStorage.java | 16 +++++++++++- .../internal/platform/EnvironmentVars.java | 16 ++++++++++++ .../storage/AbstractStorageService.java | 14 ++++++++++ .../definition/internal/storage/Storage.java | 26 +++++++++++++++++++ .../internal/storage/SubModelKind.java | 3 ++- 6 files changed, 90 insertions(+), 2 deletions(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/Metadata.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/Metadata.java index ab15ae63911..c8a3e2482c7 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/Metadata.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/Metadata.java @@ -70,6 +70,11 @@ public final class Metadata implements Status { @JsonInclude(JsonInclude.Include.NON_EMPTY) private String failureMessage; + @Schema(nullable = true, description = "The name of the execution profile the run was submitted with.") + @JsonAlias({ "executionprofile" }) + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private String executionProfile; + public Metadata() { this((String) null); } @@ -102,6 +107,7 @@ public Metadata(Metadata metadata) { this.parentId = metadata.parentId; this.originId = metadata.originId; this.failureMessage = metadata.failureMessage; + this.executionProfile = metadata.executionProfile; } public String getId() { @@ -260,6 +266,14 @@ public void setFailureMessage(String failureMessage) { this.failureMessage = failureMessage; } + public String getExecutionProfile() { + return executionProfile; + } + + public void setExecutionProfile(String executionProfile) { + this.executionProfile = executionProfile; + } + @Override public void solvingStarted() { if (solverStatus != SolvingStatus.DATASET_COMPUTED @@ -333,6 +347,9 @@ public Map asMap() { if (score != null) { map.put("score", score.toString()); } + if (executionProfile != null) { + map.put("executionProfile", executionProfile); + } return map; } diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/storage/inmemory/InMemoryStorage.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/storage/inmemory/InMemoryStorage.java index 6e0934c5d3a..af1a9f2b705 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/storage/inmemory/InMemoryStorage.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/storage/inmemory/InMemoryStorage.java @@ -2,6 +2,7 @@ import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.util.List; import java.util.Map; @@ -114,6 +115,19 @@ public void storeSubModel(StorageAddress options, String id, SubModelKind kind, resources.put(id + "_" + kind.id(), subModel); } + @Override + public void storeSubModelStream(StorageAddress options, String id, SubModelKind kind, InputStream input) { + if (input == null) { + return; + } + try { + resources.put(id + "_" + kind.id(), input.readAllBytes()); + } catch (IOException e) { + throw new TimefoldRuntimeException(ErrorCodes.STORAGE_UNABLE_TO_WRITE, + "Unable to store sub model (" + kind + ") to the storage for id " + id, e); + } + } + @Override public void updateSubModel(StorageAddress options, String id, SubModelKind subModelKind, Object subModel) { if (subModel == null) { @@ -132,7 +146,7 @@ public void getSubModelStream(StorageAddress options, String id, SubModelKind su Object subModel = resources.get(id + "_" + subModelKind.id()); if (subModel != null) { try { - byte[] content = mapper.writeValueAsBytes(subModel); + byte[] content = subModel instanceof byte[] raw ? raw : mapper.writeValueAsBytes(subModel); output.write(compress(content)); } catch (IOException e) { diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java index 28f07a91a3d..870fe1c54a5 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java @@ -22,6 +22,22 @@ public class EnvironmentVars { */ public static final String ENV_TIMEFOLD_ON_START_COMMAND = "AI_TIMEFOLD_ON_START_COMMAND"; + /** + * Name of the execution profile the run was started with (always set, including for the default profile). + */ + public static final String ENV_TIMEFOLD_EXECUTION_PROFILE = "AI_TIMEFOLD_EXECUTION_PROFILE"; + + /** + * Directory the solver worker collects execution-profile diagnostic artifacts into; its contents are zipped and + * uploaded when the run finishes. + */ + public static final String ENV_TIMEFOLD_EXECUTION_PROFILE_DIR = "AI_TIMEFOLD_EXECUTION_PROFILE_DIR"; + + /** + * Default value for {@link #ENV_TIMEFOLD_EXECUTION_PROFILE_DIR} when the variable is not set. + */ + public static final String DEFAULT_EXECUTION_PROFILE_DIR = "/tmp/timefold-diagnostics"; + /** * Plan name that is being used to run the job */ diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/AbstractStorageService.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/AbstractStorageService.java index 1bd843de63b..53ef64ea88b 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/AbstractStorageService.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/AbstractStorageService.java @@ -1,5 +1,6 @@ package ai.timefold.solver.service.definition.internal.storage; +import java.io.InputStream; import java.io.OutputStream; import java.util.List; import java.util.concurrent.ConcurrentHashMap; @@ -521,6 +522,19 @@ public void storeLogs(StorageAddress storageAddress, String id, LogInfo info) { } } + public void storeExecutionProfileArtifacts(String id, InputStream input) { + storeExecutionProfileArtifacts(null, id, input); + } + + public void storeExecutionProfileArtifacts(StorageAddress storageAddress, String id, InputStream input) { + acquireLock(id); + try { + storage.storeSubModelStream(storageAddress, id, SubModelKind.EXECUTION_PROFILE_ARTIFACTS, input); + } finally { + releaseLock(id); + } + } + public void deleteAll(String id) { deleteAll(null, id); } diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/Storage.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/Storage.java index 6b233c07819..ad7d73c7f60 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/Storage.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/Storage.java @@ -1,5 +1,6 @@ package ai.timefold.solver.service.definition.internal.storage; +import java.io.InputStream; import java.io.OutputStream; import java.util.List; @@ -273,6 +274,31 @@ default void storeSubModel(String id, SubModelKind subModelKind, Object subModel */ void storeSubModel(StorageAddress options, String id, SubModelKind subModelKind, Object subModel); + /** + * Stores a sub resource verbatim from a binary stream in the default location of the underlying data store, without any + * marshalling. Use this (rather than {@link #storeSubModel}) for binary payloads such as archives, so that the bytes can be + * retrieved unchanged through {@link #getSubModelStream}. + * + * @param id unique identifier of the data set + * @param subModelKind kind of the sub resource e.g. execution profile artifacts + * @param input the binary content to be stored; the caller is responsible for closing it + */ + default void storeSubModelStream(String id, SubModelKind subModelKind, InputStream input) { + storeSubModelStream(null, id, subModelKind, input); + } + + /** + * Stores a sub resource verbatim from a binary stream in the location defined by StorageOptions, without any + * marshalling. Use this (rather than {@link #storeSubModel}) for binary payloads such as archives, so that the bytes can be + * retrieved unchanged through {@link #getSubModelStream}. + * + * @param options storage option to apply during the operation + * @param id unique identifier of the data set + * @param subModelKind kind of the sub resource e.g. execution profile artifacts + * @param input the binary content to be stored; the caller is responsible for closing it + */ + void storeSubModelStream(StorageAddress options, String id, SubModelKind subModelKind, InputStream input); + /** * Stores sub resource associated with given data set given by identifier in the default location of the underlying data * store diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/SubModelKind.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/SubModelKind.java index 0d6cde676bf..deed573a1bd 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/SubModelKind.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/SubModelKind.java @@ -17,7 +17,8 @@ public enum SubModelKind { SCORE_ANALYSIS("score-analysis"), SCORE_ANALYSIS_WITH_JUSTIFICATIONS("score-analysis-with-justifications"), WAYPOINTS("waypoints"), - PATCH_REQUEST("patch-request"); + PATCH_REQUEST("patch-request"), + EXECUTION_PROFILE_ARTIFACTS("execution-profile-artifacts"); private String id; From 8e22ec40b86ca4dcbac52b43db5c1eb8852f1b3c Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Fri, 4 Sep 2026 13:51:07 +0200 Subject: [PATCH 03/19] feat: remove execution profiles from metadata --- .../service/definition/api/domain/Metadata.java | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/Metadata.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/Metadata.java index c8a3e2482c7..ab15ae63911 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/Metadata.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/Metadata.java @@ -70,11 +70,6 @@ public final class Metadata implements Status { @JsonInclude(JsonInclude.Include.NON_EMPTY) private String failureMessage; - @Schema(nullable = true, description = "The name of the execution profile the run was submitted with.") - @JsonAlias({ "executionprofile" }) - @JsonInclude(JsonInclude.Include.NON_EMPTY) - private String executionProfile; - public Metadata() { this((String) null); } @@ -107,7 +102,6 @@ public Metadata(Metadata metadata) { this.parentId = metadata.parentId; this.originId = metadata.originId; this.failureMessage = metadata.failureMessage; - this.executionProfile = metadata.executionProfile; } public String getId() { @@ -266,14 +260,6 @@ public void setFailureMessage(String failureMessage) { this.failureMessage = failureMessage; } - public String getExecutionProfile() { - return executionProfile; - } - - public void setExecutionProfile(String executionProfile) { - this.executionProfile = executionProfile; - } - @Override public void solvingStarted() { if (solverStatus != SolvingStatus.DATASET_COMPUTED @@ -347,9 +333,6 @@ public Map asMap() { if (score != null) { map.put("score", score.toString()); } - if (executionProfile != null) { - map.put("executionProfile", executionProfile); - } return map; } From b16cfe5baf810e8f1f6511a5d3e26e2445e7478e Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Fri, 4 Sep 2026 14:06:08 +0200 Subject: [PATCH 04/19] feat: rename execution profile artifacts to execution artifacts --- .../internal/storage/AbstractStorageService.java | 8 ++++---- .../service/definition/internal/storage/SubModelKind.java | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/AbstractStorageService.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/AbstractStorageService.java index 53ef64ea88b..11a91362779 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/AbstractStorageService.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/AbstractStorageService.java @@ -522,14 +522,14 @@ public void storeLogs(StorageAddress storageAddress, String id, LogInfo info) { } } - public void storeExecutionProfileArtifacts(String id, InputStream input) { - storeExecutionProfileArtifacts(null, id, input); + public void storeExecutionArtifacts(String id, InputStream input) { + storeExecutionArtifacts(null, id, input); } - public void storeExecutionProfileArtifacts(StorageAddress storageAddress, String id, InputStream input) { + public void storeExecutionArtifacts(StorageAddress storageAddress, String id, InputStream input) { acquireLock(id); try { - storage.storeSubModelStream(storageAddress, id, SubModelKind.EXECUTION_PROFILE_ARTIFACTS, input); + storage.storeSubModelStream(storageAddress, id, SubModelKind.EXECUTION_ARTIFACTS, input); } finally { releaseLock(id); } diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/SubModelKind.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/SubModelKind.java index deed573a1bd..dcf2d951d5f 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/SubModelKind.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/storage/SubModelKind.java @@ -18,7 +18,7 @@ public enum SubModelKind { SCORE_ANALYSIS_WITH_JUSTIFICATIONS("score-analysis-with-justifications"), WAYPOINTS("waypoints"), PATCH_REQUEST("patch-request"), - EXECUTION_PROFILE_ARTIFACTS("execution-profile-artifacts"); + EXECUTION_ARTIFACTS("execution-artifacts"); private String id; From 9c1479c0ade0bbe5d07a3184213225976214a19b Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Fri, 4 Sep 2026 17:11:59 +0200 Subject: [PATCH 05/19] feat: add execution profiles to solver --- .../DefaultExecutionProfile.java | 26 ++++++++++++++ .../executionprofile/ExecutionProfile.java | 21 +++++++++++ .../ExecutionProfileRegistry.java | 36 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/DefaultExecutionProfile.java create mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java create mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfileRegistry.java diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/DefaultExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/DefaultExecutionProfile.java new file mode 100644 index 00000000000..61b27b35d85 --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/DefaultExecutionProfile.java @@ -0,0 +1,26 @@ +package ai.timefold.solver.service.definition.api.executionprofile; + +import jakarta.enterprise.context.ApplicationScoped; + +/** + * The always-available baseline {@link ExecutionProfile}, applied when a run selects no other profile. It contributes no + * runtime specification of its own. + */ +@ApplicationScoped +public class DefaultExecutionProfile implements ExecutionProfile { + + /** + * Identifier of the always-available baseline profile, applied when a run selects no other profile. + */ + public static final String NAME = "default"; + + @Override + public String name() { + return NAME; + } + + @Override + public String description() { + return "Standard runtime configuration used for regular runs"; + } +} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java new file mode 100644 index 00000000000..ac1a459cd79 --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java @@ -0,0 +1,21 @@ +package ai.timefold.solver.service.definition.api.executionprofile; + +/** + * A named, predefined runtime configuration a run can be started with. + *

+ * Execution profiles belong to the solver service: they describe how a run executes (diagnostics, logging, profiling, ...). + * Implementations are discovered as CDI beans (see {@link ExecutionProfileRegistry}), so a new profile can be added simply by + * providing a new implementation - no central registry needs to be edited. A run may activate several profiles at once. + */ +public interface ExecutionProfile { + + /** + * Stable identifier of the profile, used in APIs and permissions. Must be unique across all implementations. + */ + String name(); + + /** + * Human readable description of the profile. + */ + String description(); +} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfileRegistry.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfileRegistry.java new file mode 100644 index 00000000000..9a4cef0e0b1 --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfileRegistry.java @@ -0,0 +1,36 @@ +package ai.timefold.solver.service.definition.api.executionprofile; + +import java.util.Collection; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Instance; +import jakarta.inject.Inject; + +/** + * Discovers all {@link ExecutionProfile} implementations available on the classpath (as CDI beans) and exposes them by name. + * Consumers inject this registry rather than referencing individual profiles, so the set of profiles is extensible without + * touching call sites. + */ +@ApplicationScoped +public class ExecutionProfileRegistry { + + private final Map profilesByName; + + @Inject + public ExecutionProfileRegistry(Instance profiles) { + this.profilesByName = profiles.stream() + .collect(Collectors.toUnmodifiableMap(ExecutionProfile::name, Function.identity())); + } + + public Collection all() { + return profilesByName.values(); + } + + public Optional findByName(String name) { + return Optional.ofNullable(profilesByName.get(name)); + } +} From ce245cd2b002944bd0362b3c4a9ff4f72f45e1f4 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Mon, 7 Sep 2026 14:41:43 +0200 Subject: [PATCH 06/19] feat: Remove default execution profile --- .../DefaultExecutionProfile.java | 26 ------------------- .../internal/platform/EnvironmentVars.java | 2 +- 2 files changed, 1 insertion(+), 27 deletions(-) delete mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/DefaultExecutionProfile.java diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/DefaultExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/DefaultExecutionProfile.java deleted file mode 100644 index 61b27b35d85..00000000000 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/DefaultExecutionProfile.java +++ /dev/null @@ -1,26 +0,0 @@ -package ai.timefold.solver.service.definition.api.executionprofile; - -import jakarta.enterprise.context.ApplicationScoped; - -/** - * The always-available baseline {@link ExecutionProfile}, applied when a run selects no other profile. It contributes no - * runtime specification of its own. - */ -@ApplicationScoped -public class DefaultExecutionProfile implements ExecutionProfile { - - /** - * Identifier of the always-available baseline profile, applied when a run selects no other profile. - */ - public static final String NAME = "default"; - - @Override - public String name() { - return NAME; - } - - @Override - public String description() { - return "Standard runtime configuration used for regular runs"; - } -} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java index 870fe1c54a5..debbad643ef 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/platform/EnvironmentVars.java @@ -36,7 +36,7 @@ public class EnvironmentVars { /** * Default value for {@link #ENV_TIMEFOLD_EXECUTION_PROFILE_DIR} when the variable is not set. */ - public static final String DEFAULT_EXECUTION_PROFILE_DIR = "/tmp/timefold-diagnostics"; + public static final String DEFAULT_EXECUTION_PROFILE_DIR = "/tmp/timefold-execution-artifacts"; /** * Plan name that is being used to run the job From a91a294f89fbff3cd40f2b0870d82283d34d5baf Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Mon, 7 Sep 2026 14:54:06 +0200 Subject: [PATCH 07/19] feat: move ExecutionProfileRegistry to internal package --- .../definition/api/executionprofile/ExecutionProfile.java | 4 ++-- .../executionprofile/ExecutionProfileRegistry.java | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) rename service/definition/src/main/java/ai/timefold/solver/service/definition/{api => internal}/executionprofile/ExecutionProfileRegistry.java (87%) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java index ac1a459cd79..0e0cc9469e4 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java @@ -4,8 +4,8 @@ * A named, predefined runtime configuration a run can be started with. *

* Execution profiles belong to the solver service: they describe how a run executes (diagnostics, logging, profiling, ...). - * Implementations are discovered as CDI beans (see {@link ExecutionProfileRegistry}), so a new profile can be added simply by - * providing a new implementation - no central registry needs to be edited. A run may activate several profiles at once. + * Implementations are discovered as CDI beans, so a new profile can be added simply by providing a new implementation - no + * central registry needs to be edited. A run may activate several profiles at once. */ public interface ExecutionProfile { diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfileRegistry.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java similarity index 87% rename from service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfileRegistry.java rename to service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java index 9a4cef0e0b1..a63e056af77 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfileRegistry.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java @@ -1,4 +1,4 @@ -package ai.timefold.solver.service.definition.api.executionprofile; +package ai.timefold.solver.service.definition.internal.executionprofile; import java.util.Collection; import java.util.Map; @@ -10,6 +10,8 @@ import jakarta.enterprise.inject.Instance; import jakarta.inject.Inject; +import ai.timefold.solver.service.definition.api.executionprofile.ExecutionProfile; + /** * Discovers all {@link ExecutionProfile} implementations available on the classpath (as CDI beans) and exposes them by name. * Consumers inject this registry rather than referencing individual profiles, so the set of profiles is extensible without From a1a595d801b9c6867fe8fa755bdd635417f073a6 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Tue, 8 Sep 2026 15:21:19 +0200 Subject: [PATCH 08/19] feat: remove applicationScoped from execution profile registry and use service loader instead --- .../executionprofile/ExecutionProfile.java | 21 ------------ .../executionprofile/ExecutionProfile.java | 33 +++++++++++++++++++ .../ExecutionProfileRegistry.java | 26 +++++++-------- 3 files changed, 45 insertions(+), 35 deletions(-) delete mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java create mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java deleted file mode 100644 index 0e0cc9469e4..00000000000 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/executionprofile/ExecutionProfile.java +++ /dev/null @@ -1,21 +0,0 @@ -package ai.timefold.solver.service.definition.api.executionprofile; - -/** - * A named, predefined runtime configuration a run can be started with. - *

- * Execution profiles belong to the solver service: they describe how a run executes (diagnostics, logging, profiling, ...). - * Implementations are discovered as CDI beans, so a new profile can be added simply by providing a new implementation - no - * central registry needs to be edited. A run may activate several profiles at once. - */ -public interface ExecutionProfile { - - /** - * Stable identifier of the profile, used in APIs and permissions. Must be unique across all implementations. - */ - String name(); - - /** - * Human readable description of the profile. - */ - String description(); -} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java new file mode 100644 index 00000000000..d972e9933ee --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java @@ -0,0 +1,33 @@ +package ai.timefold.solver.service.definition.internal.executionprofile; + +import java.util.Map; + +/** + * A named, predefined runtime configuration a run can be started with. + *

+ * Execution profiles describe how a run executes (diagnostics, logging, profiling, ...). This is an internal contract: + * model developers are not expected to implement or reference it. Implementations are provided by the solver service and + * the platform, and are discovered via {@link java.util.ServiceLoader}, so adding a new profile does not require editing + * any central registry. A run may activate several profiles at once. + */ +public interface ExecutionProfile { + + /** + * Stable identifier of the profile, used in APIs and permissions. Must be unique across all implementations. + */ + String name(); + + /** + * Human readable description of the profile. + */ + String description(); + + /** + * Additional configuration contributed by this profile, applied to the run's environment - each entry is injected as + * an environment variable into the solver pod. Keys must be valid environment-variable names. When multiple profiles + * are activated and define the same key, the resulting value is unspecified. Defaults to no extra configuration. + */ + default Map properties() { + return Map.of(); + } +} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java index a63e056af77..f672b823974 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java @@ -3,28 +3,26 @@ import java.util.Collection; import java.util.Map; import java.util.Optional; +import java.util.ServiceLoader; import java.util.function.Function; import java.util.stream.Collectors; - -import jakarta.enterprise.context.ApplicationScoped; -import jakarta.enterprise.inject.Instance; -import jakarta.inject.Inject; - -import ai.timefold.solver.service.definition.api.executionprofile.ExecutionProfile; +import java.util.stream.StreamSupport; /** - * Discovers all {@link ExecutionProfile} implementations available on the classpath (as CDI beans) and exposes them by name. - * Consumers inject this registry rather than referencing individual profiles, so the set of profiles is extensible without - * touching call sites. + * Discovers all {@link ExecutionProfile} implementations available on the classpath via {@link ServiceLoader} and exposes + * them by name. Consumers use this registry rather than referencing individual profiles, so the set of profiles is + * extensible without touching call sites. */ -@ApplicationScoped -public class ExecutionProfileRegistry { +public final class ExecutionProfileRegistry { private final Map profilesByName; - @Inject - public ExecutionProfileRegistry(Instance profiles) { - this.profilesByName = profiles.stream() + public ExecutionProfileRegistry() { + this(ServiceLoader.load(ExecutionProfile.class)); + } + + ExecutionProfileRegistry(Iterable profiles) { + this.profilesByName = StreamSupport.stream(profiles.spliterator(), false) .collect(Collectors.toUnmodifiableMap(ExecutionProfile::name, Function.identity())); } From 09c3c32641ffe10836d45bdbbff87de89ffae3d3 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Wed, 9 Sep 2026 15:03:36 +0200 Subject: [PATCH 09/19] feat: Add seed execution profile --- .../SeedExecutionProfile.java | 65 +++++++++++++++++++ .../executionprofile/ExecutionProfile.java | 32 ++++++--- .../ExecutionProfileParameter.java | 53 +++++++++++++++ ...internal.executionprofile.ExecutionProfile | 1 + .../SeedExecutionProfileTest.java | 48 ++++++++++++++ .../ExecutionProfileRegistryTest.java | 39 +++++++++++ 6 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java create mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileParameter.java create mode 100644 service/definition/src/main/resources/META-INF/services/ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile create mode 100644 service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java create mode 100644 service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java new file mode 100644 index 00000000000..c4610bdac31 --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java @@ -0,0 +1,65 @@ +package ai.timefold.solver.service.definition.impl.executionprofile; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.ThreadLocalRandom; + +import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile; +import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfileParameter; + +/** + * Runs the solver with a fixed random seed, making a run reproducible. + *

+ * The seed is an optional {@code seed} parameter; when it is not supplied, a random seed is generated and persisted with + * the run so the exact seed used can be read back and replayed. The seed is applied by mapping it to the Timefold Quarkus + * property {@code quarkus.timefold.solver.random-seed} (via its environment-variable form), which the solver pod applies to + * its {@code SolverConfig} at startup. + */ +public final class SeedExecutionProfile implements ExecutionProfile { + + static final String PARAMETER_SEED = "seed"; + + /** + * Environment-variable form of {@code quarkus.timefold.solver.default.random-seed}, consumed by the Timefold Quarkus + * extension. The {@code default} segment is the solver name ({@code TimefoldRuntimeConfig.DEFAULT_SOLVER_NAME}); it is + * correct for the usual single, unnamed solver a model defines. Note this property lives under a solver-name-keyed map, + * so injecting it purely via an environment variable may not be honored by SmallRye - see the profile's notes. + */ + static final String ENV_QUARKUS_RANDOM_SEED = "QUARKUS_TIMEFOLD_SOLVER_DEFAULT_RANDOM_SEED"; + + @Override + public String name() { + return "seed"; + } + + @Override + public String description() { + return "Runs the solver with a fixed random seed for reproducible results. " + + "A random seed is generated and recorded when none is supplied."; + } + + @Override + public List parameters() { + return List.of(new ExecutionProfileParameter(PARAMETER_SEED, + "Random seed to solve with. When omitted, a random seed is generated and recorded.", + ExecutionProfileParameter.Type.LONG, false)); + } + + @Override + public Map resolveParameters(Map inputs) { + String seed = inputs.get(PARAMETER_SEED); + if (seed == null) { + seed = Long.toString(ThreadLocalRandom.current().nextLong()); + } + return Map.of(PARAMETER_SEED, seed); + } + + @Override + public Map toEnvironment(Map resolvedParameters) { + String seed = resolvedParameters.get(PARAMETER_SEED); + if (seed == null) { + return Map.of(); + } + return Map.of(ENV_QUARKUS_RANDOM_SEED, seed); + } +} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java index d972e9933ee..c816208c5c7 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java @@ -1,14 +1,10 @@ package ai.timefold.solver.service.definition.internal.executionprofile; +import java.util.List; import java.util.Map; /** * A named, predefined runtime configuration a run can be started with. - *

- * Execution profiles describe how a run executes (diagnostics, logging, profiling, ...). This is an internal contract: - * model developers are not expected to implement or reference it. Implementations are provided by the solver service and - * the platform, and are discovered via {@link java.util.ServiceLoader}, so adding a new profile does not require editing - * any central registry. A run may activate several profiles at once. */ public interface ExecutionProfile { @@ -23,11 +19,29 @@ public interface ExecutionProfile { String description(); /** - * Additional configuration contributed by this profile, applied to the run's environment - each entry is injected as - * an environment variable into the solver pod. Keys must be valid environment-variable names. When multiple profiles - * are activated and define the same key, the resulting value is unspecified. Defaults to no extra configuration. + * The inputs this profile accepts. Acts as a whitelist: the platform validates submitted values against these and + * rejects anything not declared. Defaults to no parameters (a fixed profile that takes no input). */ - default Map properties() { + default List parameters() { + return List.of(); + } + + /** + * Turns the caller-supplied inputs (already validated against {@link #parameters()}) into the concrete parameter values + * to use for this run - applying defaults and generating values for absent parameters where applicable. Called once at + * submit time; the result is persisted with the run so it stays reproducible. Defaults to returning the inputs + * unchanged. + */ + default Map resolveParameters(Map inputs) { + return inputs; + } + + /** + * Maps the resolved parameter values (from {@link #resolveParameters(Map)}) to environment variables injected into the + * solver pod. Keys must be valid environment-variable names. When multiple profiles are activated and define the same + * key, the resulting value is unspecified. Defaults to no environment variables. + */ + default Map toEnvironment(Map resolvedParameters) { return Map.of(); } } diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileParameter.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileParameter.java new file mode 100644 index 00000000000..f851dade4fe --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileParameter.java @@ -0,0 +1,53 @@ +package ai.timefold.solver.service.definition.internal.executionprofile; + +/** + * Declares a single input an {@link ExecutionProfile} accepts. The declared parameters form the whitelist of inputs a + * caller may supply: the platform validates submitted values against them and rejects anything not declared, so a profile + * fully controls which values can reach the run. + * + * @param name stable identifier of the parameter, used in APIs + * @param description human readable description of the parameter + * @param type expected value type, used to validate submitted values + * @param required whether a value must be supplied by the caller (a profile may still generate one in + * {@link ExecutionProfile#resolveParameters}, in which case this should be {@code false}) + */ +public record ExecutionProfileParameter(String name, String description, Type type, boolean required) { + + public enum Type { + STRING, + INTEGER, + LONG, + BOOLEAN; + + /** + * Whether the given raw value is a valid representation of this type. + */ + public boolean isValid(String rawValue) { + if (rawValue == null) { + return false; + } + switch (this) { + case STRING: + return true; + case INTEGER: + try { + Integer.parseInt(rawValue); + return true; + } catch (NumberFormatException e) { + return false; + } + case LONG: + try { + Long.parseLong(rawValue); + return true; + } catch (NumberFormatException e) { + return false; + } + case BOOLEAN: + return "true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue); + default: + return false; + } + } + } +} diff --git a/service/definition/src/main/resources/META-INF/services/ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile b/service/definition/src/main/resources/META-INF/services/ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile new file mode 100644 index 00000000000..51bcdb4831d --- /dev/null +++ b/service/definition/src/main/resources/META-INF/services/ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile @@ -0,0 +1 @@ +ai.timefold.solver.service.definition.impl.executionprofile.SeedExecutionProfile diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java new file mode 100644 index 00000000000..860fe494aee --- /dev/null +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java @@ -0,0 +1,48 @@ +package ai.timefold.solver.service.definition.impl.executionprofile; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Map; + +import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfileParameter; + +import org.junit.jupiter.api.Test; + +class SeedExecutionProfileTest { + + private final SeedExecutionProfile profile = new SeedExecutionProfile(); + + @Test + void declaresOptionalLongSeedParameter() { + assertThat(profile.parameters()).singleElement() + .satisfies(parameter -> { + assertThat(parameter.name()).isEqualTo(SeedExecutionProfile.PARAMETER_SEED); + assertThat(parameter.type()).isEqualTo(ExecutionProfileParameter.Type.LONG); + assertThat(parameter.required()).isFalse(); + }); + } + + @Test + void keepsSuppliedSeed() { + Map resolved = profile.resolveParameters(Map.of(SeedExecutionProfile.PARAMETER_SEED, "42")); + assertThat(resolved).containsExactly(Map.entry(SeedExecutionProfile.PARAMETER_SEED, "42")); + assertThat(profile.toEnvironment(resolved)) + .containsExactly(Map.entry(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED, "42")); + } + + @Test + void generatesSeedWhenAbsent() { + Map resolved = profile.resolveParameters(Map.of()); + assertThat(resolved).containsOnlyKeys(SeedExecutionProfile.PARAMETER_SEED); + // The generated seed is recorded and is a valid long. + assertThat(resolved.get(SeedExecutionProfile.PARAMETER_SEED)).isNotNull(); + assertThat(Long.parseLong(resolved.get(SeedExecutionProfile.PARAMETER_SEED))).isNotNull(); + assertThat(profile.toEnvironment(resolved)) + .containsOnlyKeys(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED); + } + + @Test + void emitsNoEnvironmentWithoutSeed() { + assertThat(profile.toEnvironment(Map.of())).isEmpty(); + } +} diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java new file mode 100644 index 00000000000..890cce30814 --- /dev/null +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java @@ -0,0 +1,39 @@ +package ai.timefold.solver.service.definition.internal.executionprofile; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +class ExecutionProfileRegistryTest { + + @Test + void discoversRegisteredProfilesViaServiceLoader() { + ExecutionProfileRegistry registry = new ExecutionProfileRegistry(); + // The seed profile is registered as a service, so it must be discovered. + assertThat(registry.findByName("seed")).isPresent(); + assertThat(registry.all()).extracting(ExecutionProfile::name).contains("seed"); + } + + @Test + void findByNameReturnsEmptyForUnknownProfile() { + ExecutionProfileRegistry registry = new ExecutionProfileRegistry(); + assertThat(registry.findByName("does-not-exist")).isEmpty(); + } + + @Test + void indexesProfilesByName() { + ExecutionProfile profile = new NamedProfile("custom"); + ExecutionProfileRegistry registry = new ExecutionProfileRegistry(List.of(profile)); + assertThat(registry.findByName("custom")).containsSame(profile); + assertThat(registry.all()).containsExactly(profile); + } + + private record NamedProfile(String name) implements ExecutionProfile { + @Override + public String description() { + return name; + } + } +} From 93fa617bd5f9d97f5f9db6ee69371ea294b5baa2 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Fri, 11 Sep 2026 14:12:56 +0200 Subject: [PATCH 10/19] feat: add name to execution profiles --- .../executionprofile/ExecutionProfile.java | 8 +++++++- .../ExecutionProfileRegistry.java | 16 ++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java index d972e9933ee..c89eedb44e7 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java @@ -13,7 +13,13 @@ public interface ExecutionProfile { /** - * Stable identifier of the profile, used in APIs and permissions. Must be unique across all implementations. + * Stable, unique identifier of the profile. + */ + String id(); + + /** + * Human readable name of the profile, for display in user interfaces. Unlike {@link #id()} this is not a stable key: + * it may be changed or localized without breaking existing references. */ String name(); diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java index f672b823974..c976796f50c 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java @@ -10,27 +10,27 @@ /** * Discovers all {@link ExecutionProfile} implementations available on the classpath via {@link ServiceLoader} and exposes - * them by name. Consumers use this registry rather than referencing individual profiles, so the set of profiles is - * extensible without touching call sites. + * them by their stable {@link ExecutionProfile#id() id}. Consumers use this registry rather than referencing individual + * profiles, so the set of profiles is extensible without touching call sites. */ public final class ExecutionProfileRegistry { - private final Map profilesByName; + private final Map profilesById; public ExecutionProfileRegistry() { this(ServiceLoader.load(ExecutionProfile.class)); } ExecutionProfileRegistry(Iterable profiles) { - this.profilesByName = StreamSupport.stream(profiles.spliterator(), false) - .collect(Collectors.toUnmodifiableMap(ExecutionProfile::name, Function.identity())); + this.profilesById = StreamSupport.stream(profiles.spliterator(), false) + .collect(Collectors.toUnmodifiableMap(ExecutionProfile::id, Function.identity())); } public Collection all() { - return profilesByName.values(); + return profilesById.values(); } - public Optional findByName(String name) { - return Optional.ofNullable(profilesByName.get(name)); + public Optional findById(String id) { + return Optional.ofNullable(profilesById.get(id)); } } From b78efb47079fe307e5ecbe2a379383e9959daf98 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Fri, 11 Sep 2026 14:36:00 +0200 Subject: [PATCH 11/19] feat: add name to execution profiles --- .../SeedExecutionProfile.java | 7 +++++- .../executionprofile/ExecutionProfile.java | 10 +++++++- .../ExecutionProfileRegistry.java | 16 ++++++------- .../ExecutionProfileRegistryTest.java | 23 +++++++++++-------- 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java index c4610bdac31..5c3323f56ad 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java @@ -28,10 +28,15 @@ public final class SeedExecutionProfile implements ExecutionProfile { static final String ENV_QUARKUS_RANDOM_SEED = "QUARKUS_TIMEFOLD_SOLVER_DEFAULT_RANDOM_SEED"; @Override - public String name() { + public String id() { return "seed"; } + @Override + public String name() { + return "Fixed random seed"; + } + @Override public String description() { return "Runs the solver with a fixed random seed for reproducible results. " diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java index c816208c5c7..79cde06f937 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java @@ -9,7 +9,15 @@ public interface ExecutionProfile { /** - * Stable identifier of the profile, used in APIs and permissions. Must be unique across all implementations. + * Stable, unique identifier of the profile. Used everywhere the profile is referenced programmatically - APIs, + * permissions, tags, feature-flag keys - so it must never change once published, and must be unique across all + * implementations. + */ + String id(); + + /** + * Human readable name of the profile, for display in user interfaces. Unlike {@link #id()} this is not a stable key: + * it may be changed or localized without breaking existing references. */ String name(); diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java index f672b823974..c976796f50c 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistry.java @@ -10,27 +10,27 @@ /** * Discovers all {@link ExecutionProfile} implementations available on the classpath via {@link ServiceLoader} and exposes - * them by name. Consumers use this registry rather than referencing individual profiles, so the set of profiles is - * extensible without touching call sites. + * them by their stable {@link ExecutionProfile#id() id}. Consumers use this registry rather than referencing individual + * profiles, so the set of profiles is extensible without touching call sites. */ public final class ExecutionProfileRegistry { - private final Map profilesByName; + private final Map profilesById; public ExecutionProfileRegistry() { this(ServiceLoader.load(ExecutionProfile.class)); } ExecutionProfileRegistry(Iterable profiles) { - this.profilesByName = StreamSupport.stream(profiles.spliterator(), false) - .collect(Collectors.toUnmodifiableMap(ExecutionProfile::name, Function.identity())); + this.profilesById = StreamSupport.stream(profiles.spliterator(), false) + .collect(Collectors.toUnmodifiableMap(ExecutionProfile::id, Function.identity())); } public Collection all() { - return profilesByName.values(); + return profilesById.values(); } - public Optional findByName(String name) { - return Optional.ofNullable(profilesByName.get(name)); + public Optional findById(String id) { + return Optional.ofNullable(profilesById.get(id)); } } diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java index 890cce30814..c0ed90adbde 100644 --- a/service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java @@ -12,28 +12,33 @@ class ExecutionProfileRegistryTest { void discoversRegisteredProfilesViaServiceLoader() { ExecutionProfileRegistry registry = new ExecutionProfileRegistry(); // The seed profile is registered as a service, so it must be discovered. - assertThat(registry.findByName("seed")).isPresent(); - assertThat(registry.all()).extracting(ExecutionProfile::name).contains("seed"); + assertThat(registry.findById("seed")).isPresent(); + assertThat(registry.all()).extracting(ExecutionProfile::id).contains("seed"); } @Test - void findByNameReturnsEmptyForUnknownProfile() { + void findByIdReturnsEmptyForUnknownProfile() { ExecutionProfileRegistry registry = new ExecutionProfileRegistry(); - assertThat(registry.findByName("does-not-exist")).isEmpty(); + assertThat(registry.findById("does-not-exist")).isEmpty(); } @Test - void indexesProfilesByName() { - ExecutionProfile profile = new NamedProfile("custom"); + void indexesProfilesById() { + ExecutionProfile profile = new IdProfile("custom"); ExecutionProfileRegistry registry = new ExecutionProfileRegistry(List.of(profile)); - assertThat(registry.findByName("custom")).containsSame(profile); + assertThat(registry.findById("custom")).containsSame(profile); assertThat(registry.all()).containsExactly(profile); } - private record NamedProfile(String name) implements ExecutionProfile { + private record IdProfile(String id) implements ExecutionProfile { + @Override + public String name() { + return id; + } + @Override public String description() { - return name; + return id; } } } From da9707fbd4224023dcd548600e853acfaec33e47 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Mon, 14 Sep 2026 14:10:58 +0200 Subject: [PATCH 12/19] feat: execution profile parameters given by the caller --- .../SeedExecutionProfile.java | 22 ++++---- .../executionprofile/ExecutionProfile.java | 22 +++----- .../ExecutionProfileParameter.java | 53 ------------------- .../SeedExecutionProfileTest.java | 28 +++++----- 4 files changed, 32 insertions(+), 93 deletions(-) delete mode 100644 service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileParameter.java diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java index 5c3323f56ad..13da8eaf62b 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java @@ -1,11 +1,9 @@ package ai.timefold.solver.service.definition.impl.executionprofile; -import java.util.List; import java.util.Map; import java.util.concurrent.ThreadLocalRandom; import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile; -import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfileParameter; /** * Runs the solver with a fixed random seed, making a run reproducible. @@ -44,17 +42,17 @@ public String description() { } @Override - public List parameters() { - return List.of(new ExecutionProfileParameter(PARAMETER_SEED, - "Random seed to solve with. When omitted, a random seed is generated and recorded.", - ExecutionProfileParameter.Type.LONG, false)); - } - - @Override - public Map resolveParameters(Map inputs) { - String seed = inputs.get(PARAMETER_SEED); + public Map resolveParameters(Map options) { + String seed = options == null ? null : options.get(PARAMETER_SEED); if (seed == null) { - seed = Long.toString(ThreadLocalRandom.current().nextLong()); + return Map.of(PARAMETER_SEED, Long.toString(ThreadLocalRandom.current().nextLong())); + } + try { + Long.parseLong(seed); + } catch (NumberFormatException e) { + throw new IllegalArgumentException( + "Execution profile '" + id() + "' requires option '" + PARAMETER_SEED + "' to be a long, but was: " + + seed); } return Map.of(PARAMETER_SEED, seed); } diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java index a4951779f04..76121e01333 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java @@ -1,6 +1,5 @@ package ai.timefold.solver.service.definition.internal.executionprofile; -import java.util.List; import java.util.Map; /** @@ -25,21 +24,14 @@ public interface ExecutionProfile { String description(); /** - * The inputs this profile accepts. Acts as a whitelist: the platform validates submitted values against these and - * rejects anything not declared. Defaults to no parameters (a fixed profile that takes no input). + * Reads the values this profile cares about from the run's options (as supplied via + * {@code RunConfiguration.options}), applying defaults, generating values for absent inputs, and validating them. + * The profile picks out only the keys it recognizes and ignores the rest, since the options map is shared with other + * run configuration. Called once at submit time; the returned values are persisted with the run so it stays + * reproducible. Defaults to no parameters. */ - default List parameters() { - return List.of(); - } - - /** - * Turns the caller-supplied inputs (already validated against {@link #parameters()}) into the concrete parameter values - * to use for this run - applying defaults and generating values for absent parameters where applicable. Called once at - * submit time; the result is persisted with the run so it stays reproducible. Defaults to returning the inputs - * unchanged. - */ - default Map resolveParameters(Map inputs) { - return inputs; + default Map resolveParameters(Map options) { + return Map.of(); } /** diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileParameter.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileParameter.java deleted file mode 100644 index f851dade4fe..00000000000 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileParameter.java +++ /dev/null @@ -1,53 +0,0 @@ -package ai.timefold.solver.service.definition.internal.executionprofile; - -/** - * Declares a single input an {@link ExecutionProfile} accepts. The declared parameters form the whitelist of inputs a - * caller may supply: the platform validates submitted values against them and rejects anything not declared, so a profile - * fully controls which values can reach the run. - * - * @param name stable identifier of the parameter, used in APIs - * @param description human readable description of the parameter - * @param type expected value type, used to validate submitted values - * @param required whether a value must be supplied by the caller (a profile may still generate one in - * {@link ExecutionProfile#resolveParameters}, in which case this should be {@code false}) - */ -public record ExecutionProfileParameter(String name, String description, Type type, boolean required) { - - public enum Type { - STRING, - INTEGER, - LONG, - BOOLEAN; - - /** - * Whether the given raw value is a valid representation of this type. - */ - public boolean isValid(String rawValue) { - if (rawValue == null) { - return false; - } - switch (this) { - case STRING: - return true; - case INTEGER: - try { - Integer.parseInt(rawValue); - return true; - } catch (NumberFormatException e) { - return false; - } - case LONG: - try { - Long.parseLong(rawValue); - return true; - } catch (NumberFormatException e) { - return false; - } - case BOOLEAN: - return "true".equalsIgnoreCase(rawValue) || "false".equalsIgnoreCase(rawValue); - default: - return false; - } - } - } -} diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java index 860fe494aee..5eabf345277 100644 --- a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java @@ -1,27 +1,16 @@ package ai.timefold.solver.service.definition.impl.executionprofile; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Map; -import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfileParameter; - import org.junit.jupiter.api.Test; class SeedExecutionProfileTest { private final SeedExecutionProfile profile = new SeedExecutionProfile(); - @Test - void declaresOptionalLongSeedParameter() { - assertThat(profile.parameters()).singleElement() - .satisfies(parameter -> { - assertThat(parameter.name()).isEqualTo(SeedExecutionProfile.PARAMETER_SEED); - assertThat(parameter.type()).isEqualTo(ExecutionProfileParameter.Type.LONG); - assertThat(parameter.required()).isFalse(); - }); - } - @Test void keepsSuppliedSeed() { Map resolved = profile.resolveParameters(Map.of(SeedExecutionProfile.PARAMETER_SEED, "42")); @@ -35,12 +24,25 @@ void generatesSeedWhenAbsent() { Map resolved = profile.resolveParameters(Map.of()); assertThat(resolved).containsOnlyKeys(SeedExecutionProfile.PARAMETER_SEED); // The generated seed is recorded and is a valid long. - assertThat(resolved.get(SeedExecutionProfile.PARAMETER_SEED)).isNotNull(); assertThat(Long.parseLong(resolved.get(SeedExecutionProfile.PARAMETER_SEED))).isNotNull(); assertThat(profile.toEnvironment(resolved)) .containsOnlyKeys(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED); } + @Test + void ignoresUnrelatedOptions() { + // The seed profile only reads its own option key; other run options are left alone. + Map resolved = + profile.resolveParameters(Map.of("solver", "fast", SeedExecutionProfile.PARAMETER_SEED, "7")); + assertThat(resolved).containsExactly(Map.entry(SeedExecutionProfile.PARAMETER_SEED, "7")); + } + + @Test + void rejectsNonLongSeed() { + assertThatThrownBy(() -> profile.resolveParameters(Map.of(SeedExecutionProfile.PARAMETER_SEED, "not-a-number"))) + .isInstanceOf(IllegalArgumentException.class); + } + @Test void emitsNoEnvironmentWithoutSeed() { assertThat(profile.toEnvironment(Map.of())).isEmpty(); From 57fe85342c5e3d41c207efdf5c15f335c8b9d1d7 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Tue, 15 Sep 2026 11:15:15 +0200 Subject: [PATCH 13/19] fix: sonarlog issues --- .../impl/executionprofile/SeedExecutionProfileTest.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java index 5eabf345277..7efffbcb6fa 100644 --- a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java @@ -1,6 +1,7 @@ package ai.timefold.solver.service.definition.impl.executionprofile; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Map; @@ -24,7 +25,8 @@ void generatesSeedWhenAbsent() { Map resolved = profile.resolveParameters(Map.of()); assertThat(resolved).containsOnlyKeys(SeedExecutionProfile.PARAMETER_SEED); // The generated seed is recorded and is a valid long. - assertThat(Long.parseLong(resolved.get(SeedExecutionProfile.PARAMETER_SEED))).isNotNull(); + assertThatCode(() -> Long.parseLong(resolved.get(SeedExecutionProfile.PARAMETER_SEED))) + .doesNotThrowAnyException(); assertThat(profile.toEnvironment(resolved)) .containsOnlyKeys(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED); } @@ -39,7 +41,8 @@ void ignoresUnrelatedOptions() { @Test void rejectsNonLongSeed() { - assertThatThrownBy(() -> profile.resolveParameters(Map.of(SeedExecutionProfile.PARAMETER_SEED, "not-a-number"))) + Map options = Map.of(SeedExecutionProfile.PARAMETER_SEED, "not-a-number"); + assertThatThrownBy(() -> profile.resolveParameters(options)) .isInstanceOf(IllegalArgumentException.class); } From 90bb6b2a74870468d7592715be87a6e63c6bfbee Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Tue, 15 Sep 2026 17:24:38 +0200 Subject: [PATCH 14/19] remove toProperties from execution profiles --- .../SeedExecutionProfile.java | 24 ++++--------- .../executionprofile/ExecutionProfile.java | 21 ++++-------- .../SeedExecutionProfileTest.java | 34 ++++++------------- 3 files changed, 23 insertions(+), 56 deletions(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java index 13da8eaf62b..ef500fcd454 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java @@ -1,17 +1,16 @@ package ai.timefold.solver.service.definition.impl.executionprofile; import java.util.Map; -import java.util.concurrent.ThreadLocalRandom; import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile; /** * Runs the solver with a fixed random seed, making a run reproducible. *

- * The seed is an optional {@code seed} parameter; when it is not supplied, a random seed is generated and persisted with - * the run so the exact seed used can be read back and replayed. The seed is applied by mapping it to the Timefold Quarkus - * property {@code quarkus.timefold.solver.random-seed} (via its environment-variable form), which the solver pod applies to - * its {@code SolverConfig} at startup. + * The seed is taken from the optional {@code seed} run option. When it is not supplied, the profile contributes nothing and + * the solver uses its own default randomness - the profile never invents a seed. A supplied seed is applied by mapping it to + * the Timefold Quarkus property {@code quarkus.timefold.solver.random-seed} (via its environment-variable form), which the + * solver pod applies to its {@code SolverConfig} at startup. */ public final class SeedExecutionProfile implements ExecutionProfile { @@ -38,14 +37,14 @@ public String name() { @Override public String description() { return "Runs the solver with a fixed random seed for reproducible results. " - + "A random seed is generated and recorded when none is supplied."; + + "The seed must be supplied as the 'seed' run option."; } @Override - public Map resolveParameters(Map options) { + public Map toEnvironment(Map options) { String seed = options == null ? null : options.get(PARAMETER_SEED); if (seed == null) { - return Map.of(PARAMETER_SEED, Long.toString(ThreadLocalRandom.current().nextLong())); + return Map.of(); } try { Long.parseLong(seed); @@ -54,15 +53,6 @@ public Map resolveParameters(Map options) { "Execution profile '" + id() + "' requires option '" + PARAMETER_SEED + "' to be a long, but was: " + seed); } - return Map.of(PARAMETER_SEED, seed); - } - - @Override - public Map toEnvironment(Map resolvedParameters) { - String seed = resolvedParameters.get(PARAMETER_SEED); - if (seed == null) { - return Map.of(); - } return Map.of(ENV_QUARKUS_RANDOM_SEED, seed); } } diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java index 76121e01333..7d423f68b4d 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java @@ -24,22 +24,13 @@ public interface ExecutionProfile { String description(); /** - * Reads the values this profile cares about from the run's options (as supplied via - * {@code RunConfiguration.options}), applying defaults, generating values for absent inputs, and validating them. - * The profile picks out only the keys it recognizes and ignores the rest, since the options map is shared with other - * run configuration. Called once at submit time; the returned values are persisted with the run so it stays - * reproducible. Defaults to no parameters. + * Reads the values this profile recognizes from the run's options (as supplied via {@code RunConfiguration.options}), + * validates them, and maps them to environment variables injected into the solver pod. The profile picks out only the + * keys it recognizes and ignores the rest, since the options map is shared with other run configuration. It must not + * invent values the caller did not supply. Keys must be valid environment-variable names. When multiple profiles are + * activated and define the same key, the resulting value is unspecified. Defaults to no environment variables. */ - default Map resolveParameters(Map options) { - return Map.of(); - } - - /** - * Maps the resolved parameter values (from {@link #resolveParameters(Map)}) to environment variables injected into the - * solver pod. Keys must be valid environment-variable names. When multiple profiles are activated and define the same - * key, the resulting value is unspecified. Defaults to no environment variables. - */ - default Map toEnvironment(Map resolvedParameters) { + default Map toEnvironment(Map options) { return Map.of(); } } diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java index 7efffbcb6fa..b2bbcf035d5 100644 --- a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java @@ -1,7 +1,6 @@ package ai.timefold.solver.service.definition.impl.executionprofile; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Map; @@ -13,41 +12,28 @@ class SeedExecutionProfileTest { private final SeedExecutionProfile profile = new SeedExecutionProfile(); @Test - void keepsSuppliedSeed() { - Map resolved = profile.resolveParameters(Map.of(SeedExecutionProfile.PARAMETER_SEED, "42")); - assertThat(resolved).containsExactly(Map.entry(SeedExecutionProfile.PARAMETER_SEED, "42")); - assertThat(profile.toEnvironment(resolved)) + void mapsSuppliedSeedToEnvironment() { + assertThat(profile.toEnvironment(Map.of(SeedExecutionProfile.PARAMETER_SEED, "42"))) .containsExactly(Map.entry(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED, "42")); } @Test - void generatesSeedWhenAbsent() { - Map resolved = profile.resolveParameters(Map.of()); - assertThat(resolved).containsOnlyKeys(SeedExecutionProfile.PARAMETER_SEED); - // The generated seed is recorded and is a valid long. - assertThatCode(() -> Long.parseLong(resolved.get(SeedExecutionProfile.PARAMETER_SEED))) - .doesNotThrowAnyException(); - assertThat(profile.toEnvironment(resolved)) - .containsOnlyKeys(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED); + void ignoresUnrelatedOptions() { + // The seed profile only reads its own option key; other run options are left alone. + assertThat(profile.toEnvironment(Map.of("solver", "fast", SeedExecutionProfile.PARAMETER_SEED, "7"))) + .containsExactly(Map.entry(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED, "7")); } @Test - void ignoresUnrelatedOptions() { - // The seed profile only reads its own option key; other run options are left alone. - Map resolved = - profile.resolveParameters(Map.of("solver", "fast", SeedExecutionProfile.PARAMETER_SEED, "7")); - assertThat(resolved).containsExactly(Map.entry(SeedExecutionProfile.PARAMETER_SEED, "7")); + void emitsNoEnvironmentWhenSeedAbsent() { + // No seed supplied: the profile contributes nothing (it never invents a seed). + assertThat(profile.toEnvironment(Map.of())).isEmpty(); } @Test void rejectsNonLongSeed() { Map options = Map.of(SeedExecutionProfile.PARAMETER_SEED, "not-a-number"); - assertThatThrownBy(() -> profile.resolveParameters(options)) + assertThatThrownBy(() -> profile.toEnvironment(options)) .isInstanceOf(IllegalArgumentException.class); } - - @Test - void emitsNoEnvironmentWithoutSeed() { - assertThat(profile.toEnvironment(Map.of())).isEmpty(); - } } From 52390fa2f6b79edd1c59141b7068146eb520ed4c Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Tue, 15 Sep 2026 17:29:55 +0200 Subject: [PATCH 15/19] Fail if seed parameter is not supplied --- .../impl/executionprofile/SeedExecutionProfile.java | 11 ++++++----- .../executionprofile/SeedExecutionProfileTest.java | 7 ++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java index ef500fcd454..a1aa36355e2 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java @@ -7,10 +7,10 @@ /** * Runs the solver with a fixed random seed, making a run reproducible. *

- * The seed is taken from the optional {@code seed} run option. When it is not supplied, the profile contributes nothing and - * the solver uses its own default randomness - the profile never invents a seed. A supplied seed is applied by mapping it to - * the Timefold Quarkus property {@code quarkus.timefold.solver.random-seed} (via its environment-variable form), which the - * solver pod applies to its {@code SolverConfig} at startup. + * The seed is taken from the required {@code seed} run option; selecting this profile without supplying it is rejected. The + * profile never invents a seed. The supplied seed is applied by mapping it to the Timefold Quarkus property + * {@code quarkus.timefold.solver.random-seed} (via its environment-variable form), which the solver pod applies to its + * {@code SolverConfig} at startup. */ public final class SeedExecutionProfile implements ExecutionProfile { @@ -44,7 +44,8 @@ public String description() { public Map toEnvironment(Map options) { String seed = options == null ? null : options.get(PARAMETER_SEED); if (seed == null) { - return Map.of(); + throw new IllegalArgumentException( + "Execution profile '" + id() + "' requires the '" + PARAMETER_SEED + "' option to be supplied."); } try { Long.parseLong(seed); diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java index b2bbcf035d5..5b7271a5103 100644 --- a/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java @@ -25,9 +25,10 @@ void ignoresUnrelatedOptions() { } @Test - void emitsNoEnvironmentWhenSeedAbsent() { - // No seed supplied: the profile contributes nothing (it never invents a seed). - assertThat(profile.toEnvironment(Map.of())).isEmpty(); + void failsWhenSeedMissing() { + // Selecting the seed profile without supplying a seed is an error - the profile never invents one. + assertThatThrownBy(() -> profile.toEnvironment(Map.of())) + .isInstanceOf(IllegalArgumentException.class); } @Test From 1be94392be9ae1a2a1d528bf01e8343ecaa5219c Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Wed, 23 Sep 2026 15:44:22 +0200 Subject: [PATCH 16/19] feat: Update docs --- .../pages/running-timefold-solver/service/rest-api.adoc | 8 ++++++-- service/definition/src/build/revapi-differences.json | 8 ++++++++ .../internal/executionprofile/ExecutionProfile.java | 6 +++--- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc index 67813933cbd..847399c9a96 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc @@ -131,7 +131,10 @@ Additionally, more fields might be added for specific model implementations. "unimprovedSpentLimit": "PT10S" }, "maxThreadCount": 1, - "tags": [] + "tags": [], + "options": { + "seed": "42" + } }, "model": { "overrides": "" @@ -141,12 +144,13 @@ Additionally, more fields might be added for specific model implementations. } ---- -The `run` configuration has four fields: +The `run` configuration has five fields: - `name`: The run name (if empty, it will be generated). - `maxThreadCount`: The maximum thread count, which indicates the maximum number of threads to be used for solving. If not provided, 1 will be used. - `tags`: The tags, which are a set of optional tags to be assigned to the run. +- `options`: An optional map of string values consumed by the active execution profiles, for example the `seed` option read by the fixed random seed profile. - `termination`: The termination properties determining how long the solver should run: * `spentLimit`: The maximum duration to keep the solver running (ISO 8601 Duration). diff --git a/service/definition/src/build/revapi-differences.json b/service/definition/src/build/revapi-differences.json index e3a7539e6cf..ae2a6823f5a 100644 --- a/service/definition/src/build/revapi-differences.json +++ b/service/definition/src/build/revapi-differences.json @@ -38,6 +38,14 @@ "new": "interface ai.timefold.solver.service.definition.api.ModelConvertor, ModelInput_ extends ai.timefold.solver.service.definition.api.ModelInput, ModelConfigurationOverrides_ extends ai.timefold.solver.service.definition.api.ModelConfigOverrides, SolverModel_ extends ai.timefold.solver.service.definition.api.SolverModel, ModelOutput_ extends ai.timefold.solver.service.definition.api.ModelOutput>", "annotationType": "org.jspecify.annotations.NullMarked", "justification": "Documents existing nullability contract; not a behavioral or binary-incompatible change." + }, + { + "ignore": true, + "code": "java.annotation.removed", + "old": "class ai.timefold.solver.service.definition.api.domain.RunConfiguration", + "new": "class ai.timefold.solver.service.definition.api.domain.RunConfiguration", + "annotationType": "org.eclipse.microprofile.openapi.annotations.media.Schema", + "justification": "RunConfiguration now carries a free-form options map, so the class-level @Schema(additionalProperties = Schema.False.class) was intentionally removed to allow arbitrary option keys." } ] } diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java index 7d423f68b4d..3033a3bf8fb 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfile.java @@ -26,9 +26,9 @@ public interface ExecutionProfile { /** * Reads the values this profile recognizes from the run's options (as supplied via {@code RunConfiguration.options}), * validates them, and maps them to environment variables injected into the solver pod. The profile picks out only the - * keys it recognizes and ignores the rest, since the options map is shared with other run configuration. It must not - * invent values the caller did not supply. Keys must be valid environment-variable names. When multiple profiles are - * activated and define the same key, the resulting value is unspecified. Defaults to no environment variables. + * keys it recognizes and ignores the rest, since the options map is shared with other run configuration. Keys must + * be valid environment-variable names. If two activated profiles map to the same environment variable, the run is + * rejected. Defaults to no environment variables. */ default Map toEnvironment(Map options) { return Map.of(); From 6d0c846f6fe803ecfe915f58039ed762a5a5dd11 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Wed, 23 Sep 2026 16:14:00 +0200 Subject: [PATCH 17/19] documentation: add options in documentation --- .../running-timefold-solver/service/consumer-guide.adoc | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc index 4399e9e5534..2896344a5c0 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc @@ -109,7 +109,10 @@ The request body is a JSON envelope with one required top-level field (`modelInp "unimprovedSpentLimit": "PT30S" }, "maxThreadCount": 1, - "tags": ["production", "monday"] + "tags": ["production", "monday"], + "options": { + "seed": "42" + } } }, "modelInput": { <1> @@ -133,6 +136,9 @@ The `run` configuration controls how long the solver runs: | `tags` | Optional labels for filtering or grouping runs. + +| `options` +| Optional map of string values consumed by the active execution profiles, such as the `seed` option for the fixed random seed profile. |=== `Termination` settings can be specified per dataset to override the hardcoded termination settings. From d384840a53128dad7211e525348f582b5cf5cf05 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Wed, 30 Sep 2026 09:41:04 +0200 Subject: [PATCH 18/19] feat: make options in RunConfigurations visible --- .../solver/service/definition/api/domain/RunConfiguration.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java index 6b503a17df6..34bd356e3c9 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java @@ -22,7 +22,8 @@ public record RunConfiguration( minimum = "1") @JsonInclude(JsonInclude.Include.NON_EMPTY) @Positive Integer maxThreadCount, @JsonInclude(JsonInclude.Include.NON_NULL) @Schema( description = "Optional tags to be assigned to the dataset.") @Size(max = 100) Set tags, - @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(hidden = true) Map options) { + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(nullable = true, + description = "Optional map of string run options consumed by the active execution profiles.") Map options) { public RunConfiguration(String name, SolverTerminationConfig termination, Integer maxThreadCount, Set tags) { this(name, termination, maxThreadCount, tags, null); From a1e352bd1b85a1764ce49742a57f102832b43302 Mon Sep 17 00:00:00 2001 From: Diogo Ferreira Date: Wed, 30 Sep 2026 13:22:44 +0200 Subject: [PATCH 19/19] feat: make execution profile options input invisible to client --- .../running-timefold-solver/service/consumer-guide.adoc | 8 +------- .../pages/running-timefold-solver/service/rest-api.adoc | 8 ++------ .../service/definition/api/domain/RunConfiguration.java | 3 +-- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc index 2896344a5c0..4399e9e5534 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/consumer-guide.adoc @@ -109,10 +109,7 @@ The request body is a JSON envelope with one required top-level field (`modelInp "unimprovedSpentLimit": "PT30S" }, "maxThreadCount": 1, - "tags": ["production", "monday"], - "options": { - "seed": "42" - } + "tags": ["production", "monday"] } }, "modelInput": { <1> @@ -136,9 +133,6 @@ The `run` configuration controls how long the solver runs: | `tags` | Optional labels for filtering or grouping runs. - -| `options` -| Optional map of string values consumed by the active execution profiles, such as the `seed` option for the fixed random seed profile. |=== `Termination` settings can be specified per dataset to override the hardcoded termination settings. diff --git a/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc b/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc index 847399c9a96..67813933cbd 100644 --- a/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc +++ b/docs/src/modules/ROOT/pages/running-timefold-solver/service/rest-api.adoc @@ -131,10 +131,7 @@ Additionally, more fields might be added for specific model implementations. "unimprovedSpentLimit": "PT10S" }, "maxThreadCount": 1, - "tags": [], - "options": { - "seed": "42" - } + "tags": [] }, "model": { "overrides": "" @@ -144,13 +141,12 @@ Additionally, more fields might be added for specific model implementations. } ---- -The `run` configuration has five fields: +The `run` configuration has four fields: - `name`: The run name (if empty, it will be generated). - `maxThreadCount`: The maximum thread count, which indicates the maximum number of threads to be used for solving. If not provided, 1 will be used. - `tags`: The tags, which are a set of optional tags to be assigned to the run. -- `options`: An optional map of string values consumed by the active execution profiles, for example the `seed` option read by the fixed random seed profile. - `termination`: The termination properties determining how long the solver should run: * `spentLimit`: The maximum duration to keep the solver running (ISO 8601 Duration). diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java index 34bd356e3c9..6b503a17df6 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/domain/RunConfiguration.java @@ -22,8 +22,7 @@ public record RunConfiguration( minimum = "1") @JsonInclude(JsonInclude.Include.NON_EMPTY) @Positive Integer maxThreadCount, @JsonInclude(JsonInclude.Include.NON_NULL) @Schema( description = "Optional tags to be assigned to the dataset.") @Size(max = 100) Set tags, - @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(nullable = true, - description = "Optional map of string run options consumed by the active execution profiles.") Map options) { + @JsonInclude(JsonInclude.Include.NON_NULL) @Schema(hidden = true) Map options) { public RunConfiguration(String name, SolverTerminationConfig termination, Integer maxThreadCount, Set tags) { this(name, termination, maxThreadCount, tags, null);