Final QA: year-rollover dry run #8

Closed
opened 2026-07-07 16:51:41 +00:00 by claude-bot · 2 comments
Contributor

Goal

Confirm the year boundary is handled without a restart: new-year events surface and the yearly Jan 1 job runs.

Steps

  • In a disposable environment, set the system clock to Dec 31 23:55
  • Observe the rollover into Jan 1
  • Confirm the year-cache refresh and the Jan 1 backup + candidate-generation job fire
  • Confirm next-year events are queryable without a restart

Acceptance criteria

  • Year cache refreshes across the boundary
  • Jan 1 job creates a backup and flags the review queue

Part of the M10 final QA pass. Related: deferred year-rollover scheduled job issue.

## Goal Confirm the year boundary is handled without a restart: new-year events surface and the yearly Jan 1 job runs. ## Steps - [ ] In a disposable environment, set the system clock to Dec 31 23:55 - [ ] Observe the rollover into Jan 1 - [ ] Confirm the year-cache refresh and the Jan 1 backup + candidate-generation job fire - [ ] Confirm next-year events are queryable without a restart ## Acceptance criteria - [ ] Year cache refreshes across the boundary - [ ] Jan 1 job creates a backup and flags the review queue Part of the M10 final QA pass. Related: deferred year-rollover scheduled job issue.
claude-bot added this to the v1.0.0 milestone 2026-07-15 15:33:02 +00:00
Author
Contributor

Picking this up.

Adjusting the method

The steps say "set the system clock to Dec 31 23:55." That isn't directly possible in a container — it shares the host clock, and changing it would need CAP_SYS_TIME and would affect every other stack on that host. So instead: a disposable container running the real :main image with libfaketime preloaded, started at a faked 2026-12-31 23:55 and left to roll over on its own.

Deliberately isolated from anything real:

  • its own fresh data directory, not the dev DB
  • WLED_HOST unset, so it cannot reach the controller
  • its own port

libfaketime fakes the wall clock but not the monotonic clock, which is what we want here: APScheduler computes when to fire from the faked wall clock, then waits on a real monotonic timer — so the 23:55 → 00:05 window takes ten real minutes rather than being skipped instantly. The jobs have to genuinely fire.

What I'll be checking

Beyond the stated criteria, there are specific things worth watching given #106 was a date-boundary bug that passed tests and manual invocation and only failed across a real midnight:

  • reschedule_daily (00:01) then yearly_job (00:05) — four minutes apart, on the one night when both cross a year. Their ordering has never been exercised.
  • get_active_times now reads today - 1 day (from #106's fix), which on Jan 1 reaches into the previous year.
  • run_yearly_job derives years from date.today().year at execution time, so it depends on having actually crossed midnight rather than on when it was scheduled.
  • Next-year events queryable without a restart — the acceptance criterion, and the thing a long-lived process is most likely to get wrong, since the lifespan computes its years list once at startup.

Recurrence tables run to 2035 (#53), so the lookups themselves should be fine; the risk is in cache and scheduling state, not the data.

Picking this up. ### Adjusting the method The steps say *"set the system clock to Dec 31 23:55."* That isn't directly possible in a container — it shares the host clock, and changing it would need `CAP_SYS_TIME` and would affect every other stack on that host. So instead: a **disposable container running the real `:main` image with `libfaketime` preloaded**, started at a faked 2026-12-31 23:55 and left to roll over on its own. Deliberately isolated from anything real: - its own fresh data directory, not the dev DB - `WLED_HOST` unset, so it cannot reach the controller - its own port `libfaketime` fakes the wall clock but not the monotonic clock, which is what we want here: APScheduler computes *when* to fire from the faked wall clock, then waits on a real monotonic timer — so the 23:55 → 00:05 window takes ten real minutes rather than being skipped instantly. The jobs have to genuinely fire. ### What I'll be checking Beyond the stated criteria, there are specific things worth watching given #106 was a date-boundary bug that passed tests and manual invocation and only failed across a real midnight: - **`reschedule_daily` (00:01) then `yearly_job` (00:05)** — four minutes apart, on the one night when both cross a year. Their ordering has never been exercised. - **`get_active_times` now reads `today - 1 day`** (from #106's fix), which on Jan 1 reaches into the previous *year*. - **`run_yearly_job` derives `years` from `date.today().year`** at execution time, so it depends on having actually crossed midnight rather than on when it was scheduled. - **Next-year events queryable without a restart** — the acceptance criterion, and the thing a long-lived process is most likely to get wrong, since the lifespan computes its `years` list once at startup. Recurrence tables run to 2035 (#53), so the lookups themselves should be fine; the risk is in cache and scheduling state, not the data.
Author
Contributor

Year rollover passes

Ran in a disposable container on the real :main image with libfaketime preloaded (FAKETIME="+10496437s"), started at a faked 2026-12-31 23:56 CST and left to cross midnight on its own. Isolated: fresh DB, WLED_HOST unset, own port. Because the monotonic clock is not faked, the nine faked minutes to 00:05 took nine real minutes — every job had to genuinely fire rather than be skipped.

Before (23:56 on Dec 31)

reschedule_daily   2027-01-01 00:01 CST
yearly_job         2027-01-01 00:05 CST
off_job            2027-01-01 07:20 CST     <- crosses the year
2026: 47 events   2027: 50 events   2028: 0 events
backups: 0

After (00:07 on Jan 1)

2027-01-01T06:05:00Z  yearly_job        success
2027-01-01T06:01:00Z  reschedule_daily  success

2026: 47 events   2027: 50 events   2028: 47 events
backups: iris_20270101T060500Z.db

off_job            2027-01-01 07:20 CST   <- survived the reschedule
on_job             2027-01-01 16:21 CST
dim_job            2027-01-01 23:00 CST
reschedule_daily   2027-01-02 00:01 CST
yearly_job         2028-01-01 00:05 CST
Scheme generation complete for years [2027, 2028] (169 schemes)
yearly_job: year 2027 initialized (722 ms)

No errors or warnings anywhere in the run.

Acceptance criteria

  • Year cache refreshes across the boundary — 2028 went from 0 → 47 events and 83 candidate schemes with no restart, which is the whole point of populate_db + refresh_cache in run_yearly_job.
  • Jan 1 job creates a backup and flags the review queueiris_20270101T060500Z.db written before any mutation, and candidates generated for both boundary years (169 schemes across 2027/2028).

Steps

  • Disposable environment at Dec 31 23:55 — 23:56, via libfaketime rather than the host clock
  • Observed the rollover into Jan 1
  • Year-cache refresh and the Jan 1 backup + candidate generation fired
  • Next-year events queryable without a restart

Worth noting

The #106 fix holds across a year boundary, which I had not specifically tested. off_job was pending at 2027-01-01 07:20 when reschedule_daily ran four minutes into 2027, and it survived — get_active_times reaches back to today - 1 day, which on Jan 1 crosses into the previous year. Pre-#106 that job would have been pushed to Jan 2 and the lights would have stayed on through New Year's Day. This test would have caught #106 independently.

reschedule_daily (00:01) and yearly_job (00:05) ran in that order without interfering: the reschedule set up Jan 1's cycle, and the yearly job then rebuilt the event/scheme set for 2027–2028 without disturbing it.

One thing not verified: the review_queue_ready webhook dispatch itself. No webhook endpoints are configured in a throwaway environment, so that path was a no-op. The substantive half — candidate generation — is confirmed; webhook delivery is covered by test_webhooks.py.

Harness torn down. Closing.

## Year rollover passes Ran in a disposable container on the real `:main` image with `libfaketime` preloaded (`FAKETIME="+10496437s"`), started at a faked **2026-12-31 23:56 CST** and left to cross midnight on its own. Isolated: fresh DB, `WLED_HOST` unset, own port. Because the monotonic clock is *not* faked, the nine faked minutes to 00:05 took nine real minutes — every job had to genuinely fire rather than be skipped. ### Before (23:56 on Dec 31) ``` reschedule_daily 2027-01-01 00:01 CST yearly_job 2027-01-01 00:05 CST off_job 2027-01-01 07:20 CST <- crosses the year 2026: 47 events 2027: 50 events 2028: 0 events backups: 0 ``` ### After (00:07 on Jan 1) ``` 2027-01-01T06:05:00Z yearly_job success 2027-01-01T06:01:00Z reschedule_daily success 2026: 47 events 2027: 50 events 2028: 47 events backups: iris_20270101T060500Z.db off_job 2027-01-01 07:20 CST <- survived the reschedule on_job 2027-01-01 16:21 CST dim_job 2027-01-01 23:00 CST reschedule_daily 2027-01-02 00:01 CST yearly_job 2028-01-01 00:05 CST ``` ``` Scheme generation complete for years [2027, 2028] (169 schemes) yearly_job: year 2027 initialized (722 ms) ``` No errors or warnings anywhere in the run. ### Acceptance criteria - [x] **Year cache refreshes across the boundary** — 2028 went from **0 → 47 events** and 83 candidate schemes with **no restart**, which is the whole point of `populate_db` + `refresh_cache` in `run_yearly_job`. - [x] **Jan 1 job creates a backup and flags the review queue** — `iris_20270101T060500Z.db` written before any mutation, and candidates generated for both boundary years (169 schemes across 2027/2028). ### Steps - [x] Disposable environment at Dec 31 23:55 — 23:56, via libfaketime rather than the host clock - [x] Observed the rollover into Jan 1 - [x] Year-cache refresh and the Jan 1 backup + candidate generation fired - [x] Next-year events queryable without a restart ### Worth noting **The #106 fix holds across a year boundary, which I had not specifically tested.** `off_job` was pending at `2027-01-01 07:20` when `reschedule_daily` ran four minutes into 2027, and it survived — `get_active_times` reaches back to `today - 1 day`, which on Jan 1 crosses into the previous *year*. Pre-#106 that job would have been pushed to Jan 2 and the lights would have stayed on through New Year's Day. This test would have caught #106 independently. `reschedule_daily` (00:01) and `yearly_job` (00:05) ran in that order without interfering: the reschedule set up Jan 1's cycle, and the yearly job then rebuilt the event/scheme set for 2027–2028 without disturbing it. **One thing not verified:** the `review_queue_ready` webhook dispatch itself. No webhook endpoints are configured in a throwaway environment, so that path was a no-op. The substantive half — candidate generation — is confirmed; webhook delivery is covered by `test_webhooks.py`. Harness torn down. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
rbrooks/Iris-WLED#8
No description provided.