Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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) {

Copy link
Copy Markdown
Contributor

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?


/**
* 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
Expand Up @@ -2,7 +2,7 @@

public abstract sealed class AbstractEvent
permits AbstractDatasetEvent, ItemCompleted, SolveStartCommand, SolveTerminateCommand,
DatasetValidateComputeCommand, ItemTerminated {
DatasetValidateComputeCommand, ItemTerminated, UniqueResourceReportEvent {

private final String id;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaFeSN9IjPYRmQMCmi3t&open=AaFeSN9IjPYRmQMCmi3t&pullRequest=2769
.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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaFeSN9IjPYRmQMCmi3u&open=AaFeSN9IjPYRmQMCmi3u&pullRequest=2769
.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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Use assertThat(actual).hasSameHashCodeAs(expected) instead.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaFeSN9IjPYRmQMCmi3v&open=AaFeSN9IjPYRmQMCmi3v&pullRequest=2769
}

@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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor the code of the lambda to have only one invocation possibly throwing a runtime exception.

See more on https://sonarcloud.io/project/issues?id=ai.timefold%3Atimefold-solver&issues=AaFeSN9IjPYRmQMCmi3w&open=AaFeSN9IjPYRmQMCmi3w&pullRequest=2769
.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")));
}
}
Loading
Loading