Skip to content

Commit e246d32

Browse files
KMKoushikclaude
andauthored
fix: prevent duplicate notification emails via atomic Redis SET NX (#346)
The warning and limit-reached notification emails were being sent multiple times because of a race condition: concurrent workers could both read the Redis cooldown key as empty (GET), both send emails, then both set the key (SETEX). Replaced the non-atomic GET + SETEX pattern with a single atomic SET ... NX EX that claims the cooldown slot before any emails are sent. Also increased cooldown from 6 hours to 24 hours so each notification is sent at most once per day. https://claude.ai/code/session_01VBYXi5e64Vtq1cXWsfTYTw Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1b3b8f5 commit e246d32

1 file changed

Lines changed: 12 additions & 20 deletions

File tree

apps/web/src/server/service/team-service.ts

Lines changed: 12 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -356,7 +356,7 @@ export class TeamService {
356356
}
357357

358358
/**
359-
* Notify all team users that email limit has been reached, at most once per 6 hours.
359+
* Notify all team users that email limit has been reached, at most once per day.
360360
*/
361361
static async maybeNotifyEmailLimitReached(
362362
teamId: number,
@@ -390,13 +390,15 @@ export class TeamService {
390390

391391
const redis = getRedis();
392392
const cacheKey = `limit:notify:${teamId}:${reason}`;
393-
const alreadySent = await redis.get(cacheKey);
394-
if (alreadySent) {
393+
// Atomic SET NX to prevent race conditions: only one concurrent caller
394+
// can acquire the cooldown key. TTL = 24 hours (one notification per day).
395+
const acquired = await redis.set(cacheKey, "1", "EX", 24 * 60 * 60, "NX");
396+
if (acquired !== "OK") {
395397
logger.info(
396398
{ teamId, cacheKey },
397399
"[TeamService]: Skipping notify — cooldown active",
398400
);
399-
return; // within cooldown window
401+
return; // another request already claimed this window
400402
}
401403

402404
const team = await TeamService.getTeamCached(teamId);
@@ -447,17 +449,11 @@ export class TeamService {
447449
throw err;
448450
}
449451

450-
// Set cooldown for 6 hours
451-
await redis.setex(cacheKey, 6 * 60 * 60, "1");
452-
logger.info(
453-
{ teamId, cacheKey },
454-
"[TeamService]: Set limit reached notification cooldown",
455-
);
456452
}
457453

458454
/**
459455
* Notify all team users that they're nearing their email limit.
460-
* Rate limited via Redis to avoid spamming; sends at most once per 6 hours per reason.
456+
* Rate limited via Redis to avoid spamming; sends at most once per day per reason.
461457
*/
462458
static async sendWarningEmail(
463459
teamId: number,
@@ -492,13 +488,15 @@ export class TeamService {
492488

493489
const redis = getRedis();
494490
const cacheKey = `limit:warning:${teamId}:${reason}`;
495-
const alreadySent = await redis.get(cacheKey);
496-
if (alreadySent) {
491+
// Atomic SET NX to prevent race conditions: only one concurrent caller
492+
// can acquire the cooldown key. TTL = 24 hours (one notification per day).
493+
const acquired = await redis.set(cacheKey, "1", "EX", 24 * 60 * 60, "NX");
494+
if (acquired !== "OK") {
497495
logger.info(
498496
{ teamId, cacheKey },
499497
"[TeamService]: Skipping warning — cooldown active",
500498
);
501-
return; // within cooldown window
499+
return; // another request already claimed this window
502500
}
503501

504502
const team = await TeamService.getTeamCached(teamId);
@@ -558,12 +556,6 @@ export class TeamService {
558556
throw err;
559557
}
560558

561-
// Set cooldown for 6 hours
562-
await redis.setex(cacheKey, 6 * 60 * 60, "1");
563-
logger.info(
564-
{ teamId, cacheKey },
565-
"[TeamService]: Set warning notification cooldown",
566-
);
567559
}
568560
}
569561

0 commit comments

Comments
 (0)