Repository navigation
feat: report unique resource IDs #2769
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, Collection<String>> uniqueResourceIdsPerType) { | ||
|
|
||
| /** | ||
| * Constructs the {@link UniqueResources} from a single resource type. | ||
| */ | ||
| public static UniqueResources from(String resourceType, Collection<String> 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<String, Collection<String>> 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<String, Collection<String>> 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<String> uniqueResourceIds) { | ||
| Objects.requireNonNull(resourceType); | ||
| Objects.requireNonNull(uniqueResourceIds); | ||
| uniqueResourceIdsPerType.computeIfAbsent(resourceType, unused -> new LinkedHashSet<>()).addAll(uniqueResourceIds); | ||
| return this; | ||
| } | ||
|
|
||
| public UniqueResources build() { | ||
| return new UniqueResources(uniqueResourceIdsPerType); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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"))) | ||
|
Check warning on line 30 in service/definition/src/test/java/ai/timefold/solver/service/definition/api/usage/UniqueResourcesTest.java
|
||
| .isInstanceOf(NullPointerException.class); | ||
| assertThatThrownBy(() -> UniqueResources.from("employee", null)) | ||
| .isInstanceOf(NullPointerException.class); | ||
| } | ||
|
|
||
| @Test | ||
| void constructorDeduplicatesAndPreservesOrder() { | ||
| Map<String, Collection<String>> 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<String, Collection<String>> 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"))) | ||
|
Check warning on line 70 in service/definition/src/test/java/ai/timefold/solver/service/definition/api/usage/UniqueResourcesTest.java
|
||
| .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<String, Collection<String>> nullKey = new HashMap<>(); | ||
| nullKey.put(null, List.of("Ann")); | ||
| assertThatThrownBy(() -> new UniqueResources(nullKey)) | ||
| .isInstanceOf(NullPointerException.class); | ||
|
|
||
| Map<String, Collection<String>> 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()); | ||
|
Check warning on line 102 in service/definition/src/test/java/ai/timefold/solver/service/definition/api/usage/UniqueResourcesTest.java
|
||
| } | ||
|
|
||
| @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"))) | ||
|
Check warning on line 163 in service/definition/src/test/java/ai/timefold/solver/service/definition/api/usage/UniqueResourcesTest.java
|
||
| .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"))); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
just a thought, would it be easier to have
UniqueResources(String type, Collection<String> uniqueResourceIds)and then we could have collection of it? Bit better from type perspective - having map by unknown keys might bite us soon.In addition, since the issue says there should be one billable at the moment - how do we know that? will this come from here or?