Third post about the engineering inside an airport slot coordination platform. The first two — lossless schedule repacking and conflict detection with Union-Find — were about algorithms I designed. This one is about a bug I inherited, and about the most misunderstood exception in Java.
The incident
While bringing an abandoned flight-operations service back to life, production threw this at me:
java.lang.IllegalArgumentException: Comparison method violates its general contract!
Not from some exotic subsystem — from a stream's .sorted(comparator) in the endpoint that lists aircraft stand states ordered by parking-area code (under the hood, sorted buffers into an array and hands it to Arrays.sort — TimSort). A request that sorted stands by code simply aborted. Sometimes. On some data. Not in tests, not on staging, and not reproducibly on demand — which is this exception's signature move, and the reason it's so widely misread as a JDK bug.
It isn't one. It's the JDK catching you.
What the contract actually says
Every Java developer can recite the Comparator interface: negative, zero, positive. Far fewer can recite the three laws the JavaDoc actually demands:
-
Antisymmetry:
sgn(compare(a, b)) == -sgn(compare(b, a))— if a beats b, then b must lose to a. Always. Both directions must agree. - Transitivity: if a beats b and b beats c, a must beat c.
-
Consistency: if
compare(a, b) == 0, then a and b must agree in how they compare against everything else.
Why TimSort notices, and why only sometimes
Since Java 7, Arrays.sort for objects is TimSort — a merge sort that hunts for already-ordered runs and merges them with a galloping optimization. That machinery relies on the total-order laws: when it gallops through a run, it trusts that what compared "less" stays "less" from every direction it checks.
Hand it a comparator that lies, and one of two things happens. If the input happens not to expose the lie, the sort completes — perhaps correctly, perhaps subtly misordered. If the input does expose it, TimSort's internal invariants collapse and it throws the contract exception rather than return garbage.
Two consequences worth noting:
- The exception is data-dependent. That's why it passed every test and failed in production on Tuesdays. A broken comparator is a landmine; TimSort only reports the mine when someone steps on it. Absence of the exception proves nothing.
-
The exception is a gift. Pre-Java-7 merge sort would have silently produced a wrong order. There's even an escape hatch —
-Djava.util.Arrays.useLegacyMergeSort=true— and reaching for it is the classic wrong fix: it doesn't repair the comparator, it just asks the JDK to stop telling you about it.
The actual bug
Parking-area codes are mostly letter-plus-number — B2, B10 — sorted "naturally", so B2 precedes B10. But the population also contains letter-only codes (A, B) and empty strings — states whose parking area is simply absent, collapsed to "". The comparator I inherited split each code into a numeric part and a letter part, parsed the numbers, compared them — and handled the no-number cases in a branch chain that ended like this:
// reached when a code has no numeric part
} else if (numPart1.isBlank()) {
return 1; // fires regardless of what numPart2 is
}
When both codes lacked digits — A vs B, or anything vs "" — that branch fired in both argument orders: compare("A","B") == 1 and compare("B","A") == 1. Textbook antisymmetry violation. And the letter-comparison written to handle exactly this case sat just below the branch, unreachable — dead code guarding the live bug. My favourite detail: compare("A","A") == 1 too. Under this comparator, a letter-only stand wasn't even equal to itself.
The matrix view makes the lie visible: antisymmetry demands that the grid mirror-negate across a zero diagonal. The numeric codes (top-left) obey. The letter-only-and-empty corner is a solid block of +1 — nine cells of contradiction, three of them on the diagonal itself.
So for most requests — most stands carrying ordinary numeric codes — the sort held. Let enough letter-only or parking-less states into one response, in positions where TimSort's merge compares them from both directions, and the request died. The bug had sat quietly in an inherited codebase until the data found it.
There was a second, quieter landmine in the same method: Integer.parseInt on an unvalidated digit run — one unexpectedly long code away from NumberFormatException. Nothing in production had tripped it yet; it went into the same fix anyway.
The fix: compare digits without parsing them
The repair restructured the branches so every case returns a consistent answer from both directions:
// after — every path total and symmetric (illustrative shape, not production source)
if (bothHaveNumbers) return compareNumericallyThenByLetters(a, b);
if (aHasNumber) return -1; // numeric codes sort first…
if (bHasNumber) return 1; // …seen from either side
return lettersOf(a).compareTo(lettersOf(b)); // both letter-only: the once-dead branch, now alive
Two details in the "numerically" part: ties between equal numbers break on the letter part, so compare == 0 only ever means genuinely interchangeable — law three's cheap insurance. And the numeric comparison stopped parsing entirely: digit runs compare as strings — longer significant run wins, equal lengths compare lexicographically — which is overflow-proof at any code length. Ordering for every previously-valid code is unchanged; only the lies are gone.
Then the step that mattered as much as the fix: auditing the sibling comparators. The same endpoint also sorts by arrival and departure flight time; both of those delegate to OffsetDateTime.compareTo — a valid total order — which exonerated them for this crash. But the audit still paid: both could NPE, because a state can legitimately lack its arrival or departure flight, and every candidate time field can be null. Their fix was a null guard plus Comparator.nullsLast(...) — with a note to the product owner that whether missing times sort first or last is a product decision, not something a comparator should decide by accident. Defects cluster; code written in the same week shares the same blind spots.
Proving it, not eyeballing it
A fixed comparator that "looks right" is exactly as trustworthy as the previous one looked. The laws are properties, so the proof is property-based: a sandbox harness mirroring the production logic, generating randomised datasets of stand codes — numeric, letter-only, empty, null-adjacent — and asserting the three laws directly:
- for every pair:
sgn(compare(a,b)) == -sgn(compare(b,a)) - for every triple: transitivity holds
- for every "equal" pair: both elements order identically against every third value
First, the harness reproduced the exact production exception against the old comparator — no fix is proven until the bug is reproduced. The reproduction also measured the size of the landmine: across 4,000 randomised datasets, the original comparator threw in 1,582 of them. Two out of every five random datasets could kill the endpoint; production had been surviving on the luck of its data mix. Against the fixed comparator: zero law violations, zero sort exceptions, zero mis-ordered outputs across all 4,000, plus targeted regressions for the original failing case, leading zeros, and numeric-vs-letter precedence. The harness stayed in the suite; it now guards every future edit to those comparators.
This is the same philosophy the first two posts kept arriving at, wearing a different coat: the repacking pipeline has a set-equality guard, conflict detection asserts its example before drawing conclusions, and comparators get their laws fuzzed. Don't inspect correctness — check it, mechanically, every time.
What I'd generalise
Interfaces have laws, not just signatures. Comparator, equals/hashCode, Comparable — the compiler checks the shape and never the laws, and the laws are load-bearing. The most dangerous code in a codebase is a five-line method everyone assumed was too simple to be wrong.
Data-dependent failures demand law-based tests. Example-based tests encode the inputs you thought of; the production data will think of others. When correctness is a property, test the property.
When you find one, check the others. A broken comparator is rarely alone. The half-day spent auditing siblings bought more reliability than the fix itself.
And don't take the escape hatch. useLegacyMergeSort silences the messenger. The exception told the truth: the contract was violated. Fix the contract.
I build airport slot coordination and airline schedule systems — IATA telegram processing, schedule algorithms, conflict detection — in Java. Currently relocating to Spain. Earlier in this series: schedule repacking · conflict detection. Find me on LinkedIn.

Top comments (0)