A Daily 02:30 Billing Job Across DST, Plus a Week-Based-Year Format Bug
RevOps runs a billing scheduler that should fire once a day at 02:30 local time for the America/New_York region, producing about 40,000 invoices per cycle. It builds a LocalDateTime from LocalDateTime.now(), stores the scheduled time in a TIMESTAMP WITHOUT TIME ZONE column, and compares with isBefore(now) on startup plus a 60-second poll. In March 2019 the cycle never fired and support found a 26-hour gap; in November the same cycle ran twice and double-charged 312 accounts, which took two days to refund. Separately, finance reports that invoices dated 2019-12-30 print with year 2020, and a '30-day trial' computed with Duration.ofDays(30) expires at a different local time than the same trial computed with Period.ofDays(30), depending on the month. Job latency is measured with System.currentTimeMillis() and one node logged negative durations after an NTP correction.
1. Explain what happens to 02:30 on the spring-forward and fall-back days, and redesign the scheduling model so both cases have an explicit, testable policy.
Read the worked answer
On 2019-03-10 in America/New_York clocks jump 02:00 to 03:00, so 02:30 does not exist. ZonedDateTime.of(date, LocalDateTime.of(2019,3,10,2,30), zone) does not throw: it uses the offset in effect before the gap (EST, -05:00), which is an invalid local time, then normalizes by adding the gap length, producing 03:30-04:00 EDT. The job effectively ran at a different wall time, and a scheduler that compares stored LocalDateTime values against now() can miss the window entirely.
On 2019-11-03, 01:30 occurs twice (EDT then EST). ZonedDateTime.of resolves an overlap to the earlier offset, i.e. the first occurrence; a naive poll over wall-clock times fires again on the second pass, which is the double charge.
Redesign:
- Persist the intended run as an
Instantplus aZoneId, not a bareLocalDateTime; render withZonedDateTime.ofInstant(instant, zone). - State the policy in code: for a gap, run at the first valid instant after it (03:30); for an overlap, run once, deduplicated by the resolved
Instantor by a unique key of job id plus local date. - Use
ZoneId.of('America/New_York'), neverZoneId.systemDefault(), because containers usually run in UTC and the behaviour would change with deployment. - Make the tests drive a fixed
Clockand assert on resolved instants for both transition dates.
2. Why does 2019-12-30 print as year 2020, and what other pattern-letter mistakes should be ruled out?
Read the worked answer
YYYY is the week-based year (ISO-8601 weeks: Monday start, week 1 contains the first Thursday), not the calendar year. 2019-12-30 falls in ISO week 1 of 2020, so DateTimeFormatter.ofPattern('YYYY-MM-dd') prints 2020-12-30 - the wrong invoice year, and the error only appears in late December.
Use yyyy for year-of-era in formatting, or preferably uuuu (proleptic year) with java.time types. uuuu also behaves better with strict parsing: yyyy without an era can fail to resolve or misparse in ResolverStyle.STRICT, whereas uuuu is unambiguous and handles year zero and negative years.
Other pattern-letter traps worth ruling out in the same audit:
DDis day-of-year (1–366); the day of month isdd.hhis clock-hour 1–12; hours 0–23 areHH.mmis minutes; months areMM.SimpleDateFormatis mutable and not thread-safe, so a static instance shared across request threads produces scrambled output;DateTimeFormatteris immutable and thread-safe and is the right choice in a shared service.
Add boundary tests for 2019-12-30, 2019-12-31, 2020-01-01 and a leap day, asserting exact strings rather than parsed dates, because parsing back can mask a wrong year.
3. Compare `Period.ofDays(30)` and `Duration.ofDays(30)` for the trial, and give the correct approach for measuring job latency.
Read the worked answer
The two are different kinds of quantity:
start.plus(Period.ofDays(30))adds 30 calendar days, so the local time stays 09:00 and the physical elapsed time across a DST transition is 719 or 721 hours.start.plus(Duration.ofDays(30)), equivalent toplusHours(720), adds exactly 720 hours on the timeline; across a DST change it lands at 08:00 or 10:00 local.
So the two results differ by an hour twice a year in a DST zone. Choosing the wrong one yields renewals at the wrong local time or billing periods that look a day short or long. Rule: use Period for calendar-based business rules ('the same local time 30 days later'), and Duration/Instant for elapsed physical time, timeouts and SLAs. When mixing, convert explicitly and document which one the contract means.
For latency measurement:
System.currentTimeMillis()andInstant.now()are wall-clock and can jump backwards or forwards on NTP correction - that is the negative-duration log.System.nanoTime()is monotonic and is the correct tool for elapsed durations, timeouts and benchmarking.- Inject
Clock(Clock.systemUTC(),Clock.fixed(...)) instead of callingnow()statically, so DST and boundary tests are deterministic. - Store
Instant(UTC orTIMESTAMP WITH TIME ZONE) and renderZonedDateTimeonly at the display boundary, so every node interprets a scheduled run identically.