Java SE 8 Programmer II study materials
Java SE 8 Programmer II study resources

A Daily 02:30 Billing Job Across DST, Plus a Week-Based-Year Format Bug

Java SE 8 Programmer II · Put your knowledge to work in context.

All case studies

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:

  1. Persist the intended run as an Instant plus a ZoneId, not a bare LocalDateTime; render with ZonedDateTime.ofInstant(instant, zone).
  2. 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 Instant or by a unique key of job id plus local date.
  3. Use ZoneId.of('America/New_York'), never ZoneId.systemDefault(), because containers usually run in UTC and the behaviour would change with deployment.
  4. Make the tests drive a fixed Clock and 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:

  • DD is day-of-year (1–366); the day of month is dd.
  • hh is clock-hour 1–12; hours 0–23 are HH.
  • mm is minutes; months are MM.
  • SimpleDateFormat is mutable and not thread-safe, so a static instance shared across request threads produces scrambled output; DateTimeFormatter is 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 to plusHours(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() and Instant.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 calling now() statically, so DST and boundary tests are deterministic.
  • Store Instant (UTC or TIMESTAMP WITH TIME ZONE) and render ZonedDateTime only at the display boundary, so every node interprets a scheduled run identically.