Prepare worker and Discord snapshots through bounded catch-up
This commit is contained in:
@@ -22,34 +22,40 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
|
||||
// Retain recent delivery history and deduplication records without unbounded growth.
|
||||
db.delete(reminderDeliveries).where(lt(reminderDeliveries.updatedAt, now() - 90 * 86400000)).run();
|
||||
for (const { user } of rows) {
|
||||
function due(checkDeliveries = false) {
|
||||
const currentUser = db.select().from(users).where(eq(users.id, user.id)).get();
|
||||
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
|
||||
if (!currentUser || !prefs?.enabled) return null;
|
||||
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
|
||||
if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
|
||||
const date = localDate(timestamp, currentUser.timezone);
|
||||
const times = timesForDate(prefs, date).filter(time => time <= clock);
|
||||
if (!times.length) return null;
|
||||
if (checkDeliveries) {
|
||||
const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all();
|
||||
// This is only a cheap eligibility hint. The transaction below still owns claims.
|
||||
// Let stale pending records reach its recovery step before skipping the user.
|
||||
const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000);
|
||||
if (!stalePending) {
|
||||
const legacy = previous.find(row => row.slot === '*');
|
||||
const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp);
|
||||
if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null;
|
||||
async function due(checkDeliveries = false) {
|
||||
function eligible() {
|
||||
const currentUser = db.select().from(users).where(eq(users.id, user.id)).get();
|
||||
const prefs = db.select().from(reminderSettings).where(eq(reminderSettings.userId, user.id)).get();
|
||||
if (!currentUser || !prefs?.enabled) return null;
|
||||
const timestamp = now(); const clock = localClock(timestamp, currentUser.timezone);
|
||||
if (inQuietHours(clock, prefs.quietStart, prefs.quietEnd)) return null;
|
||||
const date = localDate(timestamp, currentUser.timezone);
|
||||
const times = timesForDate(prefs, date).filter(time => time <= clock);
|
||||
if (!times.length) return null;
|
||||
if (checkDeliveries) {
|
||||
const previous = db.select().from(reminderDeliveries).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.date, date))).all();
|
||||
// This is only a cheap eligibility hint. The transaction below still owns claims.
|
||||
// Let stale pending records reach its recovery step before skipping the user.
|
||||
const stalePending = previous.some(row => row.status === 'pending' && row.updatedAt < timestamp - 60000);
|
||||
if (!stalePending) {
|
||||
const legacy = previous.find(row => row.slot === '*');
|
||||
const claimable = (prior: typeof previous[number] | undefined) => !prior || (prior.status === 'deferred' && (prior.retryAt ?? 0) <= timestamp);
|
||||
if (previous.some(row => row.status === 'pending') || (legacy ? !claimable(legacy) : !times.some(slot => claimable(previous.find(row => row.slot === slot))))) return null;
|
||||
}
|
||||
}
|
||||
return { currentUser, prefs, date, times };
|
||||
}
|
||||
const service = new HabitService(db, { id: currentUser.id, discordId: currentUser.discordId, username: currentUser.username, displayName: currentUser.globalName ?? currentUser.username, avatarUrl: null, timezone: currentUser.timezone }, timestamp);
|
||||
service.sync();
|
||||
// Avoid sending an artificial "today" after a backwards timezone change.
|
||||
if (service.today !== localDate(timestamp, currentUser.timezone)) return null;
|
||||
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
|
||||
return remaining ? { date: service.today, remaining, timezone: currentUser.timezone, times, entries: entriesForDate(prefs, service.today) } : null;
|
||||
const initial = eligible(); if (!initial) return null;
|
||||
const owner = initial.currentUser;
|
||||
return HabitService.withPrepared(db, { id: owner.id, discordId: owner.discordId, username: owner.username, displayName: owner.globalName ?? owner.username, avatarUrl: null, timezone: owner.timezone }, now, service => {
|
||||
// Revalidate preferences and local date after bounded catch-up may have yielded.
|
||||
const latest = eligible();
|
||||
if (!latest || service.today !== latest.date || service.user.timezone !== latest.currentUser.timezone) return null;
|
||||
const remaining = service.list().filter(habit => { const day = service.day(habit.id, service.today); return day.due && !day.complete; }).length;
|
||||
return remaining ? { date: service.today, remaining, timezone: service.user.timezone, times: latest.times, entries: entriesForDate(latest.prefs, service.today) } : null;
|
||||
});
|
||||
}
|
||||
const initial = due(true); if (!initial) continue;
|
||||
const initial = await due(true); if (!initial) continue;
|
||||
const claims = db.transaction(tx => {
|
||||
// A crashed/aborted send must not block later times indefinitely.
|
||||
tx.update(reminderDeliveries).set({ status: "uncertain" }).where(and(eq(reminderDeliveries.userId, user.id), eq(reminderDeliveries.status, "pending"), lt(reminderDeliveries.updatedAt, now() - 60000))).run();
|
||||
@@ -87,7 +93,7 @@ export async function sendDueReminders(db: AppDatabase, config: ReminderConfig,
|
||||
const destination = channel.ok ? await channel.json().catch(() => null) as { id?: string } | null : null;
|
||||
if (!destination?.id || !/^\d{17,20}$/.test(destination.id)) { update("failed"); continue; }
|
||||
// A user may opt out or finish a habit while Discord opens the DM channel.
|
||||
const latest = due();
|
||||
const latest = await due();
|
||||
if (!latest || latest.date !== initial.date || !claims.some(claim => claim.slot === "*" || latest.times.includes(claim.slot))) { update("skipped"); continue; }
|
||||
const names = [...new Set(latest.entries.filter(entry => claims.some(claim => claim.slot === "*" ? latest.times.includes(entry.time) : claim.slot === entry.time)).map(entry => entry.name?.trim()).filter((name): name is string => Boolean(name)))];
|
||||
// Bound the label prefix so the count and link always fit Discord's message limit.
|
||||
|
||||
Reference in New Issue
Block a user