fix: keep background jobs alive

Add a background job orchestrator that restores profile jobs from saved credentials,
keeps pickup and store-watch schedules alive without UI logins, and exposes job
status via health/admin endpoints.
This commit is contained in:
2026-06-25 12:40:28 +02:00
parent 881b3d8d86
commit 02dfea8562
5 changed files with 308 additions and 6 deletions

View File

@@ -1,3 +1,5 @@
fix: retry transient pickup checks fix: keep background jobs alive
Retry transient Foodsharing network failures such as socket hang up for safe read/check actions and add explicit pickup fallback scheduling, tick, and result logs so missed fallback checks can be diagnosed. Add a background job orchestrator that restores profile jobs from saved credentials,
keeps pickup and store-watch schedules alive without UI logins, and exposes job
status via health/admin endpoints.

View File

@@ -22,6 +22,7 @@ const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./serv
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore'); const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
const { readStoresCache, writeStoresCache } = require('./services/storeCacheStore'); const { readStoresCache, writeStoresCache } = require('./services/storeCacheStore');
const requestLogStore = require('./services/requestLogStore'); const requestLogStore = require('./services/requestLogStore');
const backgroundJobOrchestrator = require('./services/backgroundJobOrchestrator');
const { const {
readJournal, readJournal,
writeJournal, writeJournal,
@@ -211,6 +212,12 @@ async function buildRegularPickupMapForConfig(session, config) {
function scheduleWithCurrentSettings(sessionId, config) { function scheduleWithCurrentSettings(sessionId, config) {
const settings = adminConfig.readSettings(); const settings = adminConfig.readSettings();
scheduleConfig(sessionId, config, settings); scheduleConfig(sessionId, config, settings);
const session = sessionStore.get(sessionId);
if (session?.profile?.id) {
backgroundJobOrchestrator.refreshProfile(session.profile.id, { config, settings }).catch((error) => {
console.error('[BACKGROUND] Profil-Jobs konnten nicht aktualisiert werden:', error.message);
});
}
} }
function rescheduleAllSessions() { function rescheduleAllSessions() {
@@ -223,6 +230,9 @@ function rescheduleAllSessions() {
scheduleConfig(session.id, config, settings); scheduleConfig(session.id, config, settings);
}); });
scheduleRegularPickupRefresh(settings); scheduleRegularPickupRefresh(settings);
backgroundJobOrchestrator.refreshAll({ settings }).catch((error) => {
console.error('[BACKGROUND] Profil-Jobs konnten nicht neu geplant werden:', error.message);
});
} }
function mergeStoresIntoConfig(config = [], stores = []) { function mergeStoresIntoConfig(config = [], stores = []) {
@@ -1538,6 +1548,10 @@ app.post('/api/admin/settings', requireAuth, requireAdmin, (req, res) => {
res.json(updated); res.json(updated);
}); });
app.get('/api/admin/background-jobs', requireAuth, requireAdmin, (_req, res) => {
res.json(backgroundJobOrchestrator.getStatus());
});
app.get('/api/debug/requests', requireAuth, requireAdmin, (req, res) => { app.get('/api/debug/requests', requireAuth, requireAdmin, (req, res) => {
const limit = req.query?.limit ? Number(req.query.limit) : undefined; const limit = req.query?.limit ? Number(req.query.limit) : undefined;
const logs = requestLogStore.list(limit); const logs = requestLogStore.list(limit);
@@ -1545,7 +1559,15 @@ app.get('/api/debug/requests', requireAuth, requireAdmin, (req, res) => {
}); });
app.get('/api/health', (_req, res) => { app.get('/api/health', (_req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() }); const background = backgroundJobOrchestrator.getStatus();
res.json({
status: 'ok',
timestamp: new Date().toISOString(),
backgroundJobs: {
running: background.running,
profiles: background.profiles.length
}
});
}); });
app.get('*', (req, res) => { app.get('*', (req, res) => {
@@ -1559,6 +1581,7 @@ async function startServer() {
console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message); console.error('[RESTORE] Fehler bei der Session-Wiederherstellung:', error.message);
} }
scheduleRegularPickupRefresh(adminConfig.readSettings()); scheduleRegularPickupRefresh(adminConfig.readSettings());
backgroundJobOrchestrator.start();
startBackgroundStoreRefreshTicker(); startBackgroundStoreRefreshTicker();
app.listen(port, () => { app.listen(port, () => {
console.log(`Server läuft auf Port ${port}`); console.log(`Server läuft auf Port ${port}`);

View File

@@ -0,0 +1,260 @@
const credentialStore = require('./credentialStore');
const sessionStore = require('./sessionStore');
const foodsharingClient = require('./foodsharingClient');
const adminConfig = require('./adminConfig');
const { readConfig } = require('./configStore');
const { readStoreWatch, listWatcherProfiles } = require('./storeWatchStore');
const { readStoresCache } = require('./storeCacheStore');
const notificationService = require('./notificationService');
const { scheduleConfig } = require('./pickupScheduler');
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
const WATCHDOG_INTERVAL_MS = 5 * 60 * 1000;
const SESSION_ERROR_COOLDOWN_MS = 6 * 60 * 60 * 1000;
const adminEmail = (process.env.ADMIN_EMAIL || '').toLowerCase();
const profileStates = new Map();
const sessionErrorNotifications = new Map();
let watchdogTimer = null;
let ensureAllInFlight = null;
function isAdmin(profile) {
return !!adminEmail && !!profile?.email && profile.email.toLowerCase() === adminEmail;
}
function getProfileIds() {
const ids = new Set();
Object.keys(credentialStore.loadAll()).forEach((profileId) => ids.add(String(profileId)));
listWatcherProfiles().forEach((profileId) => ids.add(String(profileId)));
return Array.from(ids).sort();
}
function getActivePickupEntries(config) {
return Array.isArray(config) ? config.filter((entry) => entry?.active) : [];
}
function createSignature({ config, watchers, settings }) {
return JSON.stringify({
pickup: Array.isArray(config)
? config.map((entry) => ({
id: entry?.id ? String(entry.id) : '',
active: !!entry?.active,
hidden: !!entry?.hidden,
onlyNotify: !!entry?.onlyNotify,
desiredDate: entry?.desiredDate || null,
desiredDateRange: entry?.desiredDateRange || null,
desiredWeekday: entry?.desiredWeekday || null,
autoDeactivate: entry?.autoDeactivate !== false
}))
: [],
watch: Array.isArray(watchers)
? watchers.map((entry) => ({
storeId: entry?.storeId ? String(entry.storeId) : '',
lastTeamSearchStatus: entry?.lastTeamSearchStatus ?? null
}))
: [],
settings: {
pickupFallbackCron: settings?.pickupFallbackCron || null,
pickupWindowOffsetsMinutes: settings?.pickupWindowOffsetsMinutes || [],
randomDelayMinSeconds: settings?.randomDelayMinSeconds ?? null,
randomDelayMaxSeconds: settings?.randomDelayMaxSeconds ?? null,
storeWatchCron: settings?.storeWatchCron || null,
storeWatchInitialDelayMinSeconds: settings?.storeWatchInitialDelayMinSeconds ?? null,
storeWatchInitialDelayMaxSeconds: settings?.storeWatchInitialDelayMaxSeconds ?? null,
storeWatchRequestDelayMs: settings?.storeWatchRequestDelayMs ?? null,
storeWatchStatusCacheMaxAgeMinutes: settings?.storeWatchStatusCacheMaxAgeMinutes ?? null
}
});
}
function shouldNotifySessionError(profileId) {
const key = profileId ? String(profileId) : null;
if (!key) {
return false;
}
const now = Date.now();
const last = sessionErrorNotifications.get(key) || 0;
if (now - last < SESSION_ERROR_COOLDOWN_MS) {
return false;
}
sessionErrorNotifications.set(key, now);
return true;
}
async function notifySessionError(profileId, credentials, error) {
if (!shouldNotifySessionError(profileId)) {
return;
}
try {
await notificationService.sendAdminSessionErrorNotification({
profileId,
profileEmail: credentials?.email,
error: error?.message,
label: 'background-orchestrator'
});
} catch (notifyError) {
console.error('[BACKGROUND] Admin-Session-Fehler konnte nicht gemeldet werden:', notifyError.message);
}
}
async function loginProfile(profileId, credentials) {
const auth = await foodsharingClient.login(credentials.email, credentials.password);
const profile = {
id: String(auth.profile.id),
name: auth.profile.name,
email: auth.profile.email || credentials.email
};
const session = sessionStore.create(
{
cookieHeader: auth.cookieHeader,
csrfToken: auth.csrfToken,
profile,
credentials,
isAdmin: isAdmin(profile),
storesCache: readStoresCache(profile.id)
},
credentials.token,
ONE_YEAR_MS
);
credentialStore.save(profile.id, {
email: credentials.email,
password: credentials.password,
token: session.id
});
if (String(profileId) !== profile.id) {
console.warn(`[BACKGROUND] Profil ${profileId} wurde als ${profile.id} angemeldet.`);
}
return session;
}
async function ensureProfile(profileId, options = {}) {
const key = profileId ? String(profileId) : null;
if (!key) {
return null;
}
const credentials = credentialStore.get(key);
const previous = profileStates.get(key) || {};
if (!credentials?.email || !credentials?.password) {
const state = {
...previous,
profileId: key,
active: false,
lastError: 'missing-credentials',
checkedAt: Date.now()
};
profileStates.set(key, state);
return state;
}
try {
let session = sessionStore.getByProfile(key) || (credentials.token ? sessionStore.get(credentials.token) : null);
if (!session?.cookieHeader) {
session = await loginProfile(key, credentials);
console.log(`[BACKGROUND] Session fuer Profil ${session.profile.id} (${session.profile.name}) bereitgestellt.`);
} else {
sessionStore.update(session.id, { credentials });
session = sessionStore.get(session.id);
}
const profileIdForData = String(session.profile.id);
const config = Array.isArray(options.config) ? options.config : readConfig(profileIdForData);
const watchers = readStoreWatch(profileIdForData);
const settings = options.settings || adminConfig.readSettings();
const signature = createSignature({ config, watchers, settings });
const hasJobs = Array.isArray(session.jobs) && session.jobs.length > 0;
const shouldSchedule = options.force || previous.signature !== signature || !hasJobs;
if (shouldSchedule) {
scheduleConfig(session.id, config, settings);
console.log(
`[BACKGROUND] Jobs fuer Profil ${profileIdForData} sichergestellt ` +
`(${getActivePickupEntries(config).length} aktive Pickup-Eintraege, ${watchers.length} Watch-Stores).`
);
}
const state = {
profileId: profileIdForData,
sessionId: session.id,
active: true,
signature,
checkedAt: Date.now(),
lastSuccessAt: Date.now(),
lastError: null,
activePickupCount: getActivePickupEntries(config).length,
watchedStoreCount: watchers.length,
jobCount: sessionStore.get(session.id)?.jobs?.length || 0
};
profileStates.set(profileIdForData, state);
return state;
} catch (error) {
console.error(`[BACKGROUND] Jobs fuer Profil ${key} konnten nicht sichergestellt werden:`, error.message);
await notifySessionError(key, credentials, error);
const state = {
...previous,
profileId: key,
active: false,
checkedAt: Date.now(),
lastError: error.message || 'unknown-error'
};
profileStates.set(key, state);
return state;
}
}
async function ensureAll(options = {}) {
if (ensureAllInFlight) {
return ensureAllInFlight;
}
ensureAllInFlight = (async () => {
const profileIds = getProfileIds();
const results = [];
for (const profileId of profileIds) {
results.push(await ensureProfile(profileId, options));
}
return results;
})();
try {
return await ensureAllInFlight;
} finally {
ensureAllInFlight = null;
}
}
function start(options = {}) {
if (watchdogTimer) {
return;
}
const intervalMs = Number(options.intervalMs) || WATCHDOG_INTERVAL_MS;
ensureAll({ force: true }).catch((error) => {
console.error('[BACKGROUND] Initiale Job-Pruefung fehlgeschlagen:', error.message);
});
watchdogTimer = setInterval(() => {
ensureAll().catch((error) => {
console.error('[BACKGROUND] Watchdog-Pruefung fehlgeschlagen:', error.message);
});
}, intervalMs);
}
function refreshProfile(profileId, options = {}) {
return ensureProfile(profileId, { ...options, force: true });
}
function refreshAll(options = {}) {
return ensureAll({ ...options, force: true });
}
function getStatus() {
return {
running: !!watchdogTimer,
profiles: Array.from(profileStates.values()).map((state) => ({ ...state }))
};
}
module.exports = {
start,
refreshProfile,
refreshAll,
getStatus
};

View File

@@ -1122,7 +1122,7 @@ function scheduleStoreWatchers(sessionId, settings) {
return false; return false;
} }
const cronExpression = effectiveSettings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron; const cronExpression = effectiveSettings.storeWatchCron || DEFAULT_SETTINGS.storeWatchCron;
const job = cron.schedule( const cronJob = cron.schedule(
cronExpression, cronExpression,
() => { () => {
checkWatchedStores(sessionId, effectiveSettings).catch((error) => { checkWatchedStores(sessionId, effectiveSettings).catch((error) => {
@@ -1131,14 +1131,22 @@ function scheduleStoreWatchers(sessionId, settings) {
}, },
{ timezone: 'Europe/Berlin' } { timezone: 'Europe/Berlin' }
); );
sessionStore.attachJob(sessionId, job); let initialTimeout = setTimeout(
setTimeout(
() => checkWatchedStores(sessionId, effectiveSettings), () => checkWatchedStores(sessionId, effectiveSettings),
randomDelayMs( randomDelayMs(
effectiveSettings.storeWatchInitialDelayMinSeconds, effectiveSettings.storeWatchInitialDelayMinSeconds,
effectiveSettings.storeWatchInitialDelayMaxSeconds effectiveSettings.storeWatchInitialDelayMaxSeconds
) )
); );
sessionStore.attachJob(sessionId, {
stop: () => {
cronJob.stop();
if (initialTimeout) {
clearTimeout(initialTimeout);
initialTimeout = null;
}
}
});
console.log( console.log(
`[WATCH] Überwache ${watchers.length} Betriebe für Session ${sessionId} (Cron: ${cronExpression}).` `[WATCH] Überwache ${watchers.length} Betriebe für Session ${sessionId} (Cron: ${cronExpression}).`
); );

View File

@@ -48,6 +48,15 @@ class SessionStore {
return session; return session;
} }
getByProfile(profileId) {
const key = profileId ? String(profileId) : null;
if (!key) {
return null;
}
const id = this.profileIndex.get(key);
return id ? this.get(id) : null;
}
update(id, patch) { update(id, patch) {
const session = this.get(id); const session = this.get(id);
if (!session) { if (!session) {