Files
notification-wakeup-service/src/alertSearch/scheduler.ts
T

173 lines
4.9 KiB
TypeScript

import {
alertAuthorizationDb,
type PendingDayUser,
} from "../db/alertAuthorizationSqlite.js";
import {
utcCalendarDay,
utcHourMinute,
} from "../services/alertAuthorization.js";
import {
ALERT_SEARCH_USER_CONCURRENCY,
forEachWithConcurrency,
} from "../util/concurrency.js";
import { errorMessage, formatElapsedMs } from "../util/formatElapsed.js";
import {
runDailyAlertSearch,
type DailyAlertSearchResult,
} from "./daily.js";
import { deliverAlertSearchNotification } from "./notify.js";
import { log } from "../util/log.js";
/** Independent of the FCM wakeup interval; does not share that timer. */
export const ALERT_SEARCH_SCHEDULER_INTERVAL_MS = 5 * 60 * 1000;
export type AlertSearchUserRunner = (
userId: string
) => Promise<DailyAlertSearchResult>;
export type AlertSearchNotifyRunner = (
result: DailyAlertSearchResult
) => Promise<unknown>;
export type AlertSearchSchedulerPassInput = {
listPending?: (input: {
day: string;
hourMinute: string;
}) => Promise<PendingDayUser[]>;
runDaily?: AlertSearchUserRunner;
notify?: AlertSearchNotifyRunner;
/** The instant this pass represents. Defaults to now. */
now?: Date;
/** Users worked on at once. Set to 1 for a deterministic order in tests. */
concurrency?: number;
};
export type AlertSearchSchedulerPassResult = {
skipped: boolean;
/** The users this pass ran, in no guaranteed order. */
userIds: string[];
attempted: number;
failed: number;
/** Users holding an unused JWT whose chosen UTC time has not arrived yet. */
deferred: number;
};
let intervalId: ReturnType<typeof setInterval> | undefined;
let passInFlight = false;
export function isAlertSearchSchedulerPassInFlight(): boolean {
return passInFlight;
}
/**
* One user-oriented alertSearch pass over the users who have work today and
* whose chosen hour has arrived. Skips if a pass is already running. After each
* daily run, may send an AlertSearch FCM digest. Does not use the device wakeup
* ping path.
*/
export async function runAlertSearchSchedulerPass(
input: AlertSearchSchedulerPassInput = {}
): Promise<AlertSearchSchedulerPassResult> {
if (passInFlight) {
log.info("[AlertSearchScheduler] Pass skipped (already in flight)");
return { skipped: true, userIds: [], attempted: 0, failed: 0, deferred: 0 };
}
passInFlight = true;
const passStarted = Date.now();
log.info("[AlertSearchScheduler] Pass started");
try {
const now = input.now ?? new Date();
const runDaily =
input.runDaily ?? ((userId: string) => runDailyAlertSearch(userId, now));
const notify = input.notify ?? deliverAlertSearchNotification;
const listPending =
input.listPending ??
((query: { day: string; hourMinute: string }) =>
alertAuthorizationDb.listPendingForDay(query));
const nowSec = Math.floor(now.getTime() / 1000);
const pending = await listPending({
day: utcCalendarDay(nowSec),
hourMinute: utcHourMinute(nowSec),
});
const userIds = pending.filter((row) => row.due).map((row) => row.userId);
const deferred = pending.length - userIds.length;
let failed = 0;
await forEachWithConcurrency(
userIds,
input.concurrency ?? ALERT_SEARCH_USER_CONCURRENCY,
async (userId) => {
try {
const daily = await runDaily(userId);
try {
await notify(daily);
} catch (err) {
log.error(
"[AlertSearchScheduler] Notification failed",
userId + ":",
errorMessage(err)
);
}
} catch (err) {
failed += 1;
log.error(
"[AlertSearchScheduler] User failed",
userId + ":",
errorMessage(err)
);
}
}
);
log.info(
"[AlertSearchScheduler] Pass completed in",
formatElapsedMs(Date.now() - passStarted) + ",",
`attempted ${userIds.length}, deferred ${deferred}, failed ${failed}`
);
return {
skipped: false,
userIds,
attempted: userIds.length,
failed,
deferred,
};
} catch (err) {
log.error(
"[AlertSearchScheduler] Pass failed in",
formatElapsedMs(Date.now() - passStarted) + ":",
errorMessage(err)
);
throw err;
} finally {
passInFlight = false;
}
}
/**
* Starts a dedicated interval. Does not run a pass immediately (same as FCM).
* Calling twice is a no-op. Independent of the FCM device wakeup timer.
*/
export function startAlertSearchScheduler(): boolean {
if (intervalId !== undefined) return false;
intervalId = setInterval(() => {
void runAlertSearchSchedulerPass();
}, ALERT_SEARCH_SCHEDULER_INTERVAL_MS);
return true;
}
export function stopAlertSearchScheduler(): void {
if (intervalId !== undefined) {
clearInterval(intervalId);
intervalId = undefined;
}
}
/** Test helper: drop the in-flight flag after an interrupted pass. */
export function resetAlertSearchSchedulerPassGuard(): void {
passInFlight = false;
}