perf(notify): bounded-concurrency fan-out for grant notification emails

Creating a grant for a group dispatched the notification emails one at
a time inside the HTTP request — 30 members × ~500 ms of SMTP ≈ 15 s
holding the POST /api/grants response (the code carried a TODO
acknowledging it).

Dispatches are independent (coalescing and rate-limiting key on the
(granter, recipient) pair, distinct per member), so run them through
`buffered(6)`: ~6× less wall time for group fan-outs while capping
parallel SMTP sessions, with outcome order still matching member order.

https://claude.ai/code/session_01Dp3oWon5GBMVn4j3QXZdgx
This commit is contained in:
Claude
2026-06-10 09:52:47 +00:00
parent 908b8f4d4b
commit 71bdb653e0
@@ -70,6 +70,11 @@ use crate::domain::services::authorization::{Resource, Subject};
use crate::infrastructure::repositories::pg::UserPgRepository; use crate::infrastructure::repositories::pg::UserPgRepository;
use crate::interfaces::middleware::rate_limit::RateLimiter; use crate::interfaces::middleware::rate_limit::RateLimiter;
/// Concurrent per-recipient dispatches in flight during a group fan-out.
/// High enough to collapse a 30-member group's serial SMTP latency,
/// low enough not to flood the relay (most reject >10 parallel sessions).
const NOTIFY_DISPATCH_CONCURRENCY: usize = 6;
/// What triggered the notification — purely an audit discriminator. /// What triggered the notification — purely an audit discriminator.
/// `GrantCreated` → fired implicitly when a grant lands; `ManualResend` /// `GrantCreated` → fired implicitly when a grant lands; `ManualResend`
/// → granter explicitly clicked "Notify by email" in My Shares. /// → granter explicitly clicked "Notify by email" in My Shares.
@@ -268,13 +273,22 @@ impl RecipientNotificationService {
); );
} }
let mut outcomes = Vec::with_capacity(members.len()); // SMTP dispatch dominates each iteration (hundreds of ms per
for member in &members { // recipient) and the iterations are independent — coalescing and
let outcome = self // rate-limiting key on (granter, recipient), which is distinct per
.dispatch_to_one_user(granter, member, resource, trigger) // member. Bounded concurrency keeps a 30-member group grant from
.await; // holding the HTTP response for 15+ s of serial sends while still
outcomes.push(outcome); // capping the pressure on the SMTP relay. `buffered` (not
} // `buffer_unordered`) preserves the member order of the outcomes.
use futures::stream::{self, StreamExt};
let outcomes: Vec<NotifyOutcome> = stream::iter(members)
.map(|member| async move {
self.dispatch_to_one_user(granter, &member, resource, trigger)
.await
})
.buffered(NOTIFY_DISPATCH_CONCURRENCY)
.collect()
.await;
Ok(NotifyOutcomeSet { outcomes }) Ok(NotifyOutcomeSet { outcomes })
} }