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

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

View File

@@ -0,0 +1,7 @@
package nl.agilecc.agiledancers.model.scheduling;
public enum SchedulingFailureKind {
INFEASIBLE,
NO_SOLUTION,
SOLVER_FAILED
}

View File

@@ -0,0 +1,4 @@
package nl.agilecc.agiledancers.model.scheduling;
/** Progress is observational; intermediate assignments are never published or applied. */
public record SchedulingProgress(String phase, boolean feasible) {}

View File

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

View File

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

View File

@@ -18,15 +18,14 @@ import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import nl.agilecc.agiledancers.model.DayPart;
import nl.agilecc.agiledancers.model.Show;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.rest.apimodel.DayPartRequest;
import nl.agilecc.agiledancers.rest.apimodel.ScheduleRequest;
import nl.agilecc.agiledancers.rest.apimodel.SchedulingView;
import nl.agilecc.agiledancers.rest.apimodel.ShowRequest;
import nl.agilecc.agiledancers.rest.apimodel.ShowView;
import nl.agilecc.agiledancers.service.SchedulingService;
import nl.agilecc.agiledancers.service.SchedulingRuns;
import nl.agilecc.agiledancers.service.SchoolManagementService;
import nl.agilecc.agiledancers.service.ShowSchedules;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
@Path("/api/dance-schools/{schoolId}/shows")
@Produces(MediaType.APPLICATION_JSON)
@@ -34,7 +33,7 @@ import nl.agilecc.agiledancers.service.ShowSchedules;
public class ShowResource {
private @Inject SchoolManagementService service;
private @Inject SchedulingService scheduling;
private @Inject SchedulingRuns scheduling;
private @Inject ShowSchedules schedules;
@@ -105,10 +104,14 @@ public class ShowResource {
@POST
@Path("/{id}/schedule")
@Consumes(MediaType.WILDCARD)
public SchedulingView schedule(final @PathParam("id") long id) {
final SchedulingResult result = scheduling.schedule(schoolId, id);
return new SchedulingView(
result.optimal(), result.optimal() ? "Optimal schedule" : "Valid schedule; optimality not proven");
public Response schedule(final @PathParam("id") long id) {
return Response.accepted(scheduling.start(schoolId, id)).build();
}
@GET
@Path("/{id}/schedule")
public SchedulingRun schedulingStatus(final @PathParam("id") long id) {
return scheduling.status(schoolId, id);
}
@DELETE

View File

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

View File

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

View File

@@ -0,0 +1,123 @@
package nl.agilecc.agiledancers.service;
import jakarta.annotation.PreDestroy;
import jakarta.inject.Inject;
import jakarta.inject.Singleton;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
import nl.agilecc.agiledancers.service.model.SchedulingRun;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
/** Owns background tasks and publishes current state independently of any HTTP connection. */
@Singleton
public class SchedulingRuns {
private final SchoolManagementService schools;
private final SchedulingEngine engine;
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Map<Long, SchedulingRun> runs = new HashMap<>();
private final Map<Object, Consumer<List<SchedulingRun>>> listeners = new HashMap<>();
@Inject
public SchedulingRuns(final SchoolManagementService schools, final SchedulingEngine engine) {
this.schools = schools;
this.engine = engine;
}
public synchronized SchedulingRun start(final long schoolId, final long showId) {
final SchedulingInput input;
final SchedulingRun run;
synchronized (schools) {
final String name = schools.show(schoolId, showId).getName();
input = schools.beginScheduling(schoolId, showId);
run = SchedulingRun.started(schoolId, showId, name);
}
publish(run);
try {
executor.submit(() -> calculate(run, input));
} catch (final RuntimeException failure) {
schools.endScheduling(showId);
publish(run.completed("SOLVER_FAILED", "Berekening kon niet starten. Bestaande indeling behouden.", null));
throw failure;
}
return run;
}
public synchronized SchedulingRun status(final long schoolId, final long showId) {
schools.show(schoolId, showId);
return runs.get(showId);
}
/** Registration and initial snapshot share the publication lock, preventing a missed update. */
public synchronized Runnable subscribe(final Consumer<List<SchedulingRun>> listener) {
final Object key = new Object();
listeners.put(key, listener);
listener.accept(List.copyOf(runs.values()));
return () -> unsubscribe(key);
}
private synchronized void unsubscribe(final Object key) {
listeners.remove(key);
}
private synchronized void publish(final SchedulingRun run) {
runs.put(run.showId(), run);
final List<SchedulingRun> snapshot = List.copyOf(runs.values());
listeners.values().forEach((final Consumer<List<SchedulingRun>> listener) -> listener.accept(snapshot));
}
private synchronized void progress(final SchedulingRun run, final SchedulingProgress update) {
publish(runs.get(run.showId()).progressed(update));
}
private void calculate(final SchedulingRun run, final SchedulingInput input) {
SchedulingRun completed;
try {
final SchedulingResult result =
engine.solve(input, (final SchedulingProgress update) -> progress(run, update));
final SchedulingSummary summary = SchedulingSummary.of(input, result);
schools.finishScheduling(run.schoolId(), input, result);
completed = latest(run)
.completed(
result.optimal() ? "OPTIMAL" : "VALID",
result.optimal()
? "Optimaal conceptrooster."
: "Geldig conceptrooster; optimaliteit niet bewezen.",
summary);
} catch (final ManagementException failure) {
completed = failed(run, failure);
} catch (final RuntimeException | LinkageError failure) {
completed =
latest(run).completed("SOLVER_FAILED", "Berekening mislukt. Bestaande indeling behouden.", null);
}
finish(completed);
}
private SchedulingRun failed(final SchedulingRun run, final ManagementException failure) {
final String outcome = failure instanceof final SchedulingFailure scheduling
? scheduling.getOutcome().name()
: (failure.getKind() == ManagementExceptionKind.CONFLICT ? "STALE" : "INVALID_INPUT");
return latest(run).completed(outcome, failure.getMessage() + " Bestaande indeling behouden.", null);
}
private synchronized SchedulingRun latest(final SchedulingRun run) {
return runs.get(run.showId());
}
private synchronized void finish(final SchedulingRun completed) {
schools.endScheduling(completed.showId());
publish(completed);
}
@PreDestroy
public void close() {
executor.shutdownNow();
}
}

View File

@@ -0,0 +1,61 @@
package nl.agilecc.agiledancers.service.model;
import java.time.Instant;
import java.util.UUID;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingSummary;
/** Latest immutable state for one show's calculation; runId identifies notifications across reconnects. */
public record SchedulingRun(
UUID runId,
long schoolId,
long showId,
String showName,
Instant startedAt,
Instant finishedAt,
String state,
SchedulingProgress progress,
String message,
SchedulingSummary summary) {
public static SchedulingRun started(final long schoolId, final long showId, final String name) {
return new SchedulingRun(
UUID.randomUUID(),
schoolId,
showId,
name,
Instant.now(),
null,
"RUNNING",
new SchedulingProgress("PREPARING", false),
"Berekening gestart.",
null);
}
public SchedulingRun progressed(final SchedulingProgress update) {
return new SchedulingRun(
runId,
schoolId,
showId,
showName,
startedAt,
null,
state,
new SchedulingProgress(update.phase(), progress.feasible() || update.feasible()),
message,
null);
}
public SchedulingRun completed(final String outcome, final String explanation, final SchedulingSummary scores) {
return new SchedulingRun(
runId,
schoolId,
showId,
showName,
startedAt,
Instant.now(),
outcome,
new SchedulingProgress(progress.phase(), progress.feasible() || scores != null),
explanation,
scores);
}
}

View File

@@ -1,9 +1,15 @@
package nl.agilecc.agiledancers.service.port;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
public interface SchedulingEngine {
/** Computes assignments without mutating the captured inputs; the caller validates and commits the result. */
public SchedulingResult solve(final SchedulingInput input);
/** Reports phase changes and solution discovery while calculating. */
public default SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
return solve(input);
}
}

View File

@@ -7,10 +7,12 @@ import com.google.ortools.sat.CpSolverStatus;
import com.google.ortools.sat.LinearExpr;
import jakarta.inject.Singleton;
import java.util.List;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingFailureKind;
import nl.agilecc.agiledancers.model.scheduling.SchedulingInput;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
import nl.agilecc.agiledancers.model.scheduling.SchedulingResult;
import nl.agilecc.agiledancers.service.ManagementException;
import nl.agilecc.agiledancers.service.ManagementExceptionKind;
import nl.agilecc.agiledancers.service.SchedulingFailure;
import nl.agilecc.agiledancers.service.port.SchedulingEngine;
@Singleton
@@ -28,6 +30,11 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
@Override
public SchedulingResult solve(final SchedulingInput input) {
return solve(input, (final SchedulingProgress progress) -> {});
}
@Override
public SchedulingResult solve(final SchedulingInput input, final Consumer<SchedulingProgress> observer) {
final long deadline = System.nanoTime() + (long) (timeLimitSeconds * 1_000_000_000L);
Loader.loadNativeLibraries();
final CpModel model = new CpModel();
@@ -38,7 +45,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
solver.getParameters().setCatchSigintSignal(false);
solver.getParameters().setNumSearchWorkers(1).setRandomSeed(0);
SchedulingResult best = null;
for (final LinearExpr objective : List.of(attendance, LinearExpr.term(vars.spread(), 1), variety)) {
final List<LinearExpr> objectives = List.of(attendance, LinearExpr.term(vars.spread(), 1), variety);
final List<String> phases = List.of("ATTENDANCE", "SPREAD", "VARIETY");
for (int stage = 0; stage < objectives.size(); stage++) {
final LinearExpr objective = objectives.get(stage);
final String phase = phases.get(stage);
observer.accept(new SchedulingProgress(phase, best != null));
final double remaining = (deadline - System.nanoTime()) / 1_000_000_000.0;
if (remaining <= 0) {
if (best != null) {
@@ -48,7 +60,7 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
}
solver.getParameters().setMaxTimeInSeconds(Math.max(0.001, remaining));
model.minimize(objective);
final CpSolverStatus status = solver.solve(model);
final CpSolverStatus status = solver.solve(model, new SchedulingSolutionObserver(observer, phase));
if (status == CpSolverStatus.UNKNOWN && best != null) {
return new SchedulingResult(false, best.assignments());
}
@@ -64,8 +76,12 @@ public class OrToolsSchedulingEngine implements SchedulingEngine {
private static void requireSolution(final CpSolverStatus status) {
if (status != CpSolverStatus.OPTIMAL && status != CpSolverStatus.FEASIBLE) {
throw new ManagementException(
ManagementExceptionKind.INVALID_INPUT,
throw new SchedulingFailure(
switch (status) {
case INFEASIBLE -> SchedulingFailureKind.INFEASIBLE;
case UNKNOWN -> SchedulingFailureKind.NO_SOLUTION;
default -> SchedulingFailureKind.SOLVER_FAILED;
},
switch (status) {
case INFEASIBLE -> "Scheduling is infeasible. Existing assignments were preserved.";
case UNKNOWN ->

View File

@@ -0,0 +1,25 @@
package nl.agilecc.agiledancers.solver;
import com.google.ortools.sat.CpSolverSolutionCallback;
import java.util.function.Consumer;
import nl.agilecc.agiledancers.model.scheduling.SchedulingProgress;
/** Publish only the first feasible solution in a phase, avoiding an event per solver improvement. */
public class SchedulingSolutionObserver extends CpSolverSolutionCallback {
private final Consumer<SchedulingProgress> observer;
private final String phase;
private boolean reported;
public SchedulingSolutionObserver(final Consumer<SchedulingProgress> observer, final String phase) {
this.observer = observer;
this.phase = phase;
}
@Override
public void onSolutionCallback() {
if (!reported) {
reported = true;
observer.accept(new SchedulingProgress(phase, true));
}
}
}