6.0 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 |
|
|
|
|
|
|
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
runReminderCheckwith event still in window; assertsdispatchPushcalled 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
-
Window: open lower bound on
now—gt(now)instead ofgte(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. -
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.
-
CR-01 pruning on started-event, not stale-bucket — With per-uid dedup there is no bucket concept. Pruning on
dtstartMs <= nowachieves the same bounded-growth goal and is semantically cleaner: an event that has started will never re-enter(now, now+16min]. -
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
apps/api/src/broker/reminderScheduler.tsexists and compiles (typecheck exit 0)apps/api/tests/broker/reminderScheduler.test.tsexists and passes (10/10)- Commit
3fdb242exists - Commit
93bb2c1exists