fix: prevent duplicate scheduler reminder sends (#270)

This commit is contained in:
Daniel Volz
2026-02-22 10:56:13 +01:00
committed by GitHub
parent 9a2d42b8b9
commit 2296303236
+86 -2
View File
@@ -1,4 +1,4 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs"; import { closeSync, existsSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
import { resolve } from "node:path"; import { resolve } from "node:path";
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import nodemailer from "nodemailer"; import nodemailer from "nodemailer";
@@ -40,6 +40,56 @@ function escapeHtml(text: string): string {
const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time const REMINDER_HOUR = parseInt(process.env.REMINDER_HOUR ?? "6", 10); // Default 6:00 AM local time
const reminderStateFile = resolve(getDataDir(), "reminder-state.json"); const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
const reminderLocksDir = resolve(getDataDir(), "scheduler-locks");
const LOCK_STALE_MS = 15 * 60 * 1000;
function ensureReminderLocksDir(): void {
if (!existsSync(reminderLocksDir)) {
mkdirSync(reminderLocksDir, { recursive: true });
}
}
function acquireReminderSendLock(lockKey: string): string | null {
ensureReminderLocksDir();
const lockFilePath = resolve(reminderLocksDir, `${lockKey}.lock`);
const tryCreateLock = (): boolean => {
try {
const fd = openSync(lockFilePath, "wx");
closeSync(fd);
return true;
} catch {
return false;
}
};
if (tryCreateLock()) {
return lockFilePath;
}
try {
const stats = statSync(lockFilePath);
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
unlinkSync(lockFilePath);
if (tryCreateLock()) {
return lockFilePath;
}
}
} catch {
// ignore; lock acquisition fails safely
}
return null;
}
function releaseReminderSendLock(lockFilePath: string | null): void {
if (!lockFilePath) return;
try {
unlinkSync(lockFilePath);
} catch {
// ignore release errors
}
}
function loadReminderState(): ReminderState { function loadReminderState(): ReminderState {
try { try {
@@ -565,6 +615,11 @@ async function checkAndSendReminderForUser(
if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) { if (allLowStock.length > 0 && (stockEmailEnabled || stockPushEnabled)) {
if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) { if (!state.notifiedMedications.includes(userStockNotifiedKey) || settings.repeatDailyReminders) {
const stockSendLock = acquireReminderSendLock(userStockNotifiedKey);
if (!stockSendLock) {
logger.debug(`[Reminder] User ${settings.userId}: stock reminder lock already held, skipping duplicate send`);
} else {
try {
logger.info( logger.info(
`[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...` `[Reminder] User ${settings.userId}: Sending stock reminder for ${allLowStock.length} medications...`
); );
@@ -643,11 +698,22 @@ async function checkAndSendReminderForUser(
const medNames = allLowStock.map((m) => m.name).join(", "); const medNames = allLowStock.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "stock", channel, medNames); await updateUserReminderSentTime(settings.userId, "stock", channel, medNames);
} }
} finally {
releaseReminderSendLock(stockSendLock);
}
}
} }
} }
if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) { if (allPrescriptionLow.length > 0 && (prescriptionEmailEnabled || prescriptionPushEnabled)) {
if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) { if (!state.notifiedMedications.includes(userPrescriptionNotifiedKey) || settings.repeatDailyReminders) {
const prescriptionSendLock = acquireReminderSendLock(userPrescriptionNotifiedKey);
if (!prescriptionSendLock) {
logger.debug(
`[Reminder] User ${settings.userId}: prescription reminder lock already held, skipping duplicate send`
);
} else {
try {
logger.info( logger.info(
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...` `[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
); );
@@ -695,7 +761,9 @@ async function checkAndSendReminderForUser(
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length }); : t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
const bodyText = const bodyText =
emptyRx.length > 0 ? tr.prescriptionReminder.descriptionEmpty : tr.prescriptionReminder.descriptionLow; emptyRx.length > 0
? tr.prescriptionReminder.descriptionEmpty
: tr.prescriptionReminder.descriptionLow;
const emptyAlert = const emptyAlert =
emptyRx.length === 1 emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle ? tr.prescriptionReminder.alertEmptySingle
@@ -833,11 +901,16 @@ async function checkAndSendReminderForUser(
const medNames = allPrescriptionLow.map((m) => m.name).join(", "); const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames); await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
} }
} finally {
releaseReminderSendLock(prescriptionSendLock);
}
}
} }
} }
} }
let schedulerTimeout: NodeJS.Timeout | null = null; let schedulerTimeout: NodeJS.Timeout | null = null;
let schedulerStarted = false;
function scheduleNextCheck(logger: ServiceLogger): void { function scheduleNextCheck(logger: ServiceLogger): void {
const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR); const msUntilNext = getMsUntilNextCheck(REMINDER_HOUR);
@@ -862,6 +935,11 @@ function scheduleNextCheck(logger: ServiceLogger): void {
} }
export function startReminderScheduler(logger: ServiceLogger): void { export function startReminderScheduler(logger: ServiceLogger): void {
if (schedulerStarted) {
logger.info(`[Reminder] Scheduler already started, skipping duplicate start call`);
return;
}
schedulerStarted = true;
logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`); logger.info(`[Reminder] Starting reminder scheduler (timezone: ${getTimezone()})...`);
// Check if we need to run immediately (missed today's check) // Check if we need to run immediately (missed today's check)
@@ -881,9 +959,15 @@ export function startReminderScheduler(logger: ServiceLogger): void {
logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`); logger.info(`[Reminder] Scheduler started - daily check at ${REMINDER_HOUR}:00 ${getTimezone()}`);
} }
export async function runReminderSchedulerNow(logger: ServiceLogger): Promise<void> {
logger.info(`[Reminder] Manual trigger: running reminder check now (${getTimezone()})`);
await checkAndSendReminder(logger);
}
export function stopReminderScheduler(): void { export function stopReminderScheduler(): void {
if (schedulerTimeout) { if (schedulerTimeout) {
clearTimeout(schedulerTimeout); clearTimeout(schedulerTimeout);
schedulerTimeout = null; schedulerTimeout = null;
} }
schedulerStarted = false;
} }