S12 - Async feedback for scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
public enum SchedulingFailureKind {
|
||||
INFEASIBLE,
|
||||
NO_SOLUTION,
|
||||
SOLVER_FAILED
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
/** Progress is observational; intermediate assignments are never published or applied. */
|
||||
public record SchedulingProgress(String phase, boolean feasible) {}
|
||||
@@ -0,0 +1,59 @@
|
||||
package nl.agilecc.agiledancers.model.scheduling;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Scores match the solver objectives; lower values are better. */
|
||||
public record SchedulingSummary(long attendanceExcess, int performanceSpread, long varietyPenalty) {
|
||||
public static SchedulingSummary of(final SchedulingInput input, final SchedulingResult result) {
|
||||
final List<List<Long>> parts = result.assignments();
|
||||
final Map<Long, GroupRequirement> groups = new HashMap<>();
|
||||
input.groups().forEach((final GroupRequirement group) -> groups.put(group.groupId(), group));
|
||||
final int min = parts.stream().mapToInt(List::size).min().orElse(0);
|
||||
final int max = parts.stream().mapToInt(List::size).max().orElse(0);
|
||||
return new SchedulingSummary(attendance(input, parts), max - min, variety(parts, groups));
|
||||
}
|
||||
|
||||
private static long attendance(final SchedulingInput input, final List<List<Long>> parts) {
|
||||
final Set<Long> showcases = new HashSet<>();
|
||||
final Map<Long, Set<Integer>> attendance = new HashMap<>();
|
||||
for (final GroupRequirement group : input.groups()) {
|
||||
if (group.showcase()) {
|
||||
showcases.addAll(group.dancerIds());
|
||||
} else {
|
||||
recordAttendance(group, parts, attendance);
|
||||
}
|
||||
}
|
||||
showcases.removeAll(input.coachDancerIds());
|
||||
return showcases.stream()
|
||||
.mapToLong((final Long dancer) ->
|
||||
Math.max(0, attendance.getOrDefault(dancer, Set.of()).size() - input.preferredDayParts()))
|
||||
.sum();
|
||||
}
|
||||
|
||||
private static void recordAttendance(
|
||||
final GroupRequirement group, final List<List<Long>> parts, final Map<Long, Set<Integer>> attendance) {
|
||||
for (int part = 0; part < parts.size(); part++) {
|
||||
if (parts.get(part).contains(group.groupId())) {
|
||||
for (final long dancer : group.dancerIds()) {
|
||||
attendance
|
||||
.computeIfAbsent(dancer, (final Long id) -> new HashSet<>())
|
||||
.add(part);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static long variety(final List<List<Long>> parts, final Map<Long, GroupRequirement> groups) {
|
||||
long penalty = 0;
|
||||
for (final List<Long> part : parts) {
|
||||
for (int index = 1; index < part.size(); index++) {
|
||||
penalty += VarietyScore.penalty(groups.get(part.get(index - 1)), groups.get(part.get(index)));
|
||||
}
|
||||
}
|
||||
return penalty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package nl.agilecc.agiledancers.rest;
|
||||
|
||||
import io.smallrye.mutiny.Multi;
|
||||
import io.smallrye.mutiny.subscription.BackPressureStrategy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingEventView;
|
||||
import nl.agilecc.agiledancers.service.SchedulingRuns;
|
||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
||||
import org.jboss.resteasy.reactive.RestStreamElementType;
|
||||
|
||||
@Path("/api/scheduling/events")
|
||||
public class SchedulingEventsResource {
|
||||
private @Inject SchedulingRuns scheduling;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.SERVER_SENT_EVENTS)
|
||||
@RestStreamElementType(MediaType.APPLICATION_JSON)
|
||||
public Multi<SchedulingEventView> events() {
|
||||
final Multi<SchedulingEventView> updates = Multi.createFrom()
|
||||
.emitter(
|
||||
(final io.smallrye.mutiny.subscription.MultiEmitter<? super SchedulingEventView> emitter) -> {
|
||||
final Runnable unsubscribe = scheduling.subscribe(
|
||||
(final List<SchedulingRun> runs) -> emitter.emit(new SchedulingEventView(runs)));
|
||||
emitter.onTermination(unsubscribe);
|
||||
},
|
||||
BackPressureStrategy.LATEST);
|
||||
// Heartbeats keep idle proxy connections alive without changing the current snapshot.
|
||||
final Multi<SchedulingEventView> heartbeat = Multi.createFrom()
|
||||
.ticks()
|
||||
.every(Duration.ofSeconds(15))
|
||||
.onItem()
|
||||
.transform((final Long tick) -> new SchedulingEventView(null));
|
||||
return Multi.createBy().merging().streams(updates, heartbeat);
|
||||
}
|
||||
}
|
||||
@@ -18,15 +18,14 @@ import jakarta.ws.rs.core.UriInfo;
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
|
||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
||||
import nl.agilecc.agiledancers.service.SchedulingService;
|
||||
import nl.agilecc.agiledancers.service.SchedulingRuns;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
||||
|
||||
@Path("/api/dance-schools/{schoolId}/shows")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@@ -34,7 +33,7 @@ import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||
public class ShowResource {
|
||||
private @Inject SchoolManagementService service;
|
||||
|
||||
private @Inject SchedulingService scheduling;
|
||||
private @Inject SchedulingRuns scheduling;
|
||||
|
||||
private @Inject ShowSchedules schedules;
|
||||
|
||||
@@ -105,10 +104,14 @@ public class ShowResource {
|
||||
@POST
|
||||
@Path("/{id}/schedule")
|
||||
@Consumes(MediaType.WILDCARD)
|
||||
public SchedulingView schedule(final @PathParam("id") long id) {
|
||||
final SchedulingResult result = scheduling.schedule(schoolId, id);
|
||||
return new SchedulingView(
|
||||
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
|
||||
public Response schedule(final @PathParam("id") long id) {
|
||||
return Response.accepted(scheduling.start(schoolId, id)).build();
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/{id}/schedule")
|
||||
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
|
||||
return scheduling.status(schoolId, id);
|
||||
}
|
||||
|
||||
@DELETE
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
package nl.agilecc.agiledancers.rest.apimodel;
|
||||
|
||||
import java.util.List;
|
||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
||||
|
||||
/** A null runs field is a heartbeat, not a replacement of the current snapshot. */
|
||||
public record SchedulingEventView(List<SchedulingRun> runs) {
|
||||
public SchedulingEventView {
|
||||
runs = runs == null ? null : List.copyOf(runs);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
|
||||
|
||||
/** Carries a stable calculation outcome independently of its human-readable explanation. */
|
||||
public class SchedulingFailure extends ManagementException {
|
||||
private final SchedulingFailureKind outcome;
|
||||
|
||||
public SchedulingFailure(final SchedulingFailureKind outcome, final String message) {
|
||||
super(ManagementExceptionKind.INVALID_INPUT, message);
|
||||
this.outcome = outcome;
|
||||
}
|
||||
|
||||
public SchedulingFailureKind getOutcome() {
|
||||
return outcome;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.Consumer;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
|
||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||
|
||||
/** Owns background tasks and publishes current state independently of any HTTP connection. */
|
||||
@Singleton
|
||||
public class SchedulingRuns {
|
||||
private final SchoolManagementService schools;
|
||||
private final SchedulingEngine engine;
|
||||
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
|
||||
private final Map<Long, SchedulingRun> runs = new HashMap<>();
|
||||
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
|
||||
|
||||
@Inject
|
||||
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
|
||||
this.schools = schools;
|
||||
this.engine = engine;
|
||||
}
|
||||
|
||||
public synchronized SchedulingRun start(final long schoolId, final long showId) {
|
||||
final SchedulingInput input;
|
||||
final SchedulingRun run;
|
||||
synchronized (schools) {
|
||||
final String name = schools.show(schoolId, showId).getName();
|
||||
input = schools.beginScheduling(schoolId, showId);
|
||||
run = SchedulingRun.started(schoolId, showId, name);
|
||||
}
|
||||
publish(run);
|
||||
try {
|
||||
executor.submit(() -> calculate(run, input));
|
||||
} catch (final RuntimeException failure) {
|
||||
schools.endScheduling(showId);
|
||||
publish(run.completed("SOLVER_FAILED", "Berekening kon niet starten. Bestaande indeling behouden.", null));
|
||||
throw failure;
|
||||
}
|
||||
return run;
|
||||
}
|
||||
|
||||
public synchronized SchedulingRun status(final long schoolId, final long showId) {
|
||||
schools.show(schoolId, showId);
|
||||
return runs.get(showId);
|
||||
}
|
||||
|
||||
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
|
||||
public synchronized Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
|
||||
final Object key = new Object();
|
||||
listeners.put(key, listener);
|
||||
listener.accept(List.copyOf(runs.values()));
|
||||
return () -> unsubscribe(key);
|
||||
}
|
||||
|
||||
private synchronized void unsubscribe(final Object key) {
|
||||
listeners.remove(key);
|
||||
}
|
||||
|
||||
private synchronized void publish(final SchedulingRun run) {
|
||||
runs.put(run.showId(), run);
|
||||
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
|
||||
listeners.values().forEach((final Consumer<List<SchedulingRun>> listener) -> listener.accept(snapshot));
|
||||
}
|
||||
|
||||
private synchronized void progress(final SchedulingRun run, final SchedulingProgress update) {
|
||||
publish(runs.get(run.showId()).progressed(update));
|
||||
}
|
||||
|
||||
private void calculate(final SchedulingRun run, final SchedulingInput input) {
|
||||
SchedulingRun completed;
|
||||
try {
|
||||
final SchedulingResult result =
|
||||
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
|
||||
final SchedulingSummary summary = SchedulingSummary.of(input, result);
|
||||
schools.finishScheduling(run.schoolId(), input, result);
|
||||
completed = latest(run)
|
||||
.completed(
|
||||
result.optimal() ? "OPTIMAL" : "VALID",
|
||||
result.optimal()
|
||||
? "Optimaal conceptrooster."
|
||||
: "Geldig conceptrooster; optimaliteit niet bewezen.",
|
||||
summary);
|
||||
} catch (final ManagementException failure) {
|
||||
completed = failed(run, failure);
|
||||
} catch (final RuntimeException | LinkageError failure) {
|
||||
completed =
|
||||
latest(run).completed("SOLVER_FAILED", "Berekening mislukt. Bestaande indeling behouden.", null);
|
||||
}
|
||||
finish(completed);
|
||||
}
|
||||
|
||||
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
|
||||
final String outcome = failure instanceof final SchedulingFailure scheduling
|
||||
? scheduling.getOutcome().name()
|
||||
: (failure.getKind() == ManagementExceptionKind.CONFLICT ? "STALE" : "INVALID_INPUT");
|
||||
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
|
||||
}
|
||||
|
||||
private synchronized SchedulingRun latest(final SchedulingRun run) {
|
||||
return runs.get(run.showId());
|
||||
}
|
||||
|
||||
private synchronized void finish(final SchedulingRun completed) {
|
||||
schools.endScheduling(completed.showId());
|
||||
publish(completed);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void close() {
|
||||
executor.shutdownNow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package nl.agilecc.agiledancers.service.model;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.UUID;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
|
||||
|
||||
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
|
||||
public record SchedulingRun(
|
||||
UUID runId,
|
||||
long schoolId,
|
||||
long showId,
|
||||
String showName,
|
||||
Instant startedAt,
|
||||
Instant finishedAt,
|
||||
String state,
|
||||
SchedulingProgress progress,
|
||||
String message,
|
||||
SchedulingSummary summary) {
|
||||
public static SchedulingRun started(final long schoolId, final long showId, final String name) {
|
||||
return new SchedulingRun(
|
||||
UUID.randomUUID(),
|
||||
schoolId,
|
||||
showId,
|
||||
name,
|
||||
Instant.now(),
|
||||
null,
|
||||
"RUNNING",
|
||||
new SchedulingProgress("PREPARING", false),
|
||||
"Berekening gestart.",
|
||||
null);
|
||||
}
|
||||
|
||||
public SchedulingRun progressed(final SchedulingProgress update) {
|
||||
return new SchedulingRun(
|
||||
runId,
|
||||
schoolId,
|
||||
showId,
|
||||
showName,
|
||||
startedAt,
|
||||
null,
|
||||
state,
|
||||
new SchedulingProgress(update.phase(), progress.feasible() || update.feasible()),
|
||||
message,
|
||||
null);
|
||||
}
|
||||
|
||||
public SchedulingRun completed(final String outcome, final String explanation, final SchedulingSummary scores) {
|
||||
return new SchedulingRun(
|
||||
runId,
|
||||
schoolId,
|
||||
showId,
|
||||
showName,
|
||||
startedAt,
|
||||
Instant.now(),
|
||||
outcome,
|
||||
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
|
||||
explanation,
|
||||
scores);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,15 @@
|
||||
package nl.agilecc.agiledancers.service.port;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
|
||||
public interface SchedulingEngine {
|
||||
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
|
||||
public SchedulingResult solve(final SchedulingInput input);
|
||||
/** Reports phase changes and solution discovery while calculating. */
|
||||
public default SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
|
||||
return solve(input);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import com.google.ortools.sat.CpSolverStatus;
|
||||
import com.google.ortools.sat.LinearExpr;
|
||||
import jakarta.inject.Singleton;
|
||||
import java.util.List;
|
||||
import java.util.function.Consumer;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.service.ManagementException;
|
||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
||||
import nl.agilecc.agiledancers.service.SchedulingFailure;
|
||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||
|
||||
@Singleton
|
||||
@@ -28,6 +30,11 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
|
||||
@Override
|
||||
public SchedulingResult solve(final SchedulingInput input) {
|
||||
return solve(input, (final SchedulingProgress progress) -> {});
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
|
||||
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
|
||||
Loader.loadNativeLibraries();
|
||||
final CpModel model = new CpModel();
|
||||
@@ -38,7 +45,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
solver.getParameters().setCatchSigintSignal(false);
|
||||
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
||||
SchedulingResult best = null;
|
||||
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
|
||||
final List<LinearExpr> objectives = List.of(attendance, LinearExpr.term(vars.spread(), 1), variety);
|
||||
final List<String> phases = List.of("ATTENDANCE", "SPREAD", "VARIETY");
|
||||
for (int stage = 0; stage < objectives.size(); stage++) {
|
||||
final LinearExpr objective = objectives.get(stage);
|
||||
final String phase = phases.get(stage);
|
||||
observer.accept(new SchedulingProgress(phase, best != null));
|
||||
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
|
||||
if (remaining <= 0) {
|
||||
if (best != null) {
|
||||
@@ -48,7 +60,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
}
|
||||
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
|
||||
model.minimize(objective);
|
||||
final CpSolverStatus status = solver.solve(model);
|
||||
final CpSolverStatus status = solver.solve(model, new SchedulingSolutionObserver(observer, phase));
|
||||
if (status == CpSolverStatus.UNKNOWN && best != null) {
|
||||
return new SchedulingResult(false, best.assignments());
|
||||
}
|
||||
@@ -64,8 +76,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
||||
|
||||
private static void requireSolution(final CpSolverStatus status) {
|
||||
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
|
||||
throw new ManagementException(
|
||||
ManagementExceptionKind.INVALID_INPUT,
|
||||
throw new SchedulingFailure(
|
||||
switch (status) {
|
||||
case INFEASIBLE -> SchedulingFailureKind.INFEASIBLE;
|
||||
case UNKNOWN -> SchedulingFailureKind.NO_SOLUTION;
|
||||
default -> SchedulingFailureKind.SOLVER_FAILED;
|
||||
},
|
||||
switch (status) {
|
||||
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
|
||||
case UNKNOWN ->
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package nl.agilecc.agiledancers.solver;
|
||||
|
||||
import com.google.ortools.sat.CpSolverSolutionCallback;
|
||||
import java.util.function.Consumer;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||
|
||||
/** Publish only the first feasible solution in a phase, avoiding an event per solver improvement. */
|
||||
public class SchedulingSolutionObserver extends CpSolverSolutionCallback {
|
||||
private final Consumer<SchedulingProgress> observer;
|
||||
private final String phase;
|
||||
private boolean reported;
|
||||
|
||||
public SchedulingSolutionObserver(final Consumer<SchedulingProgress> observer, final String phase) {
|
||||
this.observer = observer;
|
||||
this.phase = phase;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSolutionCallback() {
|
||||
if (!reported) {
|
||||
reported = true;
|
||||
observer.accept(new SchedulingProgress(phase, true));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -92,7 +92,8 @@ class S18ResourceTest {
|
||||
final long dancer = dancer(school, "Coach");
|
||||
group(school, dancer);
|
||||
final String show = show(school);
|
||||
given().post(show + "/schedule").then().statusCode(200);
|
||||
given().post(show + "/schedule").then().statusCode(202);
|
||||
awaitScheduling(show);
|
||||
given().post(show + "/finalize").then().statusCode(200);
|
||||
final long coach = given().contentType("application/json")
|
||||
.body(Map.of("dancerId", dancer))
|
||||
@@ -133,4 +134,13 @@ class S18ResourceTest {
|
||||
given().delete(school).then().statusCode(204);
|
||||
}
|
||||
}
|
||||
|
||||
private static void awaitScheduling(final String show) {
|
||||
final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
|
||||
while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state"))
|
||||
&& System.nanoTime() < deadline) {
|
||||
java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20));
|
||||
}
|
||||
given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import static io.restassured.RestAssured.given;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import io.quarkus.test.common.http.TestHTTPResource;
|
||||
import io.quarkus.test.junit.QuarkusTest;
|
||||
import jakarta.inject.Inject;
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
import nl.agilecc.agiledancers.service.SchoolManagementService;
|
||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@QuarkusTest
|
||||
class SchedulingEventsTest {
|
||||
private @Inject SchoolManagementService schools;
|
||||
private @Inject ObjectMapper json;
|
||||
private @TestHTTPResource URI base;
|
||||
|
||||
public @Test void streamStartsWithSnapshotAndPushesCompletionAndReconnectSnapshot() throws Exception {
|
||||
final long school = schools.createSchool("SSE school").getId();
|
||||
try {
|
||||
final Show show = show(school);
|
||||
final String path = "/api/dance-schools/" + school + "/shows/" + show.getId() + "/schedule";
|
||||
given().get(path).then().statusCode(204);
|
||||
try (final HttpClient client = HttpClient.newHttpClient();
|
||||
final ExecutorService executor = Executors.newSingleThreadExecutor();
|
||||
final InputStream stream = stream(client);
|
||||
final BufferedReader reader =
|
||||
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
assertNotNull(next(reader).get("runs"));
|
||||
final String runId = given().post(path)
|
||||
.then()
|
||||
.statusCode(202)
|
||||
.extract()
|
||||
.jsonPath()
|
||||
.getString("runId");
|
||||
final JsonNode completion =
|
||||
executor.submit(() -> completion(reader, runId)).get(15, TimeUnit.SECONDS);
|
||||
assertEquals("OPTIMAL", completion.get("state").asText());
|
||||
assertTrue(completion.get("progress").get("feasible").asBoolean());
|
||||
given().get(path).then().statusCode(200);
|
||||
}
|
||||
assertReconnectContains(show.getId());
|
||||
} finally {
|
||||
schools.deleteSchool(school);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertReconnectContains(final long showId) throws Exception {
|
||||
try (final HttpClient client = HttpClient.newHttpClient();
|
||||
final InputStream stream = stream(client);
|
||||
final BufferedReader reader =
|
||||
new BufferedReader(new InputStreamReader(stream, StandardCharsets.UTF_8))) {
|
||||
final JsonNode runs = next(reader).get("runs");
|
||||
boolean found = false;
|
||||
for (final JsonNode run : runs) {
|
||||
if (run.get("showId").asLong() == showId) {
|
||||
assertEquals("OPTIMAL", run.get("state").asText());
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
assertTrue(found);
|
||||
}
|
||||
}
|
||||
|
||||
private InputStream stream(final HttpClient client) throws Exception {
|
||||
final HttpResponse<InputStream> response = client.send(
|
||||
HttpRequest.newBuilder(base.resolve("/api/scheduling/events"))
|
||||
.header("Accept", "text/event-stream")
|
||||
.build(),
|
||||
HttpResponse.BodyHandlers.ofInputStream());
|
||||
assertEquals(200, response.statusCode());
|
||||
assertTrue(response.headers().firstValue("content-type").orElse("").startsWith("text/event-stream"));
|
||||
return response.body();
|
||||
}
|
||||
|
||||
private JsonNode completion(final BufferedReader reader, final String runId) throws Exception {
|
||||
while (true) {
|
||||
final JsonNode event = next(reader);
|
||||
if (event.get("runs").isNull()) {
|
||||
continue;
|
||||
}
|
||||
for (final JsonNode run : event.get("runs")) {
|
||||
if (run.get("runId").asText().equals(runId)
|
||||
&& !run.get("state").asText().equals("RUNNING")) {
|
||||
return run;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private JsonNode next(final BufferedReader reader) throws Exception {
|
||||
String line = reader.readLine();
|
||||
while (line != null && !line.startsWith("data:")) {
|
||||
line = reader.readLine();
|
||||
}
|
||||
assertNotNull(line);
|
||||
return json.readTree(line.substring(5).trim());
|
||||
}
|
||||
|
||||
private Show show(final long school) {
|
||||
final long group = schools.createGroup(
|
||||
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
|
||||
.getId();
|
||||
schools.addGroupDancer(
|
||||
school,
|
||||
group,
|
||||
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
|
||||
return schools.createShow(
|
||||
school,
|
||||
"Show",
|
||||
List.of(DayPart.builder()
|
||||
.date(LocalDate.of(2027, 1, 1))
|
||||
.type(DayPartType.MORNING)
|
||||
.danceGroups(List.of())
|
||||
.build()),
|
||||
null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package nl.agilecc.agiledancers;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import nl.agilecc.agiledancers.model.scheduling.GroupRequirement;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
|
||||
import nl.agilecc.agiledancers.model.scheduling.VarietyScore;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupStyle;
|
||||
import nl.agilecc.agiledancers.service.SchedulingFailure;
|
||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SchedulingProgressTest {
|
||||
public @Test void realSolverReportsDiscoveryDuringEachPhase() {
|
||||
final SchedulingInput input = new SchedulingInput(1, 0, 2, List.of(new GroupRequirement(2, 1)));
|
||||
final List<SchedulingProgress> updates = new ArrayList<>();
|
||||
final SchedulingResult result = new OrToolsSchedulingEngine().solve(input, updates::add);
|
||||
assertTrue(result.optimal());
|
||||
assertEquals(
|
||||
List.of("ATTENDANCE", "SPREAD", "VARIETY"),
|
||||
updates.stream().map(SchedulingProgress::phase).distinct().toList());
|
||||
assertEquals(new SchedulingProgress("ATTENDANCE", false), updates.getFirst());
|
||||
assertTrue(updates.contains(new SchedulingProgress("ATTENDANCE", true)));
|
||||
assertTrue(updates.contains(new SchedulingProgress("VARIETY", true)));
|
||||
}
|
||||
|
||||
public @Test void noSolutionAndInfeasibleHaveStableDistinctOutcomes() {
|
||||
final SchedulingInput input = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 1)));
|
||||
assertEquals(
|
||||
SchedulingFailureKind.NO_SOLUTION,
|
||||
assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine(0).solve(input))
|
||||
.getOutcome());
|
||||
final SchedulingInput impossible = new SchedulingInput(1, 0, 1, List.of(new GroupRequirement(2, 2)));
|
||||
assertEquals(
|
||||
SchedulingFailureKind.INFEASIBLE,
|
||||
assertThrows(SchedulingFailure.class, () -> new OrToolsSchedulingEngine().solve(impossible))
|
||||
.getOutcome());
|
||||
}
|
||||
|
||||
public @Test void summaryMatchesTheThreeObjectiveDefinitionsAndCoachExemptions() {
|
||||
final GroupRequirement showcase = new GroupRequirement(1, 1, true, Set.of(10L, 11L), Set.of(), 20);
|
||||
final GroupRequirement regular =
|
||||
new GroupRequirement(2, 2, false, Set.of(10L, 11L), Set.of(DanceGroupStyle.MODERN), 22);
|
||||
final SchedulingInput input = new SchedulingInput(1, 0, 3, List.of(showcase, regular), 1, Set.of(11L));
|
||||
final SchedulingResult result = new SchedulingResult(false, List.of(List.of(1L, 2L), List.of(2L), List.of()));
|
||||
final SchedulingSummary summary = SchedulingSummary.of(input, result);
|
||||
assertEquals(1, summary.attendanceExcess());
|
||||
assertEquals(2, summary.performanceSpread());
|
||||
assertEquals(VarietyScore.penalty(showcase, regular), summary.varietyPenalty());
|
||||
}
|
||||
}
|
||||
@@ -175,7 +175,8 @@ class SchoolManagementResourceTest {
|
||||
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||
.then()
|
||||
.statusCode(204);
|
||||
given().post(show + "/schedule").then().statusCode(200).body("optimal", equalTo(true));
|
||||
given().post(show + "/schedule").then().statusCode(202).body("state", equalTo("RUNNING"));
|
||||
awaitScheduling(show);
|
||||
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
|
||||
final String before = given().get(show).asString();
|
||||
for (final String count : new String[] {"0", "2", "1.5", "7"}) {
|
||||
@@ -291,4 +292,13 @@ class SchoolManagementResourceTest {
|
||||
private static ValidatableResponse put(final String path, final Object body) {
|
||||
return given().contentType("application/json").body(body).put(path).then();
|
||||
}
|
||||
|
||||
private static void awaitScheduling(final String show) {
|
||||
final long deadline = System.nanoTime() + java.util.concurrent.TimeUnit.SECONDS.toNanos(15);
|
||||
while ("RUNNING".equals(given().get(show + "/schedule").jsonPath().getString("state"))
|
||||
&& System.nanoTime() < deadline) {
|
||||
java.util.concurrent.locks.LockSupport.parkNanos(java.util.concurrent.TimeUnit.MILLISECONDS.toNanos(20));
|
||||
}
|
||||
given().get(show + "/schedule").then().statusCode(200).body("state", equalTo("OPTIMAL"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
package nl.agilecc.agiledancers.service;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import java.util.concurrent.locks.LockSupport;
|
||||
import nl.agilecc.agiledancers.model.DayPart;
|
||||
import nl.agilecc.agiledancers.model.Show;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||
import nl.agilecc.agiledancers.model.specifications.DanceGroupType;
|
||||
import nl.agilecc.agiledancers.model.specifications.DayPartType;
|
||||
import nl.agilecc.agiledancers.service.model.GroupDetails;
|
||||
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||
import nl.agilecc.agiledancers.solver.OrToolsSchedulingEngine;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class SchedulingRunsTest {
|
||||
private final SchoolManagementService schools = new SchoolManagementService();
|
||||
private final long school = schools.createSchool("School").getId();
|
||||
|
||||
public @Test void startsInBackgroundRejectsDuplicatesAndRecoversCurrentStatus() throws Exception {
|
||||
final Show show = show();
|
||||
final var entered = new CountDownLatch(1);
|
||||
final var release = new CountDownLatch(1);
|
||||
final var updates = new AtomicReference<List<SchedulingRun>>();
|
||||
final var runs = new SchedulingRuns(schools, blocking(entered, release));
|
||||
try {
|
||||
final Runnable unsubscribe = runs.subscribe(updates::set);
|
||||
assertTrue(updates.get().isEmpty());
|
||||
final SchedulingRun started = runs.start(school, show.getId());
|
||||
assertEquals("RUNNING", started.state());
|
||||
assertTrue(entered.await(10, TimeUnit.SECONDS));
|
||||
assertEquals(started, runs.status(school, show.getId()));
|
||||
assertThrows(ManagementException.class, () -> runs.start(school, show.getId()));
|
||||
unsubscribe.run();
|
||||
final var reconnect = new AtomicReference<List<SchedulingRun>>();
|
||||
final Runnable disconnect = runs.subscribe(reconnect::set);
|
||||
assertEquals(started, reconnect.get().getFirst());
|
||||
release.countDown();
|
||||
final SchedulingRun finished = await(runs, show);
|
||||
assertEquals("OPTIMAL", finished.state());
|
||||
assertEquals(0, finished.summary().performanceSpread());
|
||||
assertTrue(finished.progress().feasible());
|
||||
assertTrue(finished.finishedAt().compareTo(finished.startedAt()) >= 0);
|
||||
assertFalse(show.getDayParts().getFirst().getDanceGroups().isEmpty());
|
||||
assertEquals("RUNNING", updates.get().getFirst().state());
|
||||
disconnect.run();
|
||||
assertNotEquals(started.runId(), runs.start(school, show.getId()).runId());
|
||||
await(runs, show);
|
||||
} finally {
|
||||
release.countDown();
|
||||
runs.close();
|
||||
}
|
||||
}
|
||||
|
||||
public @Test void differentShowsRunConcurrentlyAndEditsRejectStaleResults() throws Exception {
|
||||
final Show first = show();
|
||||
final Show second = show();
|
||||
final var entered = new CountDownLatch(2);
|
||||
final var release = new CountDownLatch(1);
|
||||
final var runs = new SchedulingRuns(schools, blocking(entered, release));
|
||||
try {
|
||||
runs.start(school, first.getId());
|
||||
runs.start(school, second.getId());
|
||||
assertTrue(entered.await(10, TimeUnit.SECONDS));
|
||||
schools.touch(first);
|
||||
release.countDown();
|
||||
assertEquals("STALE", await(runs, first).state());
|
||||
assertTrue(first.getDayParts().getFirst().getDanceGroups().isEmpty());
|
||||
assertEquals("OPTIMAL", await(runs, second).state());
|
||||
} finally {
|
||||
release.countDown();
|
||||
runs.close();
|
||||
}
|
||||
}
|
||||
|
||||
public @Test void failuresRemainVisibleAndReleaseTheShowForRetry() {
|
||||
final Show show = show();
|
||||
final List<SchedulingEngine> engines = new ArrayList<>();
|
||||
engines.add((final SchedulingInput input) -> {
|
||||
throw new IllegalStateException("failure");
|
||||
});
|
||||
engines.add((final SchedulingInput input) -> {
|
||||
throw new UnsatisfiedLinkError("native");
|
||||
});
|
||||
engines.add((final SchedulingInput input) -> null);
|
||||
for (final SchedulingFailureKind kind : SchedulingFailureKind.values()) {
|
||||
engines.add((final SchedulingInput input) -> {
|
||||
throw new SchedulingFailure(kind, "Explained outcome");
|
||||
});
|
||||
}
|
||||
for (final SchedulingEngine engine : engines) {
|
||||
assertFailure(show, engine);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertFailure(final Show show, final SchedulingEngine engine) {
|
||||
final var runs = new SchedulingRuns(schools, engine);
|
||||
try {
|
||||
runs.start(school, show.getId());
|
||||
final SchedulingRun failed = await(runs, show);
|
||||
assertNotEquals("RUNNING", failed.state());
|
||||
assertTrue(failed.message().contains("behouden"));
|
||||
assertTrue(show.getDayParts().getFirst().getDanceGroups().isEmpty());
|
||||
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
|
||||
} finally {
|
||||
runs.close();
|
||||
}
|
||||
}
|
||||
|
||||
public @Test void validUnprovenResultIsAppliedAndShutdownRejectsNewWorkWithoutLeakingGuard() {
|
||||
final Show show = show();
|
||||
final SchedulingEngine engine = (final SchedulingInput input) -> new SchedulingResult(
|
||||
false, new OrToolsSchedulingEngine().solve(input).assignments());
|
||||
final var runs = new SchedulingRuns(schools, engine);
|
||||
runs.start(school, show.getId());
|
||||
assertEquals("VALID", await(runs, show).state());
|
||||
runs.close();
|
||||
assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> runs.start(school, show.getId()));
|
||||
assertEquals("SOLVER_FAILED", runs.status(school, show.getId()).state());
|
||||
schools.endScheduling(schools.beginScheduling(school, show.getId()).showId());
|
||||
}
|
||||
|
||||
private Show show() {
|
||||
final long group = schools.createGroup(
|
||||
school, new GroupDetails("Group", DanceGroupType.REGULAR, 0, 99, Set.of()))
|
||||
.getId();
|
||||
schools.addGroupDancer(
|
||||
school,
|
||||
group,
|
||||
schools.createDancer(school, "Dancer", LocalDate.of(2000, 1, 1)).getId());
|
||||
return schools.createShow(
|
||||
school,
|
||||
"Show",
|
||||
List.of(DayPart.builder()
|
||||
.date(LocalDate.of(2027, 1, 1))
|
||||
.type(DayPartType.MORNING)
|
||||
.danceGroups(List.of())
|
||||
.build()),
|
||||
null);
|
||||
}
|
||||
|
||||
private SchedulingEngine blocking(final CountDownLatch entered, final CountDownLatch release) {
|
||||
return (final SchedulingInput input) -> {
|
||||
entered.countDown();
|
||||
try {
|
||||
assertTrue(release.await(10, TimeUnit.SECONDS));
|
||||
} catch (final InterruptedException failure) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new IllegalStateException(failure);
|
||||
}
|
||||
// The real solver's callbacks are covered separately; exercise the default port overload here.
|
||||
return new OrToolsSchedulingEngine().solve(input);
|
||||
};
|
||||
}
|
||||
|
||||
private SchedulingRun await(final SchedulingRuns runs, final Show show) {
|
||||
final long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
|
||||
SchedulingRun status = runs.status(school, show.getId());
|
||||
while (status.state().equals("RUNNING") && System.nanoTime() < deadline) {
|
||||
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
|
||||
status = runs.status(school, show.getId());
|
||||
}
|
||||
assertNotEquals("RUNNING", status.state());
|
||||
return status;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user