feat: add accelerated starvation-safe simulation queue
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class SimulationClock {
|
||||
private static final long MAX_SOURCE_GAP_MILLIS = 30L * 60L * 1000L;
|
||||
private final int acceleration;
|
||||
private final Map<LocalDateTime, Long> arrivals;
|
||||
|
||||
public SimulationClock(List<LocalDateTime> sourceArrivals, int acceleration) {
|
||||
if (acceleration != 10 && acceleration != 100) {
|
||||
throw new IllegalArgumentException("acceleration must be 10 or 100");
|
||||
}
|
||||
if (sourceArrivals == null || sourceArrivals.isEmpty()) {
|
||||
throw new IllegalArgumentException("source arrivals are required");
|
||||
}
|
||||
this.acceleration = acceleration;
|
||||
List<LocalDateTime> ordered = new ArrayList<LocalDateTime>(
|
||||
new LinkedHashSet<LocalDateTime>(sourceArrivals));
|
||||
if (ordered.contains(null)) throw new IllegalArgumentException("arrival must not be null");
|
||||
Collections.sort(ordered);
|
||||
LinkedHashMap<LocalDateTime, Long> converted =
|
||||
new LinkedHashMap<LocalDateTime, Long>();
|
||||
long simulationMillis = 0L;
|
||||
LocalDateTime previous = null;
|
||||
for (LocalDateTime arrival : ordered) {
|
||||
if (previous != null) {
|
||||
long sourceGap = Duration.between(previous, arrival).toMillis();
|
||||
simulationMillis += Math.min(sourceGap, MAX_SOURCE_GAP_MILLIS) / acceleration;
|
||||
}
|
||||
converted.put(arrival, simulationMillis);
|
||||
previous = arrival;
|
||||
}
|
||||
this.arrivals = Collections.unmodifiableMap(converted);
|
||||
}
|
||||
|
||||
public long toSimulationMillis(LocalDateTime sourceArrival) {
|
||||
Long value = arrivals.get(sourceArrival);
|
||||
if (value == null) throw new IllegalArgumentException("unknown source arrival");
|
||||
return value;
|
||||
}
|
||||
|
||||
public long sourceDurationMillis(long simulationDurationMillis) {
|
||||
return Math.multiplyExact(simulationDurationMillis, acceleration);
|
||||
}
|
||||
|
||||
public int getAcceleration() { return acceleration; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
public final class SimulationConfig {
|
||||
private final int acceleration;
|
||||
private final long agingIntervalMillis;
|
||||
private final int retryLimit;
|
||||
private final int noProgressLimit;
|
||||
private final int tickLimit;
|
||||
|
||||
public SimulationConfig(int acceleration, long agingIntervalMillis, int retryLimit,
|
||||
int noProgressLimit, int tickLimit) {
|
||||
if (acceleration != 10 && acceleration != 100) {
|
||||
throw new IllegalArgumentException("acceleration must be 10 or 100");
|
||||
}
|
||||
if (agingIntervalMillis <= 0 || retryLimit <= 0
|
||||
|| noProgressLimit <= 0 || tickLimit <= 0) {
|
||||
throw new IllegalArgumentException("simulation limits must be positive");
|
||||
}
|
||||
this.acceleration = acceleration;
|
||||
this.agingIntervalMillis = agingIntervalMillis;
|
||||
this.retryLimit = retryLimit;
|
||||
this.noProgressLimit = noProgressLimit;
|
||||
this.tickLimit = tickLimit;
|
||||
}
|
||||
|
||||
public static SimulationConfig defaults() {
|
||||
return new SimulationConfig(100, 30_000L, 3, 500, 20_000);
|
||||
}
|
||||
|
||||
public int getAcceleration() { return acceleration; }
|
||||
public long getAgingIntervalMillis() { return agingIntervalMillis; }
|
||||
public int getRetryLimit() { return retryLimit; }
|
||||
public int getNoProgressLimit() { return noProgressLimit; }
|
||||
public int getTickLimit() { return tickLimit; }
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
public final class SimulationTaskQueue {
|
||||
private final long agingIntervalMillis;
|
||||
private final Map<String, Entry> entries = new LinkedHashMap<String, Entry>();
|
||||
private final Map<String, Long> originalArrivals = new LinkedHashMap<String, Long>();
|
||||
|
||||
public SimulationTaskQueue(long agingIntervalMillis) {
|
||||
if (agingIntervalMillis <= 0) {
|
||||
throw new IllegalArgumentException("aging interval must be positive");
|
||||
}
|
||||
this.agingIntervalMillis = agingIntervalMillis;
|
||||
}
|
||||
|
||||
public void add(HistoricalSimulationTask task, long arrivalMillis) {
|
||||
if (task == null || arrivalMillis < 0) {
|
||||
throw new IllegalArgumentException("task and non-negative arrival are required");
|
||||
}
|
||||
String id = task.getSimulationTaskId();
|
||||
if (originalArrivals.containsKey(id)) {
|
||||
throw new IllegalArgumentException("duplicate task " + id);
|
||||
}
|
||||
originalArrivals.put(id, arrivalMillis);
|
||||
entries.put(id, new Entry(task, arrivalMillis));
|
||||
}
|
||||
|
||||
public void requeue(HistoricalSimulationTask task) {
|
||||
Long arrival = originalArrivals.get(task.getSimulationTaskId());
|
||||
if (arrival == null) throw new IllegalArgumentException("task was never admitted");
|
||||
entries.put(task.getSimulationTaskId(), new Entry(task, arrival));
|
||||
}
|
||||
|
||||
public HistoricalSimulationTask remove(String simulationTaskId) {
|
||||
Entry entry = entries.remove(simulationTaskId);
|
||||
return entry == null ? null : entry.task;
|
||||
}
|
||||
|
||||
public List<HistoricalSimulationTask> readyAt(final long nowMillis) {
|
||||
List<Entry> ready = new ArrayList<Entry>();
|
||||
for (Entry entry : entries.values()) {
|
||||
if (entry.arrivalMillis <= nowMillis) ready.add(entry);
|
||||
}
|
||||
Collections.sort(ready, Comparator
|
||||
.comparingInt((Entry entry) -> effectivePriority(entry, nowMillis))
|
||||
.thenComparingLong(entry -> entry.arrivalMillis)
|
||||
.thenComparing(entry -> text(entry.task.getRecord().getTaskNo()))
|
||||
.thenComparing(entry -> text(entry.task.getRecord().getGuid()))
|
||||
.thenComparingInt(entry -> entry.task.getArrivalSequence()));
|
||||
List<HistoricalSimulationTask> result = new ArrayList<HistoricalSimulationTask>();
|
||||
for (Entry entry : ready) result.add(entry.task);
|
||||
return Collections.unmodifiableList(result);
|
||||
}
|
||||
|
||||
public boolean isEmpty() { return entries.isEmpty(); }
|
||||
public int size() { return entries.size(); }
|
||||
|
||||
private int effectivePriority(Entry entry, long nowMillis) {
|
||||
long waited = Math.max(0L, nowMillis - entry.arrivalMillis);
|
||||
long improvement = waited / agingIntervalMillis;
|
||||
return (int) Math.max(0L, (long) entry.task.getPriority() - improvement);
|
||||
}
|
||||
|
||||
private static String text(String value) { return value == null ? "" : value; }
|
||||
|
||||
private static final class Entry {
|
||||
private final HistoricalSimulationTask task;
|
||||
private final long arrivalMillis;
|
||||
|
||||
private Entry(HistoricalSimulationTask task, long arrivalMillis) {
|
||||
this.task = task;
|
||||
this.arrivalMillis = arrivalMillis;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class SimulationClockTest {
|
||||
@Test
|
||||
void scalesRelativeArrivalsAndCapsLongIdleGaps() {
|
||||
LocalDateTime base = LocalDateTime.of(2026, 3, 1, 10, 0);
|
||||
LocalDateTime tenSecondsLater = base.plusSeconds(10);
|
||||
LocalDateTime twoHoursLater = base.plusHours(2);
|
||||
LocalDateTime afterCappedGap = twoHoursLater.plusSeconds(10);
|
||||
SimulationClock clock = new SimulationClock(Arrays.asList(
|
||||
base, tenSecondsLater, twoHoursLater, afterCappedGap), 100);
|
||||
|
||||
assertEquals(0L, clock.toSimulationMillis(base));
|
||||
assertEquals(100L, clock.toSimulationMillis(tenSecondsLater));
|
||||
assertEquals(18_100L, clock.toSimulationMillis(twoHoursLater));
|
||||
assertEquals(18_200L, clock.toSimulationMillis(afterCappedGap));
|
||||
assertEquals(1_000L, clock.sourceDurationMillis(10L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void acceptsOnlySupportedAccelerationFactors() {
|
||||
LocalDateTime base = LocalDateTime.of(2026, 3, 1, 10, 0);
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> new SimulationClock(Arrays.asList(base), 25));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.ft546.wcs.shadow.history.simulation;
|
||||
|
||||
import com.ft546.wcs.demo.model.DispatchTask;
|
||||
import com.ft546.wcs.shadow.history.HistoricalQueueRecord;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
class SimulationTaskQueueTest {
|
||||
@Test
|
||||
void ordersByPriorityThenArrivalAndProtectsOldTasksFromStarvation() {
|
||||
HistoricalSimulationTask low = task("LOW", 3, 0);
|
||||
HistoricalSimulationTask high = task("HIGH", 2, 1);
|
||||
SimulationTaskQueue queue = new SimulationTaskQueue(30_000L);
|
||||
queue.add(low, 0L);
|
||||
queue.add(high, 20_000L);
|
||||
|
||||
assertEquals(Arrays.asList(high, low), queue.readyAt(20_000L));
|
||||
assertEquals(Arrays.asList(low, high), queue.readyAt(90_000L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void requeueKeepsTheOriginalArrivalAndAccumulatedAge() {
|
||||
HistoricalSimulationTask old = task("OLD", 4, 0);
|
||||
HistoricalSimulationTask newer = task("NEW", 2, 1);
|
||||
SimulationTaskQueue queue = new SimulationTaskQueue(30_000L);
|
||||
queue.add(old, 0L);
|
||||
queue.add(newer, 60_000L);
|
||||
queue.remove(old.getSimulationTaskId());
|
||||
queue.requeue(old);
|
||||
|
||||
assertEquals(old, queue.readyAt(60_000L).get(0));
|
||||
}
|
||||
|
||||
private HistoricalSimulationTask task(String id, int priority, int sequence) {
|
||||
LocalDateTime created = LocalDateTime.of(2026, 3, 1, 10, 0).plusSeconds(sequence);
|
||||
HistoricalQueueRecord record = new HistoricalQueueRecord(
|
||||
id, id, "1", priority, "A", "B", "A,B", created.toString(),
|
||||
created.plusMinutes(1).toString(), "1");
|
||||
return new HistoricalSimulationTask(id, record, DispatchTask.TaskType.INBOUND,
|
||||
priority, created, created.plusMinutes(1), "A", "B",
|
||||
Arrays.asList("A", "B"), sequence);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user