173 lines
4.9 KiB
TypeScript
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;
|
|
}
|