PhoenixKit.Migrations.Postgres.V170 (phoenix_kit v2.6.0)

Copy Markdown View Source

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/2 filters on metadata->>'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. Serves find_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, matching order_unseen_first/1's ORDER BY expression term-for-term so the bell's recent_for_user and 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.

Summary

Functions

down(opts)

up(opts)