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.
149 lines
6.2 KiB
Markdown
149 lines
6.2 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
|