--- 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) 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` keyed `uid:minuteBucket` replaced with `Map` 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