Skip to content

fix(notifications): send blast push notifications again - #73

Merged
dylanjeffers merged 2 commits into
mainfrom
fix/dm-blast-age-guard-and-chat-allowed
Jul 16, 2026
Merged

fix(notifications): send blast push notifications again#73
dylanjeffers merged 2 commits into
mainfrom
fix/dm-blast-age-guard-and-chat-allowed

Conversation

@dylanjeffers

@dylanjeffers dylanjeffers commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Urgency

Blast push notifications are sending zero pushes and have been since the age guard was introduced — every recipient of every blast is silently dropped. This is not a slow degradation: a blast sends no pushes at all, starting from its very first tick.

Bug 1 — blast rows never carried a timestamp, so the age guard dropped all of them

config.dmNotificationMaxAgeMs (default 1 hour) filters out notifications whose timestamp is older than the cutoff, so a backlog after downtime doesn't flood users. getUnreadMessages and getUnreadReactions both alias their time column into the shape the rest of the pipeline expects (chat_message.created_at as timestamp), matching the DMNotification type in src/types/notifications.ts.

getNewBlasts did not. Its raw query ended:

SELECT blast_id, from_user_id AS sender_user_id, to_user_id AS receiver_user_id, created_at FROM targ;

The column came back as created_at, so notification.timestamp was undefined on every blast notification. The age guard evaluates n.notification.timestamp >= sendCutoff, and undefined >= <Date> is false — always, regardless of how old the blast actually is. So every blast push was skipped from the first tick onward, not after an hour. numberSkippedTooOld incremented, the cursor advanced, nothing errored. The undefined timestamp also made blasts sort arbitrarily in notificationTimestampComparator.

Fix: alias the column, so blasts carry a real timestamp like every other notification type.

SELECT blast_id, from_user_id AS sender_user_id, to_user_id AS receiver_user_id, created_at AS timestamp FROM targ;

Bug 2 — the age guard is wrong for blasts even once the timestamp is populated

With Bug 1 fixed, the age guard now actually engages for blasts — and it would be wrong to let it. Every recipient row carries chat_blast.created_at, which is when the blast was authored, identical across the whole audience; it is not when that recipient's row was queued. A blast fans out in batches of blastUserBatchSize (100) per tick, so any blast whose audience takes longer than maxAgeMs to work through would cross the cutoff and then drop every remaining recipient.

Fix: exempt blasts from the age guard. Blasts are an async fan-out by design and shouldn't face a recency check meant for live messages. Regular DMs and reactions still honor the cap.

const blastSet = new Set<Message | MessageReaction>(blastNotifications)
const toSend = notifications.filter(
  (n) => blastSet.has(n) || n.notification.timestamp >= sendCutoff
)

Bugs 1 and 2 are independent, and both must land for blasts to deliver reliably: Bug 1 blocks delivery outright; Bug 2 would block it for large audiences once Bug 1 is fixed.

Bug 3 — missing parens in getNewBlasts bypassed chat_allowed

The WHERE clause read:

WHERE chat_allowed(from_user_id, to_user_id)
AND ?::INTEGER IS NULL OR to_user_id > ?

AND binds tighter than OR, so Postgres parsed this as (chat_allowed(...) AND $cursor IS NULL) OR (to_user_id > $cursor). On the first page the cursor is null and the left branch applies, but on every subsequent page the cursor is non-null and the predicate collapses to to_user_id > $cursorchat_allowed is not evaluated at all, so recipients past the first batch could receive a blast they hadn't permitted.

This stayed latent precisely because of Bug 1: those pushes were dropped before delivery. Fixing Bug 1 without this would start delivering to users who never allowed the sender, which is why they ship together.

Fix: parenthesize the cursor condition.

WHERE chat_allowed(from_user_id, to_user_id)
AND (?::INTEGER IS NULL OR to_user_id > ?)

Also included

  • Sargable time bounds. getUnreadMessages and getUnreadReactions wrapped the indexed column in date_trunc('milliseconds', ...), which prevents the index from being used. The truncation moves to the bounds instead, preserving the same millisecond semantics while keeping chat_message.created_at and chat_message_reactions.updated_at indexable.
  • Dropped noisy console.log calls that serialized entire notification arrays on every tick.

Testing

npm run typecheck passes and eslint src/tasks/dmNotifications.ts is clean.

There is no existing test coverage for dmNotifications.ts — no harness exists for the Redis cursors plus the discovery/identity DB fan-out — so nothing to update, and the fixes here are not covered by an automated test. Bug 1 in particular is the kind of shape mismatch a single unit test over getNewBlasts' result would have caught, so coverage for the blast cursor path is worth following up on separately.

🤖 Generated with Claude Code

dylanjeffers and others added 2 commits July 16, 2026 11:58
Two bugs prevented chat blast push notifications from being delivered.

1. The dmNotificationMaxAgeMs guard (1h) dropped every blast push. Blast
   recipients all carry the blast's own created_at rather than a per-recipient
   time, so once a blast has been working through its audience for longer than
   maxAgeMs, every remaining recipient permanently fails the check. Blasts are
   now exempt from the age guard; regular DMs and reactions still honor it.

2. getNewBlasts was missing parentheses around the cursor condition. Because
   AND binds tighter than OR, the predicate parsed as
   (chat_allowed(...) AND $cursor IS NULL) OR (to_user_id > $cursor),
   so chat_allowed() was bypassed entirely once the cursor went non-null —
   blasts could be sent to users who had not allowed them.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
getNewBlasts selected created_at bare while DMNotification declares a
timestamp field, so notification.timestamp was undefined for every blast.
discoveryDB.raw returns any, so tsc never flagged the mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dylanjeffers
dylanjeffers merged commit d64a7b7 into main Jul 16, 2026
2 checks passed
dylanjeffers added a commit that referenced this pull request Jul 16, 2026
Covers getUnreadMessages, getUnreadReactions, getNewBlasts, and
sendDMNotifications with a mocked Knex query recorder and in-memory
Redis, including regressions from #73 (blast created_at AS timestamp
alias, parenthesized chat_allowed guard) and the blast exemption from
the DM max-age cutoff. Exports the three query helpers for direct
testing.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant