diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResourceAware.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResourceAware.java new file mode 100644 index 00000000000..f550cdc8d7d --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResourceAware.java @@ -0,0 +1,12 @@ +package ai.timefold.solver.service.definition.api.usage; + +/** + * Implemented by optimization models that can identify their {@link UniqueResources}. + */ +public interface UniqueResourceAware { + + /** + * Identifies the unique resources of the schedule. + */ + UniqueResources getUniqueResources(); +} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResourceReporter.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResourceReporter.java new file mode 100644 index 00000000000..dd86ec991b6 --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResourceReporter.java @@ -0,0 +1,24 @@ +package ai.timefold.solver.service.definition.api.usage; + +/** + * Reports {@link UniqueResources} identified by the optimization model for usage metering. + */ +public interface UniqueResourceReporter { + + /** + * Reports {@link UniqueResources} for a given operation. + * + * @param operationId ID of the operation to report resources for + * @param uniqueResources {@link UniqueResources} used in the given operation + */ + void reportUniqueResources(String operationId, UniqueResources uniqueResources); + + /** + * Reports {@link UniqueResources} for a given operation. + * + * @param operationId ID of the operation to report resources for + * @param datasetId ID of the dataset + * @param uniqueResources {@link UniqueResources} used in the given operation + */ + void reportUniqueResources(String operationId, String datasetId, UniqueResources uniqueResources); +} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResources.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResources.java new file mode 100644 index 00000000000..1fd4ca57aff --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/api/usage/UniqueResources.java @@ -0,0 +1,65 @@ +package ai.timefold.solver.service.definition.api.usage; + +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Objects; + +/** + * Contains IDs of unique resources identified by the optimization model. + */ +public record UniqueResources(Map> uniqueResourceIdsPerType) { + + /** + * Constructs the {@link UniqueResources} from a single resource type. + */ + public static UniqueResources from(String resourceType, Collection uniqueResourceIds) { + return new UniqueResources(Map.of(resourceType, uniqueResourceIds)); + } + + /** + * Creates a new builder to register unique resources for multiple resource types. + */ + public static UniqueResourcesBuilder builder() { + return new UniqueResourcesBuilder(); + } + + public UniqueResources { + Objects.requireNonNull(uniqueResourceIdsPerType); + Map> copy = new LinkedHashMap<>(); + // Defensive deep copy to maintain the original order, uniqueness and avoid random changes. + for (var originalEntry : uniqueResourceIdsPerType.entrySet()) { + Objects.requireNonNull(originalEntry.getKey()); + Objects.requireNonNull(originalEntry.getValue()); + copy.put(originalEntry.getKey(), Collections.unmodifiableSet(new LinkedHashSet<>(originalEntry.getValue()))); + } + uniqueResourceIdsPerType = Collections.unmodifiableMap(copy); + } + + /** + * Simplifies collecting the unique resource IDs for models with multiple resource types. + */ + public static final class UniqueResourcesBuilder { + private final Map> uniqueResourceIdsPerType = new LinkedHashMap<>(); + + /** + * Adds unique resource IDs for the given resource types. + * In case the resource type already exists in the builder, the unique resource IDs are merged. + * + * @param resourceType identifier of the resource type + * @param uniqueResourceIds collection of unique resource IDs of the given type + */ + public UniqueResourcesBuilder addResourceType(String resourceType, Collection uniqueResourceIds) { + Objects.requireNonNull(resourceType); + Objects.requireNonNull(uniqueResourceIds); + uniqueResourceIdsPerType.computeIfAbsent(resourceType, unused -> new LinkedHashSet<>()).addAll(uniqueResourceIds); + return this; + } + + public UniqueResources build() { + return new UniqueResources(uniqueResourceIdsPerType); + } + } +} diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/AbstractEvent.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/AbstractEvent.java index a289ca36331..90c5bfa2a73 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/AbstractEvent.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/AbstractEvent.java @@ -2,7 +2,7 @@ public abstract sealed class AbstractEvent permits AbstractDatasetEvent, ItemCompleted, SolveStartCommand, SolveTerminateCommand, - DatasetValidateComputeCommand, ItemTerminated { + DatasetValidateComputeCommand, ItemTerminated, UniqueResourceReportEvent { private final String id; diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/SolverChannels.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/SolverChannels.java index a388cd5b561..97730b4133b 100644 --- a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/SolverChannels.java +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/SolverChannels.java @@ -31,4 +31,6 @@ public class SolverChannels { public static final String FAILED_SOLUTION = "schedule-failed-solution"; public static final String DATASET_EVENTS = "dataset-events"; + + public static final String UNIQUE_RESOURCE_REPORTED = "unique-resource-reported"; } diff --git a/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/UniqueResourceReportEvent.java b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/UniqueResourceReportEvent.java new file mode 100644 index 00000000000..5443ee660d6 --- /dev/null +++ b/service/definition/src/main/java/ai/timefold/solver/service/definition/internal/events/UniqueResourceReportEvent.java @@ -0,0 +1,32 @@ +package ai.timefold.solver.service.definition.internal.events; + +import ai.timefold.solver.service.definition.api.usage.UniqueResources; + +/** + * Event carrying the {@link UniqueResources} reported for an operation, optionally tied to a dataset. + */ +public non-sealed class UniqueResourceReportEvent extends AbstractEvent { + + private final String operationId; + private final UniqueResources uniqueResources; + + public UniqueResourceReportEvent(String operationId, UniqueResources uniqueResources) { + super(null); + this.operationId = operationId; + this.uniqueResources = uniqueResources; + } + + public UniqueResourceReportEvent(String operationId, String datasetId, UniqueResources uniqueResources) { + super(datasetId); + this.operationId = operationId; + this.uniqueResources = uniqueResources; + } + + public String getOperationId() { + return operationId; + } + + public UniqueResources getUniqueResources() { + return uniqueResources; + } +} diff --git a/service/definition/src/test/java/ai/timefold/solver/service/definition/api/usage/UniqueResourcesTest.java b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/usage/UniqueResourcesTest.java new file mode 100644 index 00000000000..c5a1bdd91ab --- /dev/null +++ b/service/definition/src/test/java/ai/timefold/solver/service/definition/api/usage/UniqueResourcesTest.java @@ -0,0 +1,193 @@ +package ai.timefold.solver.service.definition.api.usage; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; + +class UniqueResourcesTest { + + @Test + void fromSingleResourceType() { + var uniqueResources = UniqueResources.from("employee", List.of("Ann", "Beth", "Ann")); + + assertThat(uniqueResources.uniqueResourceIdsPerType()).containsOnlyKeys("employee"); + assertThat(uniqueResources.uniqueResourceIdsPerType().get("employee")).containsExactly("Ann", "Beth"); + } + + @Test + void fromRejectsNulls() { + assertThatThrownBy(() -> UniqueResources.from(null, List.of("Ann"))) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> UniqueResources.from("employee", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void constructorDeduplicatesAndPreservesOrder() { + Map> map = new LinkedHashMap<>(); + map.put("vehicle", List.of("V2", "V1", "V2")); + map.put("employee", List.of("Beth", "Ann", "Beth")); + + var uniqueResources = new UniqueResources(map); + + assertThat(uniqueResources.uniqueResourceIdsPerType()).containsOnlyKeys("vehicle", "employee"); + assertThat(uniqueResources.uniqueResourceIdsPerType().keySet()).containsExactly("vehicle", "employee"); + assertThat(uniqueResources.uniqueResourceIdsPerType().get("vehicle")).containsExactly("V2", "V1"); + assertThat(uniqueResources.uniqueResourceIdsPerType().get("employee")).containsExactly("Beth", "Ann"); + } + + @Test + void constructorCopiesInput() { + var employeeIds = new ArrayList<>(List.of("Ann")); + Map> map = new HashMap<>(); + map.put("employee", employeeIds); + + var uniqueResources = new UniqueResources(map); + employeeIds.add("Beth"); + map.put("vehicle", List.of("V1")); + + assertThat(uniqueResources.uniqueResourceIdsPerType()).containsOnlyKeys("employee"); + assertThat(uniqueResources.uniqueResourceIdsPerType().get("employee")).containsExactly("Ann"); + } + + @Test + void resultIsUnmodifiable() { + var uniqueResources = UniqueResources.from("employee", List.of("Ann")); + var uniqueResourceIdsPerType = uniqueResources.uniqueResourceIdsPerType(); + var employeeIds = uniqueResourceIdsPerType.get("employee"); + + assertThatThrownBy(() -> uniqueResourceIdsPerType.put("vehicle", List.of("V1"))) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(() -> employeeIds.add("Beth")) + .isInstanceOf(UnsupportedOperationException.class); + assertThatThrownBy(employeeIds::clear) + .isInstanceOf(UnsupportedOperationException.class); + } + + @Test + void constructorRejectsNulls() { + assertThatThrownBy(() -> new UniqueResources(null)) + .isInstanceOf(NullPointerException.class); + + Map> nullKey = new HashMap<>(); + nullKey.put(null, List.of("Ann")); + assertThatThrownBy(() -> new UniqueResources(nullKey)) + .isInstanceOf(NullPointerException.class); + + Map> nullValue = new HashMap<>(); + nullValue.put("employee", null); + assertThatThrownBy(() -> new UniqueResources(nullValue)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void equalRegardlessOfInputCollectionType() { + var fromList = UniqueResources.from("employee", List.of("Ann", "Beth")); + var fromBuilder = UniqueResources.builder() + .addResourceType("employee", List.of("Ann", "Beth")) + .build(); + + assertThat(fromList).isEqualTo(fromBuilder); + assertThat(fromList.hashCode()).isEqualTo(fromBuilder.hashCode()); + } + + @Test + void builderWithMultipleResourceTypes() { + var uniqueResources = UniqueResources.builder() + .addResourceType("vehicle", List.of("V1", "V2")) + .addResourceType("employee", List.of("Ann")) + .build(); + + assertThat(uniqueResources.uniqueResourceIdsPerType().keySet()).containsExactly("vehicle", "employee"); + assertThat(uniqueResources.uniqueResourceIdsPerType().get("vehicle")).containsExactly("V1", "V2"); + assertThat(uniqueResources.uniqueResourceIdsPerType().get("employee")).containsExactly("Ann"); + } + + @Test + void builderMergesSameResourceType() { + var uniqueResources = UniqueResources.builder() + .addResourceType("employee", List.of("Ann", "Beth")) + .addResourceType("vehicle", List.of("V1")) + .addResourceType("employee", List.of("Beth", "Cecil")) + .build(); + + assertThat(uniqueResources.uniqueResourceIdsPerType().keySet()).containsExactly("employee", "vehicle"); + assertThat(uniqueResources.uniqueResourceIdsPerType().get("employee")).containsExactly("Ann", "Beth", "Cecil"); + } + + @Test + void builderWithoutResourceTypes() { + var uniqueResources = UniqueResources.builder().build(); + + assertThat(uniqueResources.uniqueResourceIdsPerType()).isEmpty(); + } + + @Test + void builderCopiesInput() { + var employeeIds = new ArrayList<>(List.of("Ann")); + var builder = UniqueResources.builder().addResourceType("employee", employeeIds); + employeeIds.add("Beth"); + + assertThat(builder.build().uniqueResourceIdsPerType().get("employee")).containsExactly("Ann"); + } + + @Test + void builtResultIsIndependentOfBuilder() { + var builder = UniqueResources.builder().addResourceType("employee", List.of("Ann")); + var first = builder.build(); + builder.addResourceType("employee", List.of("Beth")) + .addResourceType("vehicle", List.of("V1")); + var second = builder.build(); + + assertThat(first.uniqueResourceIdsPerType()).containsOnlyKeys("employee"); + assertThat(first.uniqueResourceIdsPerType().get("employee")).containsExactly("Ann"); + assertThat(second.uniqueResourceIdsPerType().keySet()).containsExactly("employee", "vehicle"); + assertThat(second.uniqueResourceIdsPerType().get("employee")).containsExactly("Ann", "Beth"); + } + + @Test + void builderRejectsNulls() { + var builder = UniqueResources.builder(); + + assertThatThrownBy(() -> builder.addResourceType(null, List.of("Ann"))) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> builder.addResourceType("employee", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + void jsonRoundTrip() throws JsonProcessingException { + var mapper = new ObjectMapper(); + var original = UniqueResources.builder() + .addResourceType("vehicle", List.of("V1", "V2")) + .addResourceType("employee", List.of("Ann")) + .build(); + + var json = mapper.writeValueAsString(original); + var deserialized = mapper.readValue(json, UniqueResources.class); + + assertThat(deserialized).isEqualTo(original); + assertThat(deserialized.uniqueResourceIdsPerType().keySet()).containsExactly("vehicle", "employee"); + } + + @Test + void jsonDeserializationDeduplicates() throws JsonProcessingException { + var deserialized = new ObjectMapper().readValue(""" + {"uniqueResourceIdsPerType": {"employee": ["Ann", "Ann", "Beth"]}} + """, UniqueResources.class); + + assertThat(deserialized.uniqueResourceIdsPerType().get("employee")).containsExactly("Ann", "Beth"); + assertThat(deserialized).isEqualTo(UniqueResources.from("employee", List.of("Ann", "Beth"))); + } +} diff --git a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/SolverWorker.java b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/SolverWorker.java index b0cdb857306..a702df26c31 100644 --- a/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/SolverWorker.java +++ b/service/worker/src/main/java/ai/timefold/solver/service/worker/impl/SolverWorker.java @@ -48,6 +48,10 @@ import ai.timefold.solver.service.definition.api.metrics.ModelInputMetrics; import ai.timefold.solver.service.definition.api.metrics.ModelOutputMetrics; import ai.timefold.solver.service.definition.api.metrics.OutputMetricsAware; +import ai.timefold.solver.service.definition.api.rest.OperationId; +import ai.timefold.solver.service.definition.api.usage.UniqueResourceAware; +import ai.timefold.solver.service.definition.api.usage.UniqueResourceReporter; +import ai.timefold.solver.service.definition.api.usage.UniqueResources; import ai.timefold.solver.service.definition.api.validation.LegacyValidationResult; import ai.timefold.solver.service.definition.api.validation.ModelValidator; import ai.timefold.solver.service.definition.api.validation.ValidationBuilder; @@ -151,6 +155,8 @@ public class SolverWorker { private final Emitter failedSolutionEmitter; + private final Instance uniqueResourceReporterInstance; + private final List modelPostProcessors; private final ShutdownExecutor shutdownExecutor; @@ -189,6 +195,7 @@ public SolverWorker(@ConfigProperty(name = "timefold.model.name") Optional bestSolutionConsumerDecoratorOptional, CompletionStatus completionStatus, + Instance uniqueResourceReporterInstance, @Broadcast @Channel(SolverChannels.DATASET_COMPUTED) Emitter datasetOutputsComputedEmitter, @Broadcast @Channel(SolverChannels.DATASET_VALIDATED) Emitter datasetValidatedEventEmitter, @Broadcast @Channel(SolverChannels.STARTED) Emitter scheduleStartedEmitter, @@ -216,7 +223,7 @@ public SolverWorker(@ConfigProperty(name = "timefold.model.name") Optional resolveJob(CompletableFuture> jobFuture) { if (jobFuture == null) { return null;