_last_run") gegen sein empfohlenes Intervall und führt * ihn bei Bedarf aus – über dieselbe run_*()-Funktion wie der "Jetzt ausführen"- * Button und der echte Cronjob. * * Nebenläufigkeitsschutz: Ein Job wird nur ausgeführt, wenn das UPDATE des * last_run-Settings tatsächlich eine Zeile ändert (atomares "Compare-and-Swap"). * Laufen zwei Anfragen gleichzeitig, "gewinnt" nur eine davon den Job. * * Kann in Einstellungen → Cron-Jobs deaktiviert werden (Setting "pseudo_cron_enabled"), * falls echte Server-Cronjobs eingerichtet sind und der eingebaute Ersatz nicht * gebraucht wird. */ /** Empfohlene Mindestabstände in Sekunden, angelehnt an die Crontab-Beispiele. */ function pseudo_cron_job_definitions(): array { return [ 'fetch_emails' => ['setting' => 'cron_fetch_emails_last_run', 'interval' => 300, 'file' => 'fetch_emails.php', 'fn' => 'run_fetch_emails'], 'check_sla' => ['setting' => 'cron_check_sla_last_run', 'interval' => 900, 'file' => 'check_sla.php', 'fn' => 'run_check_sla'], 'check_escalations' => ['setting' => 'cron_check_escalations_last_run', 'interval' => 3600, 'file' => 'check_escalations.php', 'fn' => 'run_check_escalations'], 'process_backups' => ['setting' => 'cron_process_backups_last_run', 'interval' => 3600, 'file' => 'process_backups.php', 'fn' => 'run_process_backups'], 'process_recurring' => ['setting' => 'cron_process_recurring_last_run', 'interval' => 86400, 'file' => 'process_recurring.php', 'fn' => 'run_process_recurring'], 'anonymize_data' => ['setting' => 'cron_anonymize_data_last_run', 'interval' => 86400, 'file' => 'anonymize_data.php', 'fn' => 'run_anonymization'], 'auto_close_tickets'=> ['setting' => 'cron_auto_close_tickets_last_run','interval' => 86400, 'file' => 'auto_close_tickets.php','fn' => 'run_auto_close_tickets'], 'process_snoozes' => ['setting' => 'cron_process_snoozes_last_run', 'interval' => 120, 'file' => 'process_snoozes.php', 'fn' => 'run_process_snoozes'], 'process_mail_queue' => ['setting' => 'cron_process_mail_queue_last_run', 'interval' => 120, 'file' => 'process_mail_queue.php', 'fn' => 'run_process_mail_queue'], 'remind_customers' => ['setting' => 'cron_remind_customers_last_run', 'interval' => 3600, 'file' => 'remind_customers.php', 'fn' => 'run_remind_customers'], 'send_csat' => ['setting' => 'cron_send_csat_last_run', 'interval' => 300, 'file' => 'send_csat.php', 'fn' => 'run_send_csat'], 'status_checks' => ['setting' => 'cron_status_checks_last_run', 'interval' => 60, 'file' => 'status_checks.php', 'fn' => 'run_status_checks'], 'send_scheduled_replies' => ['setting' => 'cron_send_scheduled_replies_last_run', 'interval' => 60, 'file' => 'send_scheduled_replies.php', 'fn' => 'run_send_scheduled_replies'], 'send_report_schedules' => ['setting' => 'cron_send_report_schedules_last_run', 'interval' => 300, 'file' => 'send_report_schedules.php', 'fn' => 'run_send_report_schedules'], 'run_plugins' => ['setting' => 'cron_run_plugins_last_run', 'interval' => 60, 'file' => 'run_plugins.php', 'fn' => 'run_plugins_cron'], ]; } /** * Versucht, die "Ausführungs-Berechtigung" für ein Setting atomar zu übernehmen: * Nur wenn der gespeicherte Zeitstempel älter als das Intervall ist, wird er auf * "jetzt" gesetzt und true zurückgegeben. Verhindert doppelte Ausführung bei * gleichzeitigen Anfragen (kein SELECT-dann-UPDATE, sondern ein atomares UPDATE). */ function pseudo_cron_try_claim(PDO $pdo, string $settingKey, int $intervalSeconds): bool { $driver = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); // Zeile anlegen falls noch nie vorhanden (fernes Datum = sofort fällig). if ($driver === 'sqlite') { $pdo->prepare("INSERT OR IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')") ->execute([$settingKey]); } else { $pdo->prepare("INSERT IGNORE INTO settings (setting_key, setting_value) VALUES (?, '1970-01-01 00:00:00')") ->execute([$settingKey]); } $now = date('Y-m-d H:i:s'); $threshold = date('Y-m-d H:i:s', time() - $intervalSeconds); $stmt = $pdo->prepare('UPDATE settings SET setting_value = ? WHERE setting_key = ? AND setting_value < ?'); $stmt->execute([$now, $settingKey, $threshold]); return $stmt->rowCount() > 0; } /** Prüft alle Jobs und führt fällige direkt aus. Läuft NACH dem Senden der Antwort. */ function pseudo_cron_dispatch(): void { try { $pdo = get_db(); if (get_setting('pseudo_cron_enabled', '1') !== '1') return; // Grobes Gate: höchstens alle 20 Sekunden eine echte Prüfung, um bei viel // Traffic nicht bei jeder einzelnen Anfrage 6 zusätzliche Abfragen zu machen. if (!pseudo_cron_try_claim($pdo, 'pseudo_cron_gate', 20)) return; if (!defined('DESKHUB_CRON_RUNNER')) define('DESKHUB_CRON_RUNNER', true); foreach (pseudo_cron_job_definitions() as $key => $job) { if (!pseudo_cron_try_claim($pdo, $job['setting'], $job['interval'])) continue; try { require_once __DIR__ . '/../cron/' . $job['file']; $fn = $job['fn']; $fn($pdo, false); } catch (Throwable $e) { error_log("pseudo_cron [$key]: " . $e->getMessage()); if (function_exists('error_alert_notice')) error_alert_notice("Hintergrundjob „{$key}“ ist fehlgeschlagen", "Der Hintergrundjob „{$key}“ hat einen Fehler gemeldet und wurde abgebrochen. Beim nächsten Intervall wird er erneut versucht.", 'cron/' . $job['file'], $e->getMessage() . ' (' . basename($e->getFile()) . ':' . $e->getLine() . ')'); } } // Plugin- und Theme-Katalog im Hintergrund aktuell halten (alle 5 Minuten) if (pseudo_cron_try_claim($pdo, 'catalog_refresh_last_run', 280)) { catalog_refresh_background(); } // Backups automatisch prüfen: neue/ungeprüfte sofort, geprüfte nach 7 Tagen erneut (höchstens alle 10 Minuten, 2 je Lauf) if (pseudo_cron_try_claim($pdo, 'cron_verify_backups_last_run', 600)) { try { if (!function_exists('backup_verify_pending')) require_once __DIR__ . '/backup_restore.php'; backup_verify_pending(2); } catch (Throwable $e) { error_log('pseudo_cron [verify_backups]: ' . $e->getMessage()); } } // Verwaiste Zeiterfassungs-Sitzungen aufräumen (includes/time_tracking.php, // lebt bereits in bootstrap.php – kein require_once aus cron/ nötig). if (pseudo_cron_try_claim($pdo, 'time_tracking_sweep_last_run', 300)) { try { finalize_stale_work_sessions($pdo, false); } catch (Throwable $e) { error_log('pseudo_cron [time_tracking_sweep]: ' . $e->getMessage()); } } } catch (Throwable $e) { error_log('pseudo_cron_dispatch: ' . $e->getMessage()); } } /** * Registriert die Hintergrundprüfung für das Ende des aktuellen Requests. * Von require_staff_login() (Web) und api_require_staff() (mobile App) aufgerufen – * läuft also bei jeder authentifizierten Mitarbeiter-Anfrage, ganz ohne dass * jemand manuell etwas klicken oder ein externer Dienst konfiguriert werden muss. */ function pseudo_cron_schedule_background(): void { static $scheduled = false; if ($scheduled) return; // pro Request nur einmal registrieren $scheduled = true; if (php_sapi_name() === 'cli') return; // im echten Cron nicht nötig register_shutdown_function(function () { // Verbindung zum Client möglichst sofort beenden, damit die Prüfung // die eigentliche Seite/den API-Aufruf nicht spürbar verlangsamt. if (function_exists('fastcgi_finish_request')) { @fastcgi_finish_request(); } else { if (function_exists('ob_get_level')) { while (ob_get_level() > 0) { @ob_end_flush(); } } @flush(); } // Sitzungssperre freigeben: Sonst wartet die nächste Seite desselben Mitarbeiters, bis der Hintergrundjob (Backup, Mail-Abruf …) fertig ist if (function_exists('session_status') && session_status() === PHP_SESSION_ACTIVE) { @session_write_close(); } @ignore_user_abort(true); pseudo_cron_dispatch(); }); }