| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193 |
- <?php
- /**
- * The backup schedule, for hosting without cron.
- *
- * The manage client in manage-client/ can do everything from the command line,
- * but this application's typical host offers no dependable cron — so the two
- * recurring jobs are driven by the web instead, the same way gallery archives
- * are (see app/archive.php):
- *
- * - **backup**, when the newest one is older than
- * MANAGE_BACKUP_AUTO_INTERVAL_SECONDS (a week by default),
- * - **heartbeat**, hourly, so the manage server can tell a silent
- * installation from a healthy one.
- *
- * An admin page load past the interval calls manage_kick(), which hands the
- * work to manage-worker.php in the background and returns immediately. The
- * backoffice is the trigger rather than the public site because these jobs
- * exist for the operator, and an installation nobody administers is one nobody
- * needs a fresh backup of.
- *
- * Deliberately not scheduled here:
- *
- * - **Updates.** Never automatic; they overwrite files under a live site and
- * have no rollback. Admin -> Maintenance, on purpose.
- * - **The update check.** It is a request to the manage server, and doing it
- * per page load would put a network round trip in front of the backoffice.
- * The Maintenance page checks when it is opened, and the heartbeat response
- * carries the same information as a side effect.
- *
- * Where real cron does exist, it calls manage-worker.php (or the CLI) on a
- * timer and this all still holds — the jobs are the same code either way.
- */
- declare(strict_types=1);
- /** Whether the manage client is installed and configured on this instance. */
- function manage_available(): bool
- {
- return is_file(APP_ROOT . '/manage-client/config.php')
- && is_file(APP_ROOT . '/manage-client/lib/client.php');
- }
- /** Load the client library. Safe to call repeatedly. */
- function manage_load(): void
- {
- require_once APP_ROOT . '/manage-client/lib/client.php';
- }
- /**
- * Marks the last time a kick was dispatched. Its mtime is the only thing read,
- * so an admin page costs one stat() when nothing is due.
- */
- function manage_tick_file(): string
- {
- return DATA_DIR . '/manage.tick';
- }
- function manage_state_file(): string
- {
- return DATA_DIR . '/manage/web-schedule.json';
- }
- /** Seconds between heartbeats. */
- function manage_heartbeat_interval(): int
- {
- return (int)config('manage.heartbeat_interval', 3600);
- }
- /**
- * Which jobs are due right now: 'backup', 'heartbeat', or neither.
- *
- * The backup side asks the client rather than deciding itself — the interval
- * lives in manage-client/config.php, and manageBackupCreateAutomaticIfDue()
- * measures it against the same backup index the Maintenance page shows.
- */
- function manage_due(): array
- {
- if (!manage_available()) {
- return [];
- }
- manage_load();
- $due = [];
- if ((int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS > 0) {
- $last = 0;
- foreach (manageBackupList() as $backup) {
- // Only backups this schedule made count towards it: a manual one
- // from the Maintenance page should not postpone the routine.
- if (in_array($backup['trigger'] ?? '', ['automatic', 'cron'], true)) {
- $last = max($last, strtotime((string)($backup['created_at'] ?? '')) ?: 0);
- }
- }
- if (time() - $last >= (int)MANAGE_BACKUP_AUTO_INTERVAL_SECONDS) {
- $due[] = 'backup';
- }
- }
- $state = json_read(manage_state_file());
- if (time() - (int)($state['heartbeat_at'] ?? 0) >= manage_heartbeat_interval()) {
- $due[] = 'heartbeat';
- }
- return $due;
- }
- /**
- * Run whatever is due. Returns a line per job for the log; never throws, because
- * every caller is a page that has something better to do than fail over this.
- */
- function manage_run_due(): array
- {
- if (!manage_available()) {
- return [];
- }
- manage_load();
- $done = [];
- $due = manage_due();
- if (in_array('backup', $due, true)) {
- try {
- // Returns null if another look at the clock says it is not due
- // after all — two workers racing, or a backup made in between.
- $record = manageBackupCreateAutomaticIfDue();
- if ($record !== null) {
- $done[] = 'backup ' . $record['filename'];
- }
- } catch (Throwable $e) {
- manageClientLog('ERROR', 'Scheduled backup failed', ['error' => $e->getMessage()]);
- $done[] = 'backup failed: ' . $e->getMessage();
- }
- }
- if (in_array('heartbeat', $due, true)) {
- // Records the attempt either way: an unreachable server must not turn
- // into a heartbeat on every single page load.
- json_update(manage_state_file(), function (array $state): array {
- $state['heartbeat_at'] = time();
- return $state;
- });
- $done[] = manageHeartbeatSendQuietly() === null ? 'heartbeat failed' : 'heartbeat';
- }
- return $done;
- }
- /** URL of manage-worker.php on this installation. */
- function manage_worker_url(): ?string
- {
- return self_url('/manage-worker.php?key=' . rawurlencode(archive_worker_key()));
- }
- /**
- * Called at the end of every admin page. Cheap when nothing is due: one stat()
- * on the tick file, and the client library is not even loaded.
- *
- * The dispatch-then-fall-back-to-inline shape is archive_kick()'s, for the same
- * reason — a host that blocks outbound HTTP to itself would otherwise never run
- * these jobs at all. Inline only happens after the page has been flushed to the
- * operator, so a weekly backup of a few hundred megabytes is never something
- * they sit and watch.
- */
- function manage_kick(): void
- {
- $tick = manage_tick_file();
- // Long enough that a click-happy session costs nothing, short enough that
- // a backup which failed to start is retried within the same visit.
- if (is_file($tick) && time() - (int)filemtime($tick) < 900) {
- return;
- }
- if (!manage_available() || manage_due() === []) {
- return;
- }
- @touch($tick);
- if (!function_exists('fastcgi_finish_request')) {
- self_dispatch(manage_worker_url(), 200);
- return;
- }
- @fastcgi_finish_request();
- if (self_dispatch(manage_worker_url(), 2000)) {
- return;
- }
- // No self-dispatch on this host. The operator already has the page, so this
- // process does the work itself.
- if (session_status() === PHP_SESSION_ACTIVE) {
- session_write_close();
- }
- @set_time_limit(0);
- manage_run_due();
- }
|