Files
familysync/.planning/quick/260610-hbu-make-phase-5-reminder-scheduler-resilien/260610-hbu-SUMMARY.md
T

146 lines
6.0 KiB
Markdown

---
phase: quick-260610-hbu
plan: "01"
subsystem: api/broker
tags: [push-notifications, reminder-scheduler, resilience, dedup]
dependency_graph:
requires: []
provides: [resilient-reminder-scan, per-uid-exactly-once-dedup]
affects: [apps/api/src/broker/reminderScheduler.ts]
tech_stack:
added: []
patterns: [catch-up-window, per-uid-dedup, mark-after-dispatch]
key_files:
created: []
modified:
- apps/api/src/broker/reminderScheduler.ts
- apps/api/tests/broker/reminderScheduler.test.ts
decisions:
- "Window changed from [now+14min, now+16min] to (now, now+16min] for missed-tick catch-up"
- "Dedup key changed from uid:minuteBucket (Set) to bare uid (Map<uid, dtstartMs>) for cross-tick exactly-once"
- "Body changed from hardcoded 'Starts in 15 min' to lead-accurate 'Starts in N min'"
- "CR-01 pruning changed from minuteBucket-age to started-event (dtstartMs <= now)"
metrics:
duration: "~15min"
completed: "2026-06-10"
tasks_completed: 2
files_modified: 2
---
# Phase quick-260610-hbu Plan 01: Reminder Scheduler Resilience Summary
**One-liner:** Catch-up scan window `(now, now+16min]` with per-uid `Map` dedup replaces fixed `[now+14, now+16]` + minuteBucket `Set`, closing the missed-tick reminder drop (UAT Test 1).
## Tasks Completed
| Task | Name | Commit | Files |
|------|------|--------|-------|
| 1 | Catch-up window + per-uid dedup in reminderScheduler.ts | 3fdb242 | apps/api/src/broker/reminderScheduler.ts |
| 2 | Update tests for catch-up + single-fire + missed-tick | 93bb2c1 | apps/api/tests/broker/reminderScheduler.test.ts |
## Verification Output
### pnpm --filter @familysync/api typecheck
```
$ tsc --noEmit
(exit 0 — no output)
```
### pnpm --filter @familysync/api test (reminderScheduler only)
```
RUN v4.1.8 /home/luc/Projects/familysync/apps/api
Test Files 1 passed (1)
Tests 10 passed (10)
Start at 12:34:43
Duration 1.01s (transform 181ms, setup 647ms, import 74ms, tests 114ms, environment 0ms)
```
### pnpm --filter @familysync/api test (full suite)
```
Test Files 4 failed | 19 passed (23)
Tests 68 failed | 166 passed (234)
Duration 22.54s
```
The 4 failing test files and 68 failing tests are all pre-existing `ECONNREFUSED 127.0.0.1:3306` DB-integration failures (MariaDB not running locally). None are related to reminderScheduler. The reminderScheduler file is tested exclusively through mocked DB calls and is not among the failures.
### Grep sanity
```
grep -n "gt(" apps/api/src/broker/reminderScheduler.ts
103: gt(calendarEvents.dtstartUtc, now),
grep -n "minuteBucket" apps/api/src/broker/reminderScheduler.ts
42: // Key: event uid (bare string — no minuteBucket suffix).
142: // Per-uid exactly-once dedup (D-12): keyed on bare uid, no minuteBucket.
(no variable named minuteBucket remains)
```
## What Changed
### Task 1 — reminderScheduler.ts
**Query window:** `gte(dtstartUtc, windowStart)` where `windowStart = now+14min` replaced with `gt(dtstartUtc, now)`. Upper bound `lte(dtstartUtc, windowEnd)` where `windowEnd = now+16min` unchanged. Import `gte` dropped; `gt` added.
**Dedup store:** `Set<string>` keyed `uid:minuteBucket` replaced with `Map<string, number>` keyed `uid` (value = dtstartMs). The `minuteBucket` variable is removed entirely.
**Dispatch loop check:** `sentReminders.has(uid:minuteBucket)` replaced with `sentReminders.has(uid)`. WR-01 mark-after-dispatch preserved: `sentReminders.set(uid, dtstartMs)` after fan-out loop.
**Notification body:** Hardcoded `'Starts in 15 min'` replaced with `\`Starts in ${minutes} min\`` where `minutes = Math.max(1, Math.round((dtstartMs - now) / 60000))`.
**CR-01 pruning:** Stale minuteBucket iteration replaced with started-event pruning: delete entries whose stored `dtstartMs <= now.getTime()`.
### Task 2 — reminderScheduler.test.ts
New tests added:
- **SINGLE-FIRE across 3 consecutive ticks:** 3 calls to `runReminderCheck` with event still in window; asserts `dispatchPush` called exactly once.
- **MISSED-TICK-RECOVERY:** No scan at the ideal 15-min mark; call at 8 min before start; asserts dispatch fires.
- **ALREADY-STARTED:** dtstart <= now excluded by SQL `gt`; mocked as empty rows; 0 dispatches.
- **D-16:** Empty subscriptions row set; zero sends, no crash.
- **T-05-19:** Per-subscription error isolation; first sub throws, second still dispatched.
- **Fan-out:** 2 subscriber rows for one event uid; 2 dispatches.
Old minuteBucket tests removed:
- `(eventUid, minuteBucket) twice within the same run` — replaced by per-uid SINGLE-FIRE test.
- `CR-01: re-dispatch in next minute bucket` — replaced by CR-01 started-event pruning test.
Retained:
- D-07 all-day exclusion test.
- D-05 non-shared exclusion test.
- WR-01 mark-after-dispatch test (updated to per-uid dedup language).
## Decisions Made
1. **Window: open lower bound on `now`** — `gt(now)` instead of `gte(now+14min)` gives the catch-up property: any scan while the event is still in the future finds it, regardless of when the cron last fired.
2. **Dedup: uid-only Map** — Removing the minuteBucket suffix from the key means a recovered (late) scan on the same event does not see a new key and does not double-fire. The Map value (dtstartMs) is used only for CR-01 pruning, not for keying.
3. **CR-01 pruning on started-event, not stale-bucket** — With per-uid dedup there is no bucket concept. Pruning on `dtstartMs <= now` achieves the same bounded-growth goal and is semantically cleaner: an event that has started will never re-enter `(now, now+16min]`.
4. **Body is lead-accurate** — `Math.max(1, ...)` guards against a 0-min display if dispatch runs extremely close to dtstart.
## Deviations from Plan
None — plan executed exactly as written.
## Known Stubs
None.
## Threat Flags
None — no new network endpoints, auth paths, or schema changes introduced.
## Self-Check
- [x] `apps/api/src/broker/reminderScheduler.ts` exists and compiles (typecheck exit 0)
- [x] `apps/api/tests/broker/reminderScheduler.test.ts` exists and passes (10/10)
- [x] Commit 3fdb242 exists
- [x] Commit 93bb2c1 exists
## Self-Check: PASSED