Interlock
Warehouse load simulator · event-sourced underneath
Interlock is a simulator, and getting there meant admitting what it wasn't. It began as an animation: a scrollytelling piece, a box traveling down a conveyor belt, one package's journey told in three acts. Watching that single box move made me want to see a whole floor of them at once, so I built the floor: a workflow engine where packages move between stations under a configurable definition, every move is an event, and the board is computed from that log rather than a status column anyone updates.
For a while I entertained it as an operations dashboard. Then I admitted what it actually was, a simulation, and the logical thing was to make it a simulator: hand over the controls and see what the flow does under load. That is where the inflow knob came from, and the valves, the recirculation belt, and the redirection belt. Something I learned was that the queue forms one stage behind whatever is constrained, so the station under strain is never the one that can fix it.
// Every step carries three conditions nobody configures: an open problem holds a package
// wherever it is, a removal tag pins it in place, and a locked station holds everything
// standing on it. Capacity is added only where the NEXT station declares a limit.
public static IEnumerable<GateCondition> EffectiveConditions(WorkflowStep step, WorkflowStep? next = null)
{
foreach (var gate in step.Config.Gates) yield return gate; // approvals, per workflow
yield return new GateCondition(ExceptionConditionKey, GateConditionType.NoOpenException);
yield return new GateCondition(LockoutConditionKey, GateConditionType.StationUnlocked);
yield return new GateCondition(RemovalConditionKey, GateConditionType.NotTaggedForRemoval);
// Opted into per station, so a workflow without a limit behaves exactly as it did before.
if (next?.Config.Capacity is not null)
yield return new GateCondition(CapacityConditionKey, GateConditionType.BelowCapacity);
}
case GateConditionType.BelowCapacity:
{
// The only condition that looks downstream — at the station this package would move TO,
// not the one it is on. That single inversion is what makes jams real: a full station
// stops the one behind it, which stops the one behind that, all the way back up the belt.
// Nothing schedules that. It falls out of evaluating one package at a time.
if (next?.Config.Capacity is not { } capacity)
return new ConditionResult(condition.Key, condition.Type, true, "no limit");
var occupancy = stations.Occupancy(next.Id);
return new ConditionResult(
condition.Key,
condition.Type,
occupancy < capacity,
occupancy < capacity ? $"{next.Name} {occupancy}/{capacity}"
: $"{next.Name} full ({occupancy}/{capacity})");
}
Why this one: it is the smallest piece of code with the largest emergent behavior. Every other condition asks something about the package in front of it; capacity asks about the station ahead. Invert that one lookup and backpressure appears for free: packages pile up behind a full station, and the pile reaches back up the line on its own. The engine is pure, so this is all decided without a database, a clock, or an HTTP client anywhere near it.
The engine is a pure fold with no I/O, so the interesting behavior is cheap to pin down: fifteen scenario fixtures, a lockout, a dual approval with an override, capacity holding behind a full station, each folded four ways, including after every prefix. That last one is what proves the timeline: the scrubber and the board cannot merely agree most of the time.
Two tiers above these need infrastructure and aren't quoted: 61 xUnit tests against a real SQL Server, outbox claiming under concurrent dispatchers, the seeder, the background services, and 15 Playwright specs against the deployed app, including the one that finds a jam rather than assuming where it is, and fails if the line has none.
state The log is the record and the board is a projection of it, so the past is a query rather than a snapshot, which is why the timeline scrubber is the same data as the board, not a second system that has to agree with it.
jams Capacity is the one gate that looks at the station ahead instead of the one underfoot. That single inversion is enough: a full station stops the one behind it, which stops the one behind that, and nothing anywhere schedules a queue. The three seeded workflows declare it sparingly across their fourteen steps, six slots at Packed, five at Staged, four and three on the return flows, so which station becomes the constraint depends on what the floor is busy with.
two tiers Recirculating is cheap, not free, it spends loop slots and dwell time, and both have ceilings: twenty packages to a loop, and forty-five seconds before a package is circling against its own delivery promise. At three-quarters of that, near thirty-four seconds, the station escalates and opens a belt: eight seconds of ramp-up during which it is staffed and carrying nothing, and it may open three beyond the one it starts with. Because the cheap lever absorbs the short spikes on its own, the expensive one only ever sees demand that lasted.
backpressure The queue forms one stage behind whatever is constrained, so the station under strain is never the one that can fix it. Measuring pressure where the packages are put the lever on a station whose own loop gauge read zero; it is now attributed to the station causing it: the capacity gate's inversion applied to the response. Pressure has to hold ten seconds before a belt opens and relief forty-five before one closes, so a single spiky beat never buys labour it does not need.
shedding Past 420 active packages the line stops admitting arrivals and counts what it turned away. Jammed packages never complete, so at the top of the knob the floor would grow without limit and take the event log and the replay time with it. A real line sheds when it cannot accept more, and the shed count is the clearest signal that the system is past its limit: more honest than modeling a queue that never fills.
hosting The line runs on a timer, so left alone it would burn a core all night on an App Service plan it shares. It skips the beat entirely after a few minutes with no visitors, and because timestamps resolve against each visitor's own clock, nobody can tell it was ever asleep.