Merge pull request 'S12_async-feedback' (#14) from S12_async-feedback into main
All checks were successful
Build, test and publish / verify (push) Successful in 4m37s
All checks were successful
Build, test and publish / verify (push) Successful in 4m37s
Reviewed-on: #14
This commit was merged in pull request #14.
This commit is contained in:
24
README.md
24
README.md
@@ -83,7 +83,8 @@ the solver live in `model.scheduling`. Each group requirement combines its group
|
|||||||
ID and appearance count in one record.
|
ID and appearance count in one record.
|
||||||
|
|
||||||
`service.port.SchedulingEngine` is the application-owned interface for solving a
|
`service.port.SchedulingEngine` is the application-owned interface for solving a
|
||||||
scheduling problem. `SchedulingService` calls this interface; dependency injection
|
scheduling problem. `SchedulingRuns` calls this interface for background runs;
|
||||||
|
`SchedulingService` retains the synchronous service entry point. Dependency injection
|
||||||
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
|
supplies `solver.OrToolsSchedulingEngine`. The port accepts and returns domain
|
||||||
types, so neither the caller nor the contract depends on OR-Tools or the concrete
|
types, so neither the caller nor the contract depends on OR-Tools or the concrete
|
||||||
school-management service. Architecture tests enforce the port's location and
|
school-management service. Architecture tests enforce the port's location and
|
||||||
@@ -160,11 +161,22 @@ dayparts; preschool groups once; showcases every daypart. See
|
|||||||
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
|
[show-scheduling-rules.md](show-scheduling-rules.md) for the full hard constraints,
|
||||||
coach exceptions, ordering and optimization rules.
|
coach exceptions, ordering and optimization rules.
|
||||||
|
|
||||||
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` generates an ordered
|
`POST /api/dance-schools/{schoolId}/shows/{showId}/schedule` returns `202` with a
|
||||||
draft using OR-Tools with a total thirty-minute time limit. A feasible result is
|
run identifier and starts an ordered draft calculation in the background using
|
||||||
accepted without claiming optimality. Concurrent runs and stale results return
|
OR-Tools with a total thirty-minute time limit. Duplicate starts for the same
|
||||||
`409`; failures retain existing assignments. Development and production proxies
|
show return `409`; different shows may run concurrently. A feasible result is
|
||||||
allow 31 minutes.
|
accepted without claiming optimality. Stale results and failures retain existing
|
||||||
|
assignments and appear as distinct terminal run states.
|
||||||
|
|
||||||
|
`GET .../shows/{showId}/schedule` returns the latest run (`204` if none exists).
|
||||||
|
`GET /api/scheduling/events` streams SSE JSON objects containing a `runs` snapshot
|
||||||
|
on connection and changes, or `runs: null` as a fifteen-second heartbeat. Clients
|
||||||
|
recover current state on reconnection rather than requiring event replay. The
|
||||||
|
production proxy disables buffering. The UI shows phase, elapsed time, solution
|
||||||
|
discovery and final objective scores, and reports completion in an application-wide
|
||||||
|
snackbar. Reloading recovers the latest state without replaying old notifications.
|
||||||
|
Run state currently shares the application's in-memory lifetime; durable restart
|
||||||
|
recovery requires the PostgreSQL integration specified in S16.
|
||||||
|
|
||||||
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
|
`PUT .../shows/{showId}/assignments` accepts `{"assignments":[[1,2],[3]]}`, an
|
||||||
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
|
ordered list of group IDs per daypart. Incomplete or invalid drafts can be saved;
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
|
public enum SchedulingFailureKind {
|
||||||
|
INFEASIBLE,
|
||||||
|
NO_SOLUTION,
|
||||||
|
SOLVER_FAILED
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
|
public enum SchedulingPhase {
|
||||||
|
PREPARING,
|
||||||
|
ATTENDANCE,
|
||||||
|
SPREAD,
|
||||||
|
VARIETY
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
package nl.agilecc.agiledancers.model.scheduling;
|
||||||
|
|
||||||
|
/** Progress is observational; intermediate assignments are never published or applied. */
|
||||||
|
public record SchedulingProgress(SchedulingPhase 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 java.util.List;
|
||||||
import nl.agilecc.agiledancers.model.DayPart;
|
import nl.agilecc.agiledancers.model.DayPart;
|
||||||
import nl.agilecc.agiledancers.model.Show;
|
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.DayPartRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
|
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.ShowRequest;
|
||||||
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
|
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.SchoolManagementService;
|
||||||
import nl.agilecc.agiledancers.service.ShowSchedules;
|
import nl.agilecc.agiledancers.service.ShowSchedules;
|
||||||
|
import nl.agilecc.agiledancers.service.model.SchedulingRun;
|
||||||
|
|
||||||
@Path("/api/dance-schools/{schoolId}/shows")
|
@Path("/api/dance-schools/{schoolId}/shows")
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
@@ -34,7 +33,7 @@ import nl.agilecc.agiledancers.service.ShowSchedules;
|
|||||||
public class ShowResource {
|
public class ShowResource {
|
||||||
private @Inject SchoolManagementService service;
|
private @Inject SchoolManagementService service;
|
||||||
|
|
||||||
private @Inject SchedulingService scheduling;
|
private @Inject SchedulingRuns scheduling;
|
||||||
|
|
||||||
private @Inject ShowSchedules schedules;
|
private @Inject ShowSchedules schedules;
|
||||||
|
|
||||||
@@ -105,10 +104,14 @@ public class ShowResource {
|
|||||||
@POST
|
@POST
|
||||||
@Path("/{id}/schedule")
|
@Path("/{id}/schedule")
|
||||||
@Consumes(MediaType.WILDCARD)
|
@Consumes(MediaType.WILDCARD)
|
||||||
public SchedulingView schedule(final @PathParam("id") long id) {
|
public Response schedule(final @PathParam("id") long id) {
|
||||||
final SchedulingResult result = scheduling.schedule(schoolId, id);
|
return Response.accepted(scheduling.start(schoolId, id)).build();
|
||||||
return new SchedulingView(
|
}
|
||||||
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
|
|
||||||
|
@GET
|
||||||
|
@Path("/{id}/schedule")
|
||||||
|
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
|
||||||
|
return scheduling.status(schoolId, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DELETE
|
@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,131 @@
|
|||||||
|
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.model.SchedulingState;
|
||||||
|
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(
|
||||||
|
SchedulingState.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() ? SchedulingState.OPTIMAL : SchedulingState.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(SchedulingState.SOLVER_FAILED, "Berekening mislukt. Bestaande indeling behouden.", null);
|
||||||
|
}
|
||||||
|
finish(completed);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
|
||||||
|
final SchedulingState outcome = failure instanceof final SchedulingFailure scheduling
|
||||||
|
? switch (scheduling.getOutcome()) {
|
||||||
|
case INFEASIBLE -> SchedulingState.INFEASIBLE;
|
||||||
|
case NO_SOLUTION -> SchedulingState.NO_SOLUTION;
|
||||||
|
case SOLVER_FAILED -> SchedulingState.SOLVER_FAILED;
|
||||||
|
}
|
||||||
|
: (failure.getKind() == ManagementExceptionKind.CONFLICT
|
||||||
|
? SchedulingState.STALE
|
||||||
|
: SchedulingState.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,63 @@
|
|||||||
|
package nl.agilecc.agiledancers.service.model;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
|
||||||
|
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,
|
||||||
|
SchedulingState 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,
|
||||||
|
SchedulingState.RUNNING,
|
||||||
|
new SchedulingProgress(SchedulingPhase.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 SchedulingState 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package nl.agilecc.agiledancers.service.model;
|
||||||
|
|
||||||
|
public enum SchedulingState {
|
||||||
|
RUNNING,
|
||||||
|
OPTIMAL,
|
||||||
|
VALID,
|
||||||
|
STALE,
|
||||||
|
INVALID_INPUT,
|
||||||
|
INFEASIBLE,
|
||||||
|
NO_SOLUTION,
|
||||||
|
SOLVER_FAILED
|
||||||
|
}
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
package nl.agilecc.agiledancers.service.port;
|
package nl.agilecc.agiledancers.service.port;
|
||||||
|
|
||||||
|
import java.util.function.Consumer;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
|
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.SchedulingResult;
|
||||||
|
|
||||||
public interface SchedulingEngine {
|
public interface SchedulingEngine {
|
||||||
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
|
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
|
||||||
public SchedulingResult solve(final SchedulingInput input);
|
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,13 @@ import com.google.ortools.sat.CpSolverStatus;
|
|||||||
import com.google.ortools.sat.LinearExpr;
|
import com.google.ortools.sat.LinearExpr;
|
||||||
import jakarta.inject.Singleton;
|
import jakarta.inject.Singleton;
|
||||||
import java.util.List;
|
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.SchedulingInput;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
|
||||||
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
|
||||||
import nl.agilecc.agiledancers.service.ManagementException;
|
import nl.agilecc.agiledancers.service.SchedulingFailure;
|
||||||
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
|
|
||||||
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
@@ -28,6 +31,11 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public SchedulingResult solve(final SchedulingInput input) {
|
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);
|
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
|
||||||
Loader.loadNativeLibraries();
|
Loader.loadNativeLibraries();
|
||||||
final CpModel model = new CpModel();
|
final CpModel model = new CpModel();
|
||||||
@@ -38,7 +46,13 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
|||||||
solver.getParameters().setCatchSigintSignal(false);
|
solver.getParameters().setCatchSigintSignal(false);
|
||||||
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
|
||||||
SchedulingResult best = null;
|
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<SchedulingPhase> phases =
|
||||||
|
List.of(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY);
|
||||||
|
for (int stage = 0; stage < objectives.size(); stage++) {
|
||||||
|
final LinearExpr objective = objectives.get(stage);
|
||||||
|
final SchedulingPhase phase = phases.get(stage);
|
||||||
|
observer.accept(new SchedulingProgress(phase, best != null));
|
||||||
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
|
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
if (best != null) {
|
if (best != null) {
|
||||||
@@ -48,7 +62,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
|||||||
}
|
}
|
||||||
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
|
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
|
||||||
model.minimize(objective);
|
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) {
|
if (status == CpSolverStatus.UNKNOWN && best != null) {
|
||||||
return new SchedulingResult(false, best.assignments());
|
return new SchedulingResult(false, best.assignments());
|
||||||
}
|
}
|
||||||
@@ -64,8 +78,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
|
|||||||
|
|
||||||
private static void requireSolution(final CpSolverStatus status) {
|
private static void requireSolution(final CpSolverStatus status) {
|
||||||
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
|
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
|
||||||
throw new ManagementException(
|
throw new SchedulingFailure(
|
||||||
ManagementExceptionKind.INVALID_INPUT,
|
switch (status) {
|
||||||
|
case INFEASIBLE -> SchedulingFailureKind.INFEASIBLE;
|
||||||
|
case UNKNOWN -> SchedulingFailureKind.NO_SOLUTION;
|
||||||
|
default -> SchedulingFailureKind.SOLVER_FAILED;
|
||||||
|
},
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
|
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
|
||||||
case UNKNOWN ->
|
case UNKNOWN ->
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package nl.agilecc.agiledancers.solver;
|
||||||
|
|
||||||
|
import com.google.ortools.sat.CpSolverSolutionCallback;
|
||||||
|
import java.util.function.Consumer;
|
||||||
|
import nl.agilecc.agiledancers.model.scheduling.SchedulingPhase;
|
||||||
|
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 SchedulingPhase phase;
|
||||||
|
private boolean reported;
|
||||||
|
|
||||||
|
public SchedulingSolutionObserver(final Consumer<SchedulingProgress> observer, final SchedulingPhase 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");
|
final long dancer = dancer(school, "Coach");
|
||||||
group(school, dancer);
|
group(school, dancer);
|
||||||
final String show = show(school);
|
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);
|
given().post(show + "/finalize").then().statusCode(200);
|
||||||
final long coach = given().contentType("application/json")
|
final long coach = given().contentType("application/json")
|
||||||
.body(Map.of("dancerId", dancer))
|
.body(Map.of("dancerId", dancer))
|
||||||
@@ -133,4 +134,13 @@ class S18ResourceTest {
|
|||||||
given().delete(school).then().statusCode(204);
|
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,61 @@
|
|||||||
|
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.SchedulingPhase;
|
||||||
|
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(SchedulingPhase.ATTENDANCE, SchedulingPhase.SPREAD, SchedulingPhase.VARIETY),
|
||||||
|
updates.stream().map(SchedulingProgress::phase).distinct().toList());
|
||||||
|
assertEquals(new SchedulingProgress(SchedulingPhase.ATTENDANCE, false), updates.getFirst());
|
||||||
|
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.ATTENDANCE, true)));
|
||||||
|
assertTrue(updates.contains(new SchedulingProgress(SchedulingPhase.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)
|
given().put(school + "/dance-groups/" + group + "/dancers/" + dancer)
|
||||||
.then()
|
.then()
|
||||||
.statusCode(204);
|
.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));
|
given().get(show).then().body("dayParts[0].danceGroupIds", contains((int) group));
|
||||||
final String before = given().get(show).asString();
|
final String before = given().get(show).asString();
|
||||||
for (final String count : new String[] {"0", "2", "1.5", "7"}) {
|
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) {
|
private static ValidatableResponse put(final String path, final Object body) {
|
||||||
return given().contentType("application/json").body(body).put(path).then();
|
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,182 @@
|
|||||||
|
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.model.SchedulingState;
|
||||||
|
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(SchedulingState.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(SchedulingState.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(SchedulingState.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(SchedulingState.STALE, await(runs, first).state());
|
||||||
|
assertTrue(first.getDayParts().getFirst().getDanceGroups().isEmpty());
|
||||||
|
assertEquals(SchedulingState.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(SchedulingState.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(SchedulingState.VALID, await(runs, show).state());
|
||||||
|
runs.close();
|
||||||
|
assertThrows(java.util.concurrent.RejectedExecutionException.class, () -> runs.start(school, show.getId()));
|
||||||
|
assertEquals(
|
||||||
|
SchedulingState.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(SchedulingState.RUNNING) && System.nanoTime() < deadline) {
|
||||||
|
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
|
||||||
|
status = runs.status(school, show.getId());
|
||||||
|
}
|
||||||
|
assertNotEquals(SchedulingState.RUNNING, status.state());
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -143,8 +143,13 @@ test('manages a school, shared memberships and shows through the real REST API',
|
|||||||
await page.getByRole('button', { name: /Schedule groups/ }).click();
|
await page.getByRole('button', { name: /Schedule groups/ }).click();
|
||||||
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
|
await expect(page.getByText('Planning voldoet aan alle harde regels.')).toBeVisible();
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('status').filter({ hasText: 'Optimaal conceptrooster.' }),
|
page.locator('.scheduling-progress').filter({ hasText: 'Optimaal conceptrooster.' }),
|
||||||
).toBeVisible();
|
).toBeVisible();
|
||||||
|
await expect(page.locator('.scheduling-snackbar')).toContainText('Zomershow');
|
||||||
|
await expect(page.locator('.scheduling-progress')).toContainText('Spreiding:');
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator('.scheduling-progress')).toContainText('Optimaal conceptrooster.');
|
||||||
|
await expect(page.locator('.scheduling-snackbar')).toHaveCount(0);
|
||||||
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
|
await page.getByRole('button', { name: 'Indeling en volgorde bewerken' }).click();
|
||||||
const scheduleDialog = page.getByRole('dialog');
|
const scheduleDialog = page.getByRole('dialog');
|
||||||
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();
|
await scheduleDialog.getByRole('button', { name: 'Definitief maken' }).click();
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ server {
|
|||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
proxy_buffering off;
|
||||||
|
proxy_cache off;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Connection "";
|
||||||
proxy_read_timeout 1860s;
|
proxy_read_timeout 1860s;
|
||||||
proxy_send_timeout 1860s;
|
proxy_send_timeout 1860s;
|
||||||
proxy_pass http://${BACKEND_UPSTREAM};
|
proxy_pass http://${BACKEND_UPSTREAM};
|
||||||
|
|||||||
@@ -11,7 +11,10 @@ export const appConfig: ApplicationConfig = {
|
|||||||
providers: [
|
providers: [
|
||||||
provideBrowserGlobalErrorListeners(),
|
provideBrowserGlobalErrorListeners(),
|
||||||
provideHttpClient(),
|
provideHttpClient(),
|
||||||
provideRouter(routes, withInMemoryScrolling({ scrollPositionRestoration: 'enabled' })),
|
provideRouter(
|
||||||
|
routes,
|
||||||
|
withInMemoryScrolling({ scrollPositionRestoration: 'enabled', anchorScrolling: 'enabled' }),
|
||||||
|
),
|
||||||
{ provide: LOCALE_ID, useValue: 'nl-NL' },
|
{ provide: LOCALE_ID, useValue: 'nl-NL' },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -237,3 +237,30 @@ main {
|
|||||||
margin: 0 20px;
|
margin: 0 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.scheduling-snackbar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
right: 1.5rem;
|
||||||
|
z-index: 1000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: min(40rem, calc(100vw - 3rem));
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
background: #193b33;
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: 0 0.25rem 1rem #0003;
|
||||||
|
}
|
||||||
|
.scheduling-snackbar a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.scheduling-snackbar button {
|
||||||
|
background: transparent;
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,3 +44,19 @@
|
|||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@if (scheduling.notifications()[0]; as run) {
|
||||||
|
<aside class="scheduling-snackbar" role="status" aria-live="polite">
|
||||||
|
<span
|
||||||
|
><strong>{{ run.showName }}</strong
|
||||||
|
>: {{ run.message }}</span
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
[routerLink]="['/schools', run.schoolId, 'shows']"
|
||||||
|
[fragment]="'show-' + run.showId"
|
||||||
|
(click)="scheduling.dismiss()"
|
||||||
|
>Bekijk show</a
|
||||||
|
>
|
||||||
|
<button type="button" aria-label="Melding sluiten" (click)="scheduling.dismiss()">×</button>
|
||||||
|
</aside>
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,17 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { provideRouter } from '@angular/router';
|
import { provideRouter } from '@angular/router';
|
||||||
import { App } from './app';
|
import { App } from './app';
|
||||||
|
import { SchedulingStatus } from './core/scheduling-status';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
|
||||||
describe('Application shell', () => {
|
describe('Application shell', () => {
|
||||||
it('provides navigation and a keyboard skip link', async () => {
|
it('provides navigation and a keyboard skip link', async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [App],
|
imports: [App],
|
||||||
providers: [provideRouter([])],
|
providers: [
|
||||||
|
provideRouter([]),
|
||||||
|
{ provide: SchedulingStatus, useValue: { connect: vi.fn(), notifications: signal([]) } },
|
||||||
|
],
|
||||||
}).compileComponents();
|
}).compileComponents();
|
||||||
const fixture = TestBed.createComponent(App);
|
const fixture = TestBed.createComponent(App);
|
||||||
await fixture.whenStable();
|
await fixture.whenStable();
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Component } from '@angular/core';
|
import { SchedulingStatus } from './core/scheduling-status';
|
||||||
|
import { Component, inject, OnInit } from '@angular/core';
|
||||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -7,4 +8,10 @@ import { RouterLink, RouterOutlet } from '@angular/router';
|
|||||||
templateUrl: './app.html',
|
templateUrl: './app.html',
|
||||||
styleUrl: './app.css',
|
styleUrl: './app.css',
|
||||||
})
|
})
|
||||||
export class App {}
|
export class App implements OnInit {
|
||||||
|
readonly scheduling = inject(SchedulingStatus);
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.scheduling.connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
108
agiledancers-fe/src/app/core/scheduling-status.spec.ts
Normal file
108
agiledancers-fe/src/app/core/scheduling-status.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { SchedulingRun, SchedulingState, SchedulingStatus } from './scheduling-status';
|
||||||
|
|
||||||
|
class FakeEventSource {
|
||||||
|
static current: FakeEventSource;
|
||||||
|
readonly close = vi.fn();
|
||||||
|
onmessage?: (event: { data: string }) => void;
|
||||||
|
onerror?: () => void;
|
||||||
|
constructor(readonly url: string) {
|
||||||
|
FakeEventSource.current = this;
|
||||||
|
}
|
||||||
|
send(runs: SchedulingRun[] | null): void {
|
||||||
|
this.onmessage?.({ data: JSON.stringify({ runs }) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(state: SchedulingState, runId = 'run-1'): SchedulingRun {
|
||||||
|
return {
|
||||||
|
runId,
|
||||||
|
schoolId: 1,
|
||||||
|
showId: 2,
|
||||||
|
showName: 'Zomershow',
|
||||||
|
startedAt: '2026-11-03T12:00:00Z',
|
||||||
|
finishedAt: state === 'RUNNING' ? null : '2026-11-03T12:01:02Z',
|
||||||
|
state,
|
||||||
|
progress: { phase: 'VARIETY', feasible: true },
|
||||||
|
message: 'Afgerond',
|
||||||
|
summary: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Scheduling status over SSE', () => {
|
||||||
|
let status: SchedulingStatus;
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.stubGlobal('EventSource', FakeEventSource);
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
status = TestBed.inject(SchedulingStatus);
|
||||||
|
status.connect();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recovers an existing outcome without replaying old notifications', () => {
|
||||||
|
expect(FakeEventSource.current.url).toBe('/api/scheduling/events');
|
||||||
|
FakeEventSource.current.send([run('OPTIMAL')]);
|
||||||
|
expect(status.run(1, 2)?.state).toBe('OPTIMAL');
|
||||||
|
expect(status.notifications()).toEqual([]);
|
||||||
|
expect(status.elapsed(run('OPTIMAL'))).toBe('1m 2s');
|
||||||
|
expect(status.phase(run('OPTIMAL'))).toBe('Variatie');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a completion missed during disconnection exactly once', () => {
|
||||||
|
FakeEventSource.current.send([run('RUNNING')]);
|
||||||
|
FakeEventSource.current.onerror?.();
|
||||||
|
expect(status.connected()).toBe(false);
|
||||||
|
const disconnected = FakeEventSource.current;
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
expect(FakeEventSource.current).not.toBe(disconnected);
|
||||||
|
expect(disconnected.close).toHaveBeenCalledOnce();
|
||||||
|
expect(status.run(1, 2)?.state).toBe('RUNNING');
|
||||||
|
FakeEventSource.current.send([run('VALID')]);
|
||||||
|
expect(status.connected()).toBe(true);
|
||||||
|
expect(status.notifications()).toHaveLength(1);
|
||||||
|
status.dismiss();
|
||||||
|
FakeEventSource.current.send([run('VALID')]);
|
||||||
|
expect(status.notifications()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not treat heartbeats or phase changes as completions', () => {
|
||||||
|
FakeEventSource.current.send(null);
|
||||||
|
FakeEventSource.current.send([run('OPTIMAL')]);
|
||||||
|
expect(status.notifications()).toEqual([]);
|
||||||
|
FakeEventSource.current.send([run('RUNNING', 'run-2')]);
|
||||||
|
FakeEventSource.current.send(null);
|
||||||
|
expect(status.run(1, 2)?.runId).toBe('run-2');
|
||||||
|
expect(status.notifications()).toEqual([]);
|
||||||
|
FakeEventSource.current.send([run('SOLVER_FAILED', 'run-2')]);
|
||||||
|
expect(status.notifications()).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('announces a locally started run completed before the first SSE snapshot', () => {
|
||||||
|
FakeEventSource.current.onerror?.();
|
||||||
|
status.started(run('RUNNING'));
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
FakeEventSource.current.send([run('OPTIMAL'), run('VALID', 'historical')]);
|
||||||
|
expect(status.notifications().map((item) => item.runId)).toEqual(['run-1']);
|
||||||
|
status.dismiss();
|
||||||
|
FakeEventSource.current.onerror?.();
|
||||||
|
vi.advanceTimersByTime(3000);
|
||||||
|
FakeEventSource.current.send([run('OPTIMAL'), run('VALID', 'historical')]);
|
||||||
|
expect(status.notifications()).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retains a fast completion when the start response arrives after SSE', () => {
|
||||||
|
FakeEventSource.current.send([]);
|
||||||
|
FakeEventSource.current.send([run('OPTIMAL')]);
|
||||||
|
status.started(run('RUNNING'));
|
||||||
|
expect(status.run(1, 2)?.state).toBe('OPTIMAL');
|
||||||
|
status.connect();
|
||||||
|
const source = FakeEventSource.current;
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
expect(source.close).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
124
agiledancers-fe/src/app/core/scheduling-status.ts
Normal file
124
agiledancers-fe/src/app/core/scheduling-status.ts
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
import { DestroyRef, inject, Injectable, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export type SchedulingState =
|
||||||
|
| 'RUNNING'
|
||||||
|
| 'OPTIMAL'
|
||||||
|
| 'VALID'
|
||||||
|
| 'STALE'
|
||||||
|
| 'INVALID_INPUT'
|
||||||
|
| 'INFEASIBLE'
|
||||||
|
| 'NO_SOLUTION'
|
||||||
|
| 'SOLVER_FAILED';
|
||||||
|
export type SchedulingPhase = 'PREPARING' | 'ATTENDANCE' | 'SPREAD' | 'VARIETY';
|
||||||
|
|
||||||
|
export interface SchedulingRun {
|
||||||
|
runId: string;
|
||||||
|
schoolId: number;
|
||||||
|
showId: number;
|
||||||
|
showName: string;
|
||||||
|
startedAt: string;
|
||||||
|
finishedAt: string | null;
|
||||||
|
state: SchedulingState;
|
||||||
|
progress: { phase: SchedulingPhase; feasible: boolean };
|
||||||
|
message: string;
|
||||||
|
summary: {
|
||||||
|
attendanceExcess: number;
|
||||||
|
performanceSpread: number;
|
||||||
|
varietyPenalty: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class SchedulingStatus {
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
readonly runs = signal<SchedulingRun[]>([]);
|
||||||
|
readonly connected = signal(false);
|
||||||
|
readonly notifications = signal<SchedulingRun[]>([]);
|
||||||
|
readonly now = signal(Date.now());
|
||||||
|
private source?: EventSource;
|
||||||
|
private reconnectTimer?: ReturnType<typeof setTimeout>;
|
||||||
|
private initialized = false;
|
||||||
|
private readonly seen = new Set<string>();
|
||||||
|
|
||||||
|
connect(): void {
|
||||||
|
if (this.source) return;
|
||||||
|
this.open();
|
||||||
|
const timer = setInterval(() => this.now.set(Date.now()), 1000);
|
||||||
|
this.destroyRef.onDestroy(() => {
|
||||||
|
this.source?.close();
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
clearInterval(timer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private open(): void {
|
||||||
|
const source = new EventSource('/api/scheduling/events');
|
||||||
|
this.source = source;
|
||||||
|
source.onmessage = (event: MessageEvent<string>) => {
|
||||||
|
const eventData = JSON.parse(event.data) as { runs: SchedulingRun[] | null };
|
||||||
|
if (eventData.runs !== null) this.accept(eventData.runs);
|
||||||
|
this.connected.set(true);
|
||||||
|
};
|
||||||
|
source.onerror = () => {
|
||||||
|
this.connected.set(false);
|
||||||
|
// Retry also when a proxy error makes native EventSource reconnection stop permanently.
|
||||||
|
source.close();
|
||||||
|
clearTimeout(this.reconnectTimer);
|
||||||
|
this.reconnectTimer = setTimeout(() => this.open(), 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private accept(runs: SchedulingRun[]): void {
|
||||||
|
const activeRuns = new Set(
|
||||||
|
this.runs()
|
||||||
|
.filter((run) => run.state === 'RUNNING')
|
||||||
|
.map((run) => run.runId),
|
||||||
|
);
|
||||||
|
for (const run of runs) {
|
||||||
|
if (run.state === 'RUNNING' || this.seen.has(run.runId)) continue;
|
||||||
|
this.seen.add(run.runId);
|
||||||
|
if (this.initialized || activeRuns.has(run.runId))
|
||||||
|
this.notifications.update((items) => [...items, run]);
|
||||||
|
}
|
||||||
|
this.initialized = true;
|
||||||
|
this.runs.set(runs);
|
||||||
|
}
|
||||||
|
|
||||||
|
run(schoolId: number, showId: number): SchedulingRun | undefined {
|
||||||
|
return this.runs().find((run) => run.schoolId === schoolId && run.showId === showId);
|
||||||
|
}
|
||||||
|
|
||||||
|
started(run: SchedulingRun): void {
|
||||||
|
// The SSE completion can arrive before the POST response. Never overwrite it with RUNNING.
|
||||||
|
if (this.runs().some((item) => item.runId === run.runId)) return;
|
||||||
|
this.runs.update((items) => [...items.filter((item) => item.showId !== run.showId), run]);
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed(run: SchedulingRun): string {
|
||||||
|
const seconds = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor(
|
||||||
|
((run.finishedAt ? Date.parse(run.finishedAt) : this.now()) - Date.parse(run.startedAt)) /
|
||||||
|
1000,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
phase(run: SchedulingRun): string {
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
{
|
||||||
|
PREPARING: 'Voorbereiden',
|
||||||
|
ATTENDANCE: 'Aanwezigheid',
|
||||||
|
SPREAD: 'Spreiding',
|
||||||
|
VARIETY: 'Variatie',
|
||||||
|
} satisfies Record<SchedulingPhase, string>
|
||||||
|
)[run.progress.phase] ?? run.progress.phase
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(): void {
|
||||||
|
this.notifications.update((items) => items.slice(1));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,11 +73,11 @@ describe('SchoolApi contract', () => {
|
|||||||
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
|
.flush({ message: 'Invalid data' }, { status: 400, statusText: 'Bad Request' });
|
||||||
expect(failure).toHaveBeenCalledOnce();
|
expect(failure).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
it('schedules synchronously with room for the 30-minute solver limit', () => {
|
it('starts a background calculation without a solver-length HTTP timeout', () => {
|
||||||
api.schedule(7, 3).subscribe();
|
api.schedule(7, 3).subscribe();
|
||||||
const request = http.expectOne('/api/dance-schools/7/shows/3/schedule');
|
const request = http.expectOne('/api/dance-schools/7/shows/3/schedule');
|
||||||
expect(request.request.method).toBe('POST');
|
expect(request.request.method).toBe('POST');
|
||||||
expect(request.request.timeout).toBe(1860000);
|
expect(request.request.timeout).toBeUndefined();
|
||||||
request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' });
|
request.flush({ optimal: false, message: 'Valid result; optimal balance not proven' });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SchedulingRun } from './scheduling-status';
|
||||||
import { inject, Injectable } from '@angular/core';
|
import { inject, Injectable } from '@angular/core';
|
||||||
import { HttpClient } from '@angular/common/http';
|
import { HttpClient } from '@angular/common/http';
|
||||||
import { forkJoin, Observable } from 'rxjs';
|
import { forkJoin, Observable } from 'rxjs';
|
||||||
@@ -47,12 +48,8 @@ export class SchoolApi {
|
|||||||
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
|
saveShow(schoolId: number, body: ShowRequest, id?: number): Observable<Show> {
|
||||||
return this.save(`${this.base}/${schoolId}/shows`, body, id);
|
return this.save(`${this.base}/${schoolId}/shows`, body, id);
|
||||||
}
|
}
|
||||||
schedule(schoolId: number, showId: number): Observable<{ optimal: boolean; message: string }> {
|
schedule(schoolId: number, showId: number): Observable<SchedulingRun> {
|
||||||
return this.http.post<{ optimal: boolean; message: string }>(
|
return this.http.post<SchedulingRun>(`${this.base}/${schoolId}/shows/${showId}/schedule`, null);
|
||||||
`${this.base}/${schoolId}/shows/${showId}/schedule`,
|
|
||||||
null,
|
|
||||||
{ timeout: 1860000 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
|
saveAssignments(schoolId: number, showId: number, assignments: number[][]): Observable<Show> {
|
||||||
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
|
return this.http.put<Show>(`${this.base}/${schoolId}/shows/${showId}/assignments`, {
|
||||||
|
|||||||
@@ -344,7 +344,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="show-list">
|
<div class="show-list">
|
||||||
@for (show of shows(); track show.id) {
|
@for (show of shows(); track show.id) {
|
||||||
<article class="show-card">
|
<article class="show-card" [id]="'show-' + show.id">
|
||||||
<div class="date-tile">
|
<div class="date-tile">
|
||||||
<strong>{{ firstDate(show) | date: 'dd' }}</strong
|
<strong>{{ firstDate(show) | date: 'dd' }}</strong
|
||||||
><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span>
|
><span>{{ firstDate(show) | date: 'MMM yyyy' }}</span>
|
||||||
@@ -372,6 +372,48 @@
|
|||||||
: 'Geen geldige planning. Plan de groepen opnieuw in.'
|
: 'Geen geldige planning. Plan de groepen opnieuw in.'
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
|
@if (schedulingStatus.run(context.school.id, show.id); as run) {
|
||||||
|
<div class="scheduling-progress" role="status">
|
||||||
|
@if (!schedulingStatus.connected()) {
|
||||||
|
<p>
|
||||||
|
Verbinding verbroken. Status mogelijk verouderd; de berekening loopt door.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (run.state === 'RUNNING') {
|
||||||
|
<p>
|
||||||
|
Bezig met inplannen · {{ schedulingStatus.phase(run) }} ·
|
||||||
|
{{ schedulingStatus.elapsed(run) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{
|
||||||
|
run.progress.feasible
|
||||||
|
? 'Geldige oplossing gevonden; optimalisatie loopt door.'
|
||||||
|
: 'Nog geen geldige oplossing gevonden.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
} @else {
|
||||||
|
<p>{{ run.message }}</p>
|
||||||
|
<p>
|
||||||
|
Afgerond: {{ run.finishedAt | date: 'dd-MM-yyyy HH:mm:ss' }} · Duur:
|
||||||
|
{{ schedulingStatus.elapsed(run) }}
|
||||||
|
</p>
|
||||||
|
@if (run.summary; as summary) {
|
||||||
|
<p>
|
||||||
|
Aanwezigheid: {{ summary.attendanceExcess }} extra dagdelen boven voorkeur
|
||||||
|
(showcasedansers, exclusief coaches).
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Spreiding: {{ summary.performanceSpread }} optredens verschil tussen drukste
|
||||||
|
en rustigste dagdeel.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Variatie: {{ summary.varietyPenalty }} strafpunten voor opeenvolgende
|
||||||
|
groepen (lager is beter).
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="button primary"
|
class="button primary"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { SchoolTransfer } from '../shared/school-transfer';
|
import { SchoolTransfer } from '../shared/school-transfer';
|
||||||
import { Component, computed, DestroyRef, inject, OnInit, signal } from '@angular/core';
|
import { Component, computed, DestroyRef, effect, inject, OnInit, signal } from '@angular/core';
|
||||||
import { DatePipe } from '@angular/common';
|
import { DatePipe } from '@angular/common';
|
||||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
import { ActivatedRoute, Router, RouterLink, RouterLinkActive } from '@angular/router';
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
Show,
|
Show,
|
||||||
} from '../core/api-models';
|
} from '../core/api-models';
|
||||||
import { apiError } from '../core/api-error';
|
import { apiError } from '../core/api-error';
|
||||||
|
import { SchedulingStatus } from '../core/scheduling-status';
|
||||||
import { SchoolApi } from '../core/school-api';
|
import { SchoolApi } from '../core/school-api';
|
||||||
import { EntityEditor } from '../shared/entity-editor';
|
import { EntityEditor } from '../shared/entity-editor';
|
||||||
import { ConfirmDialog } from '../shared/confirm-dialog';
|
import { ConfirmDialog } from '../shared/confirm-dialog';
|
||||||
@@ -57,7 +58,30 @@ export class SchoolPage implements OnInit {
|
|||||||
readonly scheduleEditing = signal<Show | null>(null);
|
readonly scheduleEditing = signal<Show | null>(null);
|
||||||
readonly members = signal<DanceGroup | null>(null);
|
readonly members = signal<DanceGroup | null>(null);
|
||||||
readonly deleting = signal<DeleteTarget | null>(null);
|
readonly deleting = signal<DeleteTarget | null>(null);
|
||||||
readonly scheduling = signal<number[]>([]);
|
readonly schedulingStatus = inject(SchedulingStatus);
|
||||||
|
private readonly starting = signal<number[]>([]);
|
||||||
|
readonly scheduling = computed(() => [
|
||||||
|
...this.starting(),
|
||||||
|
...this.schedulingStatus
|
||||||
|
.runs()
|
||||||
|
.filter((run) => run.schoolId === this.data()?.school.id && run.state === 'RUNNING')
|
||||||
|
.map((run) => run.showId),
|
||||||
|
]);
|
||||||
|
private readonly completed = new Set<string>();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const schoolId = this.data()?.school.id;
|
||||||
|
let changed = false;
|
||||||
|
for (const run of this.schedulingStatus.runs()) {
|
||||||
|
if (run.schoolId !== schoolId || run.state === 'RUNNING' || this.completed.has(run.runId))
|
||||||
|
continue;
|
||||||
|
this.completed.add(run.runId);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) void this.reload();
|
||||||
|
});
|
||||||
|
}
|
||||||
readonly deleteBusy = signal(false);
|
readonly deleteBusy = signal(false);
|
||||||
readonly deleteError = signal('');
|
readonly deleteError = signal('');
|
||||||
readonly search = signal('');
|
readonly search = signal('');
|
||||||
@@ -151,20 +175,15 @@ export class SchoolPage implements OnInit {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return;
|
return;
|
||||||
this.scheduling.update((ids) => [...ids, show.id]);
|
this.starting.update((ids) => [...ids, show.id]);
|
||||||
this.error.set('');
|
this.error.set('');
|
||||||
try {
|
try {
|
||||||
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
|
const result = await firstValueFrom(this.api.schedule(schoolId, show.id));
|
||||||
this.notice.set(
|
this.schedulingStatus.started(result);
|
||||||
result.optimal
|
|
||||||
? 'Optimaal conceptrooster.'
|
|
||||||
: 'Geldig conceptrooster; optimaliteit niet bewezen.',
|
|
||||||
);
|
|
||||||
await this.reload();
|
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
this.error.set(apiError(error));
|
this.error.set(apiError(error));
|
||||||
} finally {
|
} finally {
|
||||||
this.scheduling.update((ids) => ids.filter((id) => id !== show.id));
|
this.starting.update((ids) => ids.filter((id) => id !== show.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async reload(): Promise<void> {
|
async reload(): Promise<void> {
|
||||||
|
|||||||
84
specs/S12-validation.md
Normal file
84
specs/S12-validation.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# S12 implementation validation
|
||||||
|
|
||||||
|
## Delivered behavior
|
||||||
|
|
||||||
|
Scheduling starts through a short POST returning 202 and a run identifier.
|
||||||
|
The backend reserves and snapshots inputs before submitting background work;
|
||||||
|
completion applies a validated result or records a distinct unsuccessful outcome.
|
||||||
|
Per-show exclusion remains in the shared school service, with different shows
|
||||||
|
allowed to calculate concurrently. Status publication and subscription snapshots
|
||||||
|
share a lock so connecting cannot miss a transition between snapshot and subscription.
|
||||||
|
|
||||||
|
The solver publishes optimization phases and first feasible-solution discovery
|
||||||
|
through a callback. Intermediate assignments remain private. Final summaries
|
||||||
|
calculate the same attendance, spread and adjacency penalties as the solver.
|
||||||
|
|
||||||
|
SSE provides a current snapshot on connection and status changes, with separate
|
||||||
|
15-second heartbeats. The nginx proxy disables buffering. The frontend keeps one
|
||||||
|
application-wide connection, explicitly retries connection errors (including
|
||||||
|
permanent EventSource failures caused by proxy responses), and reconciles state
|
||||||
|
without event replay. Run identifiers deduplicate completion notifications.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
- Maven verify: 60 backend tests passed, plus 19 standards-checker regression tests.
|
||||||
|
- Backend line coverage: 1346/1384, or 97.25%, above the required 80%.
|
||||||
|
- Frontend: 30 unit tests, production build and ESLint.
|
||||||
|
- Playwright: three real REST/browser scenarios, including scheduling completion,
|
||||||
|
result summaries, a completion snackbar, reloading to recover the latest result
|
||||||
|
and suppression of old notifications after reload.
|
||||||
|
- HTTP SSE integration verifies initial snapshot, pushed completion and a new
|
||||||
|
connection recovering the completed run.
|
||||||
|
- Background tests cover duplicate exclusion, parallel shows, stale-result
|
||||||
|
rejection, preserved assignments, failure outcomes and retry after failure.
|
||||||
|
- Frontend tests cover missed completion after disconnect, reconnection, heartbeat
|
||||||
|
handling, deduplication, completion arriving before the POST response and a
|
||||||
|
locally started run completing before the first successful SSE connection.
|
||||||
|
- Real solver tests cover phase/discovery callbacks, distinct infeasible/no-solution
|
||||||
|
outcomes and objective-summary definitions including coach exemptions.
|
||||||
|
|
||||||
|
## Manual standards assessment
|
||||||
|
|
||||||
|
Scheduling states and phases use backend enums and frontend string unions while
|
||||||
|
preserving their JSON names. Initial SSE recovery notifies for locally tracked
|
||||||
|
running tasks while suppressing historical completions.
|
||||||
|
|
||||||
|
No waivers or weakened checks were introduced. The architecture tests pass.
|
||||||
|
New records contain immutable values; the SSE snapshot defensively copies its
|
||||||
|
list. The ten-component SchedulingRun record groups run identity, timestamps,
|
||||||
|
progress and outcome in one immutable snapshot, avoiding mutable task state in
|
||||||
|
API responses. Solver observers use the existing application-owned port and
|
||||||
|
framework-independent domain types; OR-Tools remains encapsulated in solver.
|
||||||
|
|
||||||
|
Retained review signals:
|
||||||
|
|
||||||
|
- SchedulingRuns fan-out (26): task orchestration uses JDK executor/collection
|
||||||
|
types, the scheduling port and immutable input/progress/result/status models.
|
||||||
|
Its injected dependencies remain two; splitting these short lifecycle methods
|
||||||
|
would distribute the shared publication and run-slot ordering.
|
||||||
|
- SchedulingEventsResource fan-out (16): SSE/Mutiny transport, JSON envelope and
|
||||||
|
service subscription types are confined to the REST adapter.
|
||||||
|
- OrToolsSchedulingEngine fan-out (21), solve cognitive complexity (11) and
|
||||||
|
length (41 code lines): the ordered three-stage loop retains deadline and
|
||||||
|
earlier-feasible-result fallback behavior while publishing phase/discovery.
|
||||||
|
requireSolution cyclomatic complexity (8) retains explicit mappings for stable
|
||||||
|
failure outcomes and their human-readable explanations.
|
||||||
|
- Integration/background test fan-out and the background lifecycle test's
|
||||||
|
34-line method reflect real HTTP/solver fixtures and lifecycle assertions.
|
||||||
|
Existing REST-test length signals remain below blocking limits after adapting
|
||||||
|
them to await background completion.
|
||||||
|
|
||||||
|
## Persistence follow-up in S16
|
||||||
|
|
||||||
|
The maintainer explicitly transferred durable scheduling status and
|
||||||
|
backend-restart recovery to S16 and approved completion of S12 with that scope
|
||||||
|
adjustment. S12 is IN_REVIEW in `in_review/S12_support-async-feedback-for-scheduling`;
|
||||||
|
implementation is ready for review and final acceptance is pending.
|
||||||
|
|
||||||
|
The application currently stores schools, saved assignments and the latest
|
||||||
|
scheduling status in memory. S16 now explicitly requires PostgreSQL storage of
|
||||||
|
accepted runs and their latest status, atomic durable result/status commits,
|
||||||
|
stable identifiers in recovered API/SSE snapshots and startup conversion of
|
||||||
|
previously running calculations to Interrupted without automatic resumption.
|
||||||
|
Restart/crash recovery tests belong to S16; the S12 validation above covers
|
||||||
|
navigation, browser reload and connection recovery within one backend lifetime.
|
||||||
144
specs/in_review/S12_support-async-feedback-for-scheduling
Normal file
144
specs/in_review/S12_support-async-feedback-for-scheduling
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# S12 — Asynchronous feedback for scheduling
|
||||||
|
|
||||||
|
Status: IN_REVIEW
|
||||||
|
|
||||||
|
Agreed after the grill-me interview; shared understanding confirmed.
|
||||||
|
|
||||||
|
## User story
|
||||||
|
|
||||||
|
As a dance-school maintainer, I want feedback after starting Schedule Groups,
|
||||||
|
so that I can see whether the backend is still calculating, when it finishes
|
||||||
|
and what the outcome is, including when I leave and return to the show page.
|
||||||
|
|
||||||
|
## Background execution and concurrency
|
||||||
|
|
||||||
|
- Start scheduling in the backend without keeping the initiating HTTP request
|
||||||
|
open until the calculation finishes. Navigation, page reloads and disconnected
|
||||||
|
clients must not stop the calculation.
|
||||||
|
- The backend owns the current scheduling status for each show. All open
|
||||||
|
screens see the same status, including screens that did not start the run.
|
||||||
|
- Allow at most one active calculation per show. Disable Schedule Groups while
|
||||||
|
it is running and reject duplicate starts in the backend as well.
|
||||||
|
- Different shows may calculate concurrently. A global capacity limit and a
|
||||||
|
waiting queue are outside S12.
|
||||||
|
- Retain the existing 30-minute solver limit and scheduling rules.
|
||||||
|
|
||||||
|
## Progress and outcomes
|
||||||
|
|
||||||
|
- While running, show elapsed time, the current optimization phase (attendance,
|
||||||
|
spread or variety), and whether a valid solution has been found.
|
||||||
|
- Report a valid solution as soon as the solver discovers one, including during
|
||||||
|
a phase. Extend solver progress reporting rather than waiting for the phase
|
||||||
|
to finish. Do not apply intermediate solutions.
|
||||||
|
- Do not present a completion percentage or estimated finish time: the phases
|
||||||
|
do not represent proportional parts of the calculation time.
|
||||||
|
- Keep the latest status and outcome per show until a new calculation starts.
|
||||||
|
Show the finish time and distinguish an optimal solution from a valid solution
|
||||||
|
whose optimality was not proven within the time limit.
|
||||||
|
- For a successful result, show a concise summary with concrete results for
|
||||||
|
attendance, spread and variety, using understandable labels.
|
||||||
|
- Clearly distinguish invalid input, infeasibility, solver failure, time limit
|
||||||
|
without a valid solution and rejection because inputs changed. Explain why
|
||||||
|
no result was applied and that existing assignments were preserved.
|
||||||
|
Backend-restart interruption recovery belongs to S16.
|
||||||
|
|
||||||
|
## Applying results
|
||||||
|
|
||||||
|
- Retain the confirmation before starting that explains replacement of existing
|
||||||
|
assignments. A successful completed calculation automatically replaces them;
|
||||||
|
no preview/apply step is introduced.
|
||||||
|
- A valid solution may be applied when the time limit expires even if optimality
|
||||||
|
has not been proven.
|
||||||
|
- Preserve the existing scheduling input revision check. A result based on
|
||||||
|
outdated inputs must not replace the current assignments.
|
||||||
|
- Failed, infeasible, interrupted, stale and no-solution calculations preserve
|
||||||
|
existing assignments.
|
||||||
|
- Report success only after the resulting assignments have been saved. Keep
|
||||||
|
status and saved results consistent.
|
||||||
|
|
||||||
|
## Server-sent events and connection recovery
|
||||||
|
|
||||||
|
- Use server-sent events (SSE) for backend-to-frontend status updates.
|
||||||
|
- On initial connection and reconnection, provide the current backend status.
|
||||||
|
Correctness must not depend on receiving every previous event.
|
||||||
|
- Maintain updates across navigation within the application so completion
|
||||||
|
notifications can appear outside the show page.
|
||||||
|
- If the connection is lost, indicate that displayed status may be outdated
|
||||||
|
while the backend calculation continues. Reconnect and reconcile with the
|
||||||
|
current status when connectivity returns.
|
||||||
|
- Configure the application's proxy for streaming so updates reach the client
|
||||||
|
promptly rather than being buffered until completion.
|
||||||
|
|
||||||
|
## Completion notifications
|
||||||
|
|
||||||
|
- Show an application-wide snackbar when a calculation completes or fails,
|
||||||
|
including when the user has navigated to another application page.
|
||||||
|
- Include the show name, a short outcome and a View show action.
|
||||||
|
- Phase changes update progress without generating snackbars.
|
||||||
|
- After temporary connection loss, show a missed completion notification once
|
||||||
|
when the connection recovers. Reconnection must not repeatedly notify for
|
||||||
|
the same completion.
|
||||||
|
- Do not replay old snackbars on page reload or reopening the application;
|
||||||
|
show the retained status on the show page instead.
|
||||||
|
- Email, browser notifications and notifications outside the application are
|
||||||
|
outside S12.
|
||||||
|
|
||||||
|
## Persistence and backend restarts
|
||||||
|
|
||||||
|
The maintainer explicitly transferred durable scheduling status and recovery
|
||||||
|
following backend restarts to [S16](../refined/S16_store-state-in-persistent-storage)
|
||||||
|
when completing S12. The latest status and outcome in S12 survive navigation,
|
||||||
|
page reloads and SSE reconnection while the same backend process remains active.
|
||||||
|
Schools, assignments and scheduling status currently remain in memory.
|
||||||
|
|
||||||
|
S16 owns PostgreSQL storage of the latest run, atomic persistence of saved
|
||||||
|
assignments and completed status, and startup recovery of previously running
|
||||||
|
calculations as Interrupted without automatic resumption. Previously saved
|
||||||
|
results must survive restart under S16. These are not completion criteria for S12.
|
||||||
|
Distributed execution and scheduling on another PC remain in S13.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Cancelling a calculation or applying an intermediate solution.
|
||||||
|
- History of earlier calculations beyond the latest status and outcome.
|
||||||
|
- Global capacity limits, queues and remote workers.
|
||||||
|
- Changes to scheduling objectives or constraints.
|
||||||
|
- This specification grants no coding-standards waiver.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- [x] Starting Schedule Groups returns without waiting for solver completion;
|
||||||
|
leaving or reloading the page does not interrupt the calculation.
|
||||||
|
- [x] Opening a show in another screen recovers its current backend status;
|
||||||
|
duplicate runs for the same show are prevented in frontend and backend,
|
||||||
|
while different shows can run concurrently.
|
||||||
|
- [x] Running status shows elapsed time, phase and discovery of a valid solution
|
||||||
|
during a phase, without a fabricated completion percentage or ETA.
|
||||||
|
- [x] SSE delivers status changes promptly through the deployed proxy. Initial
|
||||||
|
connection and reconnection reconcile the current state, and loss of
|
||||||
|
connectivity visibly marks status as potentially outdated.
|
||||||
|
- [x] Successful runs automatically apply their result after the existing start
|
||||||
|
confirmation, retain stale-input protection and distinguish proven optimal
|
||||||
|
results from valid results at the time limit.
|
||||||
|
- [x] The final display includes finish time and concrete attendance, spread and
|
||||||
|
variety results when successful; unsuccessful outcomes are distinguishable
|
||||||
|
and explain that previous assignments were preserved.
|
||||||
|
- [x] An application-wide snackbar reports completion or failure with the show
|
||||||
|
name and View show action, including after navigation to another page.
|
||||||
|
- [x] Connection recovery notifies once for a missed completion; phase changes,
|
||||||
|
repeated reconnection and reopening do not replay old snackbars.
|
||||||
|
- [x] The latest status and outcome survive navigation, page reload and SSE
|
||||||
|
reconnection within the lifetime of the running backend. Durable storage
|
||||||
|
and recovery after backend restart are explicitly transferred to S16.
|
||||||
|
|
||||||
|
## Review handoff
|
||||||
|
|
||||||
|
Background execution, solver progress callbacks, SSE, frontend status recovery,
|
||||||
|
result summaries, proxy streaming configuration and application-wide completion
|
||||||
|
notifications are implemented and verified. S12 replaces S9's synchronous request
|
||||||
|
behavior while retaining scheduling and result-application protections.
|
||||||
|
|
||||||
|
See [S12 validation](../S12-validation.md) for verification and retained standards
|
||||||
|
signals. Durable storage and backend-restart recovery are tracked in S16 and do
|
||||||
|
not block review of this specification under the agreed scope adjustment.
|
||||||
|
Implementation is ready for review; final acceptance is pending.
|
||||||
@@ -69,6 +69,34 @@ JSON remains the import/export format, rather than the primary storage model.
|
|||||||
interrupted, rather than remain indefinitely marked as running. They do not
|
interrupted, rather than remain indefinitely marked as running. They do not
|
||||||
resume automatically. Previously saved results remain available.
|
resume automatically. Previously saved results remain available.
|
||||||
|
|
||||||
|
## Persistent scheduling status (transferred from S12)
|
||||||
|
|
||||||
|
The maintainer explicitly moved durable scheduling status and backend-restart
|
||||||
|
recovery from S12 to S16 when completing S12. S12 supplies background execution,
|
||||||
|
status snapshots, SSE and notifications within the lifetime of the backend.
|
||||||
|
|
||||||
|
- Persist the latest calculation per show in PostgreSQL, including its run
|
||||||
|
identifier, school/show identity, start and finish timestamps, state, latest
|
||||||
|
phase, whether a valid solution was found, outcome explanation and final
|
||||||
|
attendance/spread/variety summary when available. A run history is not required.
|
||||||
|
- Persist acceptance of a run before acknowledging its start. After restart or
|
||||||
|
crash, an accepted calculation must remain identifiable even if it never
|
||||||
|
produced a result.
|
||||||
|
- Commit successful assignment replacement and its completed run status
|
||||||
|
atomically. Publish success through the status API and SSE only after the
|
||||||
|
transaction commits. A failed commit must not leave a successful status with
|
||||||
|
missing assignments, or replaced assignments with an outdated running status.
|
||||||
|
- On startup, mark calculations left Running by the previous backend instance
|
||||||
|
as Interrupted before serving their recovered status. Explain that no new
|
||||||
|
result was applied, retain previously saved assignments and allow a new run.
|
||||||
|
Do not resume calculations automatically or retain their old run-slot guards.
|
||||||
|
- Recover the latest status and outcome through S12's existing status endpoint
|
||||||
|
and initial/reconnection SSE snapshots. Preserve stable run identifiers so
|
||||||
|
reconnecting clients can deduplicate notifications; reopening the application
|
||||||
|
must not replay old completion snackbars.
|
||||||
|
- Verify recovery after both graceful restart and abrupt process termination,
|
||||||
|
including a calculation running at termination and a completed saved result.
|
||||||
|
|
||||||
## Database failures and schema changes
|
## Database failures and schema changes
|
||||||
|
|
||||||
- Refuse mutations while the database is unavailable and provide a clear failure
|
- Refuse mutations while the database is unavailable and provide a clear failure
|
||||||
@@ -103,8 +131,14 @@ for now. S16 does not promise recovery after loss of the database or its volume.
|
|||||||
another: the stale edit receives a clear conflict response, and the user
|
another: the stale edit receives a clear conflict response, and the user
|
||||||
can reload and retry.
|
can reload and retry.
|
||||||
- [ ] Scheduling results based on outdated input revisions are rejected.
|
- [ ] Scheduling results based on outdated input revisions are rejected.
|
||||||
- [ ] Interrupted calculations are recognizably interrupted after restart;
|
- [ ] The latest accepted scheduling run, its stable identifier, timestamps,
|
||||||
previously saved results remain available.
|
progress, outcome and objective summary survive normal restart and crash
|
||||||
|
and are returned through the status API and SSE snapshots.
|
||||||
|
- [ ] Successful assignment replacement and completed run status commit
|
||||||
|
atomically; success is published only after durable commit.
|
||||||
|
- [ ] Calculations left Running become Interrupted before recovered status is
|
||||||
|
served; previous assignments remain, no automatic resumption occurs and a
|
||||||
|
new run can start. Reopening does not replay old completion snackbars.
|
||||||
- [ ] Database unavailability prevents successful mutation acknowledgements;
|
- [ ] Database unavailability prevents successful mutation acknowledgements;
|
||||||
startup connection and migration failures are clearly reported.
|
startup connection and migration failures are clearly reported.
|
||||||
- [ ] An empty database can be initialized through Flyway and populated through
|
- [ ] An empty database can be initialized through Flyway and populated through
|
||||||
|
|||||||
Reference in New Issue
Block a user