back to projects
2026 Full-Stack

Rail Yard Slot & Conflict Manager

Full-stack rail-yard scheduling: Java 21 / Spring Boot 4.0 REST API, Angular 21 dispatcher shell, and a normalised PostgreSQL 17 schema. Conflict-free reservations are enforced at the database layer by a partial GiST exclusion constraint, not by application code.

Rail Yard Slot & Conflict Manager cover image

// outcome

tables
7
backend tests
12
endpoints
17
buffer bound
24 h

// problem

Dispatchers assign trains to yard tracks, and the conflict check has to be atomic. Two requests can both look at the same free window at the same moment, both see no conflict, and both write — a check-then-act race that no amount of application-layer care fully closes.

// approach

Push the invariant into the database: a partial GiST exclusion constraint over a half-open tstzrange on the effective occupancy window, keyed on yard and track. Setup and clearance buffers are applied before the window is stored, and a set of CHECK constraints bounds the window so no writer can bypass the exclusion constraint with an infinity bound.

// results

  • No-overlap invariant enforced by PostgreSQL — conflicting writes are rejected regardless of application race
  • Half-open `[)` ranges, so back-to-back reservations on one track are legal
  • Five CHECK constraints bound the effective occupancy window to ±24 h around the scheduled window
  • Testcontainers against real PostgreSQL 17 — exclusion-constraint behaviour cannot be reproduced on an in-memory substitute
  • RFC-style error envelope with a correlation ID propagated into the logs via MDC

// screens

Rail Yard Slot & Conflict Manager — screen 2
Rail Yard Slot & Conflict Manager — screen 3

// in depth

Why the database owns the constraint

This is the whole design. The no-overlap rule is not a service method, a repository query or a validator — it is a constraint:

ALTER TABLE track_reservations
    ADD CONSTRAINT ex_reservations_no_track_overlap
    EXCLUDE USING gist (
        yard_id WITH =,
        track_id WITH =,
        tstzrange(occupied_from, occupied_until, '[)') WITH &&
    )
    WHERE (status IN ('PLANNED', 'ACTIVE'));

Three details in that clause carry the weight.

The range is half-open — '[)'. A train that departs at 10:00 and one that arrives at 10:00 are adjacent, not overlapping. With a closed range every handover would be a conflict, and the constraint would be useless in a yard whose whole job is handover.

The predicate is partial. Only PLANNED and ACTIVE reservations block. A COMPLETED or CANCELLED row is history and must not reserve space.

yard_id is in the key deliberately. It looks redundant — the track already belongs to a yard — but it means a future migration that drops the (track_id, yard_id) composite foreign key cannot silently re-introduce a cross-yard hazard. The exclusion constraint would still refuse the write.

The equality operators on the UUID columns need btree_gist, which the first migration installs.

The window a writer can actually store

An exclusion constraint over a range is only as good as the range, and a range can be poisoned. A writer that sets occupied_until = 'infinity' satisfies the constraint trivially — it overlaps nothing because it ends after the end of time.

So the effective occupancy window is bounded by five CHECK constraints:

CONSTRAINT ck_reservations_schedule       CHECK (scheduled_departure > scheduled_arrival),
CONSTRAINT ck_reservations_occupancy       CHECK (occupied_until > occupied_from),
CONSTRAINT ck_reservations_arrival_buffer  CHECK (occupied_from <= scheduled_arrival),
CONSTRAINT ck_reservations_departure_buffer CHECK (occupied_until >= scheduled_departure),
CONSTRAINT ck_reservations_buffer_bounds   CHECK (
    occupied_until <= scheduled_departure + INTERVAL '24 hours'
    AND occupied_from  >= scheduled_arrival  - INTERVAL '24 hours')

The last one is the point: it stops a buggy or compromised writer from parking a reservation outside the schedule it claims to serve.

Buffers are applied before storage

Each track carries its own setup_buffer_minutes and clearance_buffer_minutes, both constrained to be non-negative. Those are folded into the effective occupancy window before it is written, so the constraint compares the space a train actually needs rather than the window someone typed on a timetable.

The schema

Seven tables: yards, tracks, track_capabilities, trains, train_required_capabilities, track_reservations and audit_events — plus six indexes.

track_capabilities and train_required_capabilities are tables rather than a CSV column on purpose: “this train needs electrified track” has to be a relation, so that a reservation cannot claim a yard different from its track’s yard. The composite foreign key (track_id, yard_id) → tracks(id, yard_id) enforces it structurally.

Testing against the real thing

The exclusion constraint’s behaviour cannot be reproduced on H2 or any in-memory substitute — the whole point of the design is a behaviour the tests would not reproduce faithfully elsewhere. So the backend tests run against real PostgreSQL 17 in Testcontainers, including the case that matters: two windows on one track that merely touch are accepted, and a third that overlaps them is rejected by the database.

Everything else that has to be right

A CorrelationIdFilter reads or generates X-Correlation-Id and pushes it into the SLF4J MDC, so a request, its logs and its audit event share one identifier. Every error — including a type mismatch — is mapped by a single @RestControllerAdvice into one envelope: code, message, correlation ID, timestamp, path, field errors. Entities are never serialised directly; every response is an explicit record.

Delivery is three containers — PostgreSQL 17 with a pg_isready health check, the backend, and an Nginx frontend that proxies /api, /actuator and the OpenAPI and Swagger paths — behind a three-job CI: ./mvnw verify, the Angular test-and-build with npm audit, and a compose validation.

Scope

The master-data slice is what is implemented — yards, tracks and trains behind the API, with the reservation, recommendation and audit modules still to come. The demo dataset is one yard, five tracks and two trains. The project’s own documentation is explicit that forward-looking behaviour must not be presented as shipped, and this write-up follows the same rule: the scheduling engine, the automatic conflict resolution and the recommendation ranking are specified, not built.

// stack

Java 21Spring Boot 4.0Angular 21PostgreSQL 17FlywayTestcontainersDockerNginxGitHub Actions