S12 - Async feedback for scheduling
All checks were successful
Build, test and publish / verify (push) Successful in 7m25s

This commit is contained in:
2026-11-03 23:18:44 +01:00
parent f1785ed174
commit c241392132
34 changed files with 1310 additions and 79 deletions

View File

@@ -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"));
}
}

View File

@@ -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);
}
}

View File

@@ -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());
}
}

View File

@@ -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"));
}
}

View File

@@ -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;
}
}