Files
civitai__civitai/containers
Justin Maier b83efe4dcd fix(notifications): collapse duplicate notifications for one comment (#3530)
* fix(notifications): collapse duplicate notifications for one comment

A single comment can satisfy several notification types at once — an @mention
that is also a reply in a thread you're in, on a model you own. Each type is an
independent SQL processor emitting its own `Notification.key`, so nothing
collapsed them and the recipient got three or four bell entries for one message.

Every comment-derived processor now also emits a `dedupeKey` scoped to the source
comment (`comment:v1:<id>` / `comment:v2:<id>`, namespaced because Comment and
CommentV2 ids overlap). The key rides through the producer contract, the
PendingNotification queue, and the fan-out worker, where a partial
UNIQUE ("userId", "dedupeKey") lets the existing untargeted ON CONFLICT DO
NOTHING drop the redundant rows. Skipped rows never reach RETURNING, so they also
produce no realtime signal and no unread-count bump.

Which one survives is now deliberate rather than a race: the comment family gets
explicit priorities so the most specific type runs in an earlier batch and claims
the key — mention, then direct response, then thread response, then entity owner.
The batch sort is switched from lexicographic to numeric, which that ordering
depends on.

Per-type keys, opt-out settings, debounced types, and every non-comment
notification are unchanged. Measured against a one-hour window of production
data: 373 rows collapse to 344, with 7 comments that had fired three times each.

Reported by MNeMiC.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(notifications): only claim the dedupe key when the notification renders

Adversarial review found that making new-mention the universal winner could
suppress a working notification and put a broken one in its place. Claiming the
shared key silences every other type for that comment, so a claimant now has to
earn it.

new-mention's SQL was a strict subset of the types it outranks: its thread
COALESCE and threadType CASE stopped at bountyEntry, so a mention in a challenge
or 3D-model thread fell through to the 'comment' fallback, which threadUrlMap
cannot address — a dead link that displaced a working new-challenge-comment.
It also lacked the appListing exclusion the reply processors carry. Both fixed by
bringing it to parity with new-thread-response.

Worse, a mention inside a legacy top-level comment gets parentType 'review', and
prepareMessage returns undefined for that — NotificationList drops the row
entirely. It still claimed the key, so the new-comment the user should have seen
was swallowed and only a phantom unread count remained. new-mention now emits
NULL for every shape it cannot render, so those fall through to their own
lower-priority notification exactly as before.

Verified against 14 days of production: the three non-rendering shapes
(v1 'review' 28, v2 'comment' fallback 25, model-description 159) claim the key
zero times, while challenge mentions now resolve properly and do claim it.

Also from the review:
- new-comic-comment shares the dedupe key; it is a CommentV2 row that
  new-mention and new-thread-response already sweep, so it was still duplicating.
- The bulk UPDATE-first path back-fills dedupeKey via COALESCE, so a row queued
  by the previous build is still deduped across the deploy window.
- The dedupeKey/debounceSeconds combination is rejected by the producer schema
  rather than silently dropped at fan-out, and the comment claiming it would
  abort the transaction is corrected — it never could.
- The priority test iterates the full processor registry, not just the comment
  family, and a new render-parity test asserts every claimant produces both a
  message and a URL. That test is what catches this class of bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(notifications): make the render guard symmetric across claimants

Verification pass on the previous commit confirmed the mention fixes, but pointed
out the guard was asymmetric: new-thread-response and new-comment-reply still
claimed the dedupe key unconditionally, and they share the same 'comment'
fallback for any Thread entity threadUrlMap cannot address. new-thread-response
is priority 3, so it outranks every entity-owner type.

Nothing working was being swallowed today, but only by accident of timing —
new-comic-comment is created synchronously inside the router mutation, so its
PendingNotification row lands with a lower id than the next cron tick and wins
the claim. The moment someone adds a Thread entity column plus its owner
notification (exactly what happened with challenge and model3d) the dead-link
claim would swallow it. Same bug class, one file over.

Both now use a shared commentDedupeKeyIfAddressable, which claims only when the
thread resolves to a type threadUrlMap can link to. V1 rows carry no threadType
and build their URL from modelId, so they always claim. This also makes the comic
case deterministic rather than order-dependent: those threads stay unclaimed by
the cron processors, so new-comic-comment owns the key.

Cost is negligible — over 6 hours of production, 3449 of 3457 rows still claim a
key and 190 duplicates are still suppressed; 8 rows go unclaimed.

Also mirrors the dedupeKey/debounceSeconds refine onto the monolith's duplicate
copy of createNotificationPendingRow. Only the package copy had it, so
/api/mod/send-mod-notification accepted the pair, then createNotification caught
the package schema's throw and swallowed it — a 200 with nothing sent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-03 12:59:10 -06:00
..
2025-08-12 13:20:21 -04:00