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/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/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..a1aa36355e2 --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfile.java @@ -0,0 +1,59 @@ +package ai.timefold.solver.service.definition.impl.executionprofile; + +import java.util.Map; + +import ai.timefold.solver.service.definition.internal.executionprofile.ExecutionProfile; + +/** + * Runs the solver with a fixed random seed, making a run reproducible. + *

+ * 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 { + + 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 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. " + + "The seed must be supplied as the 'seed' run option."; + } + + @Override + public Map toEnvironment(Map options) { + String seed = options == null ? null : options.get(PARAMETER_SEED); + if (seed == null) { + throw new IllegalArgumentException( + "Execution profile '" + id() + "' requires the '" + PARAMETER_SEED + "' option to be supplied."); + } + 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(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 c89eedb44e7..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 @@ -4,11 +4,6 @@ /** * 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 { @@ -29,11 +24,13 @@ 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. + * 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. 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 properties() { + default Map toEnvironment(Map options) { return Map.of(); } } 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/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/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..5b7271a5103 --- /dev/null +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/impl/executionprofile/SeedExecutionProfileTest.java @@ -0,0 +1,40 @@ +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 org.junit.jupiter.api.Test; + +class SeedExecutionProfileTest { + + private final SeedExecutionProfile profile = new SeedExecutionProfile(); + + @Test + void mapsSuppliedSeedToEnvironment() { + assertThat(profile.toEnvironment(Map.of(SeedExecutionProfile.PARAMETER_SEED, "42"))) + .containsExactly(Map.entry(SeedExecutionProfile.ENV_QUARKUS_RANDOM_SEED, "42")); + } + + @Test + 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 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 + void rejectsNonLongSeed() { + Map options = Map.of(SeedExecutionProfile.PARAMETER_SEED, "not-a-number"); + assertThatThrownBy(() -> profile.toEnvironment(options)) + .isInstanceOf(IllegalArgumentException.class); + } +} 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..c0ed90adbde --- /dev/null +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/internal/executionprofile/ExecutionProfileRegistryTest.java @@ -0,0 +1,44 @@ +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.findById("seed")).isPresent(); + assertThat(registry.all()).extracting(ExecutionProfile::id).contains("seed"); + } + + @Test + void findByIdReturnsEmptyForUnknownProfile() { + ExecutionProfileRegistry registry = new ExecutionProfileRegistry(); + assertThat(registry.findById("does-not-exist")).isEmpty(); + } + + @Test + void indexesProfilesById() { + ExecutionProfile profile = new IdProfile("custom"); + ExecutionProfileRegistry registry = new ExecutionProfileRegistry(List.of(profile)); + assertThat(registry.findById("custom")).containsSame(profile); + assertThat(registry.all()).containsExactly(profile); + } + + private record IdProfile(String id) implements ExecutionProfile { + @Override + public String name() { + return id; + } + + @Override + public String description() { + return id; + } + } +} 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 075df91128a..c5832828bf6 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; @@ -337,6 +347,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)