Fix the 3am notification avalanche and the failing morning digest
Three bugs from the first day of real use. 1. Quiet hours were evaluated in UTC. The router is handed a UTC `now` and compared its hour against "22:00-07:00" directly, so notifications were silenced from 18:00 local -- prime reading time -- and the whole day's budget was released at 03:00 local. The reported avalanche landed at 03:39 and 03:55. Quiet hours are now converted to the reader's wall clock first. 2. The timezone never resolved anyway. TZ was "America/Louisville", which exists on this Arch host but NOT in a Debian container, which ships only the canonical "America/Kentucky/Louisville". The container fell back to UTC and `date` printed "America" as the zone abbreviation. The zone is now read from config rather than the environment, so a bad TZ cannot silently change behaviour, and the compose entry uses the canonical name. NOTE: ~20 other services in compose.yml carry the same bad string; they were left alone since it only affects their log timestamps. 3. Budgets are not a rate. Six notifications fired in a single minute because the daily allowance could all be spent in one cycle. Each lane now has a minimum gap (interest 75min, importance 30min) and sends at most one per cycle, which turns the budget into a rate spread across the day. Separately, the morning digest had silently fallen back to the templated list. The real 28-story payload takes 70.3s to generate against a 60s timeout. Raised to 180s -- and write_prose now LOGS why it failed instead of returning None in silence, which is what made a one-line timeout cost a debugging session.
This commit is contained in:
1 parent
161c518aa7
commit
98bba62976
8 files changed
+151
-4
No files matched your search
@@ -154,3 +154,55 @@ def test_quiet_hours_wrap_midnight(hhmm, expected):
|
||||
h, m = map(int, hhmm.split(":"))
|
||||
now = datetime(2026, 9, 8, h, m, tzinfo=UTC)
|
||||
assert in_quiet_hours(now, {"start": "22:00", "end": "07:00"}) is expected
|
||||
|
||||
|
||||
# --- timezone and pacing -----------------------------------------------------
|
||||
|
||||
DELIVERY_TZ = {**DELIVERY, "timezone": "America/Kentucky/Louisville"}
|
||||
|
||||
|
||||
def test_quiet_hours_use_local_time_not_utc(con):
|
||||
"""Regression for the 3:39am avalanche.
|
||||
|
||||
22:00-07:00 was compared against UTC hours, so notifications were silenced
|
||||
from 18:00 local and the day's whole budget was released at 03:00 local.
|
||||
"""
|
||||
# 23:00 UTC == 19:00 EDT: awake locally, but "quiet" if you compare UTC.
|
||||
evening = datetime(2026, 9, 9, 23, 0, tzinfo=UTC)
|
||||
out = route(con, [mk("a", interest=9.0)], ROUTING, DELIVERY_TZ, evening)
|
||||
assert lanes(out)["a"] == INTEREST, "19:00 local is not quiet hours"
|
||||
|
||||
# 03:30 UTC == 23:30 EDT: genuinely quiet locally.
|
||||
night = datetime(2026, 9, 10, 3, 30, tzinfo=UTC)
|
||||
out = route(con, [mk("b", interest=9.0)], ROUTING, DELIVERY_TZ, night)
|
||||
assert lanes(out)["b"] == DIGEST
|
||||
assert out[0].reason == "quiet-hours"
|
||||
|
||||
|
||||
def test_an_unknown_timezone_does_not_stop_delivery(con):
|
||||
delivery = {**DELIVERY, "timezone": "Mars/Olympus_Mons"}
|
||||
out = route(con, [mk("a", interest=9.0)], ROUTING, delivery, NOW)
|
||||
assert lanes(out)["a"] in (INTEREST, DIGEST) # decided, not crashed
|
||||
|
||||
|
||||
def test_only_one_notification_per_lane_per_cycle(con):
|
||||
"""Budgets are not a rate. Six notifications once arrived in the same minute."""
|
||||
scores = [mk(f"i{n}", interest=9.0 - n) for n in range(4)]
|
||||
out = lanes(route(con, scores, {**ROUTING, "interest_per_day": 4},
|
||||
DELIVERY_TZ, NOW))
|
||||
assert sum(1 for v in out.values() if v == INTEREST) == 1
|
||||
assert out["i0"] == INTEREST, "the strongest candidate takes the slot"
|
||||
|
||||
|
||||
def test_a_second_send_waits_for_the_gap(con):
|
||||
con.execute(
|
||||
"INSERT INTO notification_log (cluster_id,lane,score,created_at) VALUES (?,?,?,?)",
|
||||
("earlier", INTEREST, 5.0, (NOW - timedelta(minutes=10)).isoformat()))
|
||||
out = route(con, [mk("a", interest=9.0)], ROUTING, DELIVERY_TZ, NOW)
|
||||
assert lanes(out)["a"] == DIGEST
|
||||
assert out[0].reason == "interest-too-soon"
|
||||
|
||||
# Once the gap has elapsed it goes through.
|
||||
later = NOW + timedelta(minutes=80)
|
||||
out2 = route(con, [mk("a", interest=9.0)], ROUTING, DELIVERY_TZ, later)
|
||||
assert lanes(out2)["a"] == INTEREST
|
||||
Reference in new issue
Block a user