Files
familysync/.planning/quick/260610-hbu-make-phase-5-reminder-scheduler-resilien/260610-hbu-SUMMARY.md
T
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00

6.2 KiB

phase, plan, subsystem, tags, dependency_graph, tech_stack, key_files, decisions, metrics
phase plan subsystem tags dependency_graph tech_stack key_files decisions metrics
quick-260610-hbu 01 api/broker
push-notifications
reminder-scheduler
resilience
dedup
requires provides affects
resilient-reminder-scan
per-uid-exactly-once-dedup
apps/api/src/broker/reminderScheduler.ts
added patterns
catch-up-window
per-uid-dedup
mark-after-dispatch
created modified
apps/api/src/broker/reminderScheduler.ts
apps/api/tests/broker/reminderScheduler.test.ts
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)
duration completed tasks_completed files_modified
~15min 2026-06-10 2 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`whereminutes = 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 nowgt(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-accurateMath.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

  • apps/api/src/broker/reminderScheduler.ts exists and compiles (typecheck exit 0)
  • apps/api/tests/broker/reminderScheduler.test.ts exists and passes (10/10)
  • Commit 3fdb242 exists
  • Commit 93bb2c1 exists

Self-Check: PASSED