V170: index support and a uniqueness backstop for notification collapsing.
Notifications.upsert_inapp/3 (added alongside the unseen-first inbox
ordering) shipped with neither:
The dedupe lookup walked the inbox.
find_collapsible/2filters onmetadata->>'dedupe_key'under recipient + unseen, and the only inbox index —(recipient_uuid, inserted_at DESC) WHERE dismissed_at IS NULL(V104, re-created by the V135 squash) — cannot serve either that lookup or the new(seen_at IS NOT NULL, inserted_at DESC, uuid DESC)ordering, so every bell mount and every upsert re-sorted or re-walked a recipient's whole undismissed backlog (thousands of rows for a user who never dismisses, multiplied by fan-out on the write path).The find-then-insert had no backstop. Two concurrent upserts for the same absent key (parallel Oban workers, two nodes) both read nil and both inserted; the user got two unseen rows for one logical key, pinned to the top by the unseen-first ordering, and later refreshes folded into only one of them — the stale twin sat there until manually dismissed.
Two indexes fix both:
phoenix_kit_notifications_dedupe_unseen_idx— partial UNIQUE on(recipient_uuid, (metadata->>'dedupe_key'))over undismissed, unseen, keyed rows. Servesfind_collapsible/2's exact predicate AND turns the race's second insert into a constraint violation the code retries as a collapse (Notifications.insert_collapsible/3). Rows without a dedupe key — every notification the fan-out path creates — are outside the partial predicate and completely unaffected.phoenix_kit_notifications_recipient_unseen_first_idx— on(recipient_uuid, (seen_at IS NOT NULL), inserted_at DESC, uuid DESC)over undismissed rows, matchingorder_unseen_first/1's ORDER BY expression term-for-term so the bell'srecent_for_userand the inbox pages come straight off the index again.
Existing duplicates
A unique index cannot be created over rows that already violate it, and the
raced installs are exactly the ones carrying duplicates. Before creating the
index, all but the newest unseen row per (recipient, key) — the same
"newest wins" choice find_collapsible/2 makes, inserted_at then uuid
(UUIDv7, time-ordered below the timestamp's granularity) — are marked
dismissed. Dismissal, not deletion: the rows and their history remain,
they simply stop occupying the inbox — which is where the collapsing API
would have put them had it won the race in the first place.
The fold and the index creation share one SHARE ROW EXCLUSIVE table lock,
so a concurrent insert cannot re-introduce a duplicate in the gap between
them and abort the migration.