Writing

What Broke When I Wrote My Own Scheduler

What Broke When I Wrote My Own Scheduler - abstract illustration

The scheduler in the Java service I'm replacing runs on Quartz, a mature, heavily used cron library that has quietly handled years of job fires without me thinking about it once. The native app I'm building to replace it doesn't have a Quartz equivalent to reach for on Swift, so I wrote a cron evaluator from scratch. I expected the cron expression parsing to be the hard part. It wasn't. Daylight saving time was, and it found three real bugs before a single production job ever missed a fire.

Why not just port Quartz's logic

Quartz's scheduling semantics are specific and somewhat idiosyncratic, six-field expressions, a documented rule for how the day-of-month and day-of-week fields interact when both are constrained instead of wildcarded, aliases, ranges, steps. None of that is exotic to reimplement. What made hand-rolling the evaluator the right call instead of a shortcut was that Swift has no equivalent library with Quartz's specific semantics, and the semantics matter: every existing scheduled job, once cut over, has to fire at exactly the times it fired under the old evaluator, or the cutover itself becomes a silent behavior change nobody asked for. A generic third-party cron library with its own, different interpretation of edge cases would have solved "parse a cron string" while quietly failing to solve "match the system I'm replacing."

So the evaluator is a straightforward roll-forward algorithm: given a cron expression and a starting instant, walk forward field by field, wildcards and lists and ranges and steps, until you find the next instant that satisfies every field, faithfully reproducing Quartz's specific day-field interaction rule along the way. That part came together quickly and passed its tests quickly. The part that didn't was the part that touches wall-clock time at all: what "the next occurrence of 2:30 AM" means on the two days a year when 2:30 AM either doesn't exist or exists twice.

Fixtures pinned to a real date, not an abstraction

The way I caught the bugs that follow was building daylight-saving-time fixtures pinned against the actual 2026 transition dates for America/Los_Angeles, not synthetic "suppose DST happens on some date" test cases. A test that constructs an arbitrary hypothetical transition can accidentally encode the same wrong assumption as the code being tested, because both were written by the same person reasoning about the same abstraction. A fixture that says "the real spring-forward transition in this real time zone happens at this real instant, and a job scheduled for 2:30 AM that day should fire at this real, specific, independently-verifiable instant" has no room for that kind of shared blind spot. It's checking the code against reality, not against another abstraction.

Three bugs, three different failure shapes

Spring forward: normalizing into a gap, then failing the check that should have accepted it. On the day the clock skips forward an hour, 2:30 AM doesn't exist as a wall-clock time; it's inside the skipped hour. The evaluator's first pass computed a candidate time, correctly recognized it fell in the gap, and normalized it forward past the skip, exactly the right instinct. Then it re-validated the normalized instant against the original field constraints, and the normalized instant, now sitting at 3:30 AM, no longer matched a job asking for 2:30. The fix was accepting the normalized instant directly once you've deliberately rolled it forward past a gap, instead of re-checking it against constraints that were only ever going to be true of the value before normalization.

Fall back: reconstructing the wrong occurrence of an ambiguous hour. On the day the clock falls back, 1:30 AM happens twice. The date-component reconstruction logic, given year, month, day, hour, minute, snapped to the first occurrence of that ambiguous hour by default, which is correct in isolation but wrong if the roll-forward algorithm had already reasoned its way to the second occurrence for a legitimate reason, like a job that shouldn't fire before a specific earlier event that same hour. Reconstructing from components silently discarded which occurrence was actually meant. The fix added a monotonicity guard: the reconstructed instant has to be greater than or equal to wherever the algorithm's search had already advanced to, with relative-arithmetic rollover helpers replacing the naive component snap, so "the second 1:30 AM" and "the first 1:30 AM" stop being indistinguishable once you're back to raw date components.

A matching second occurrence that was already used. The subtlest of the three: a candidate could match a job's cron fields, fall on the ambiguous hour, and be a time that had already been evaluated and rejected during an earlier step of the same search, specifically the first occurrence of that same wall-clock hour, before the algorithm advanced past it. Without a final round-trip check, comparing the candidate back against the point the search had already reached, the evaluator could return an instant it had implicitly already passed, an off-by-an-hour bug that only exists on the one day a year an hour repeats.

The one that wasn't about DST at all

A fourth gap surfaced in the same pass, this one about misfires rather than the clock itself: a job that has never fired needs its next-fire calculation anchored to a fixed point, the moment it was created, not to "now" recomputed fresh on every scheduler tick. Anchoring to a recomputed now means an interval trigger, "run every hour starting from when this job was made," never actually converges on its intended schedule, because the anchor itself keeps sliding forward with every tick that checks it. It's an easy design gap to miss, because it only shows up for jobs that haven't fired yet, and most manual testing schedules a job and then watches it fire almost immediately, never leaving the never-fired state around long enough to notice the anchor was wrong.

What this says about porting anything with a clock in it

None of these four bugs were cron-parsing bugs. The field-matching logic, the harder-looking half of the job, was correct on the first real attempt and stayed correct. Every bug that showed up lived at the seam between wall-clock time and the calendar, exactly the part of the system that's near-impossible to exercise by accident during normal development, because normal development happens on normal days, and DST transitions are, by construction, two specific days a year. This is the same lesson as testing parsers against real data, applied to time instead of file formats: the bugs that matter live in the inputs you didn't think to construct yourself, and the fix is pinning your tests to real, specific, externally-verifiable instances of exactly those inputs, not to a hypothetical version of them you made up in the same sitting as the code.

Building something like this?

This is the kind of work I do for clients. Tell me what you're building and I'll give you a straight read on the approach.

Book a 30-minute call