| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709 |
- <?php
- /**
- * Gallery ZIP archives: building them, and keeping them up to date.
- *
- * Why it works this way
- * --------------------
- * A gallery can hold hundreds of 8 MB originals, and the host caps
- * max_execution_time at 60 s. Streaming a multi-gigabyte ZIP through PHP would
- * need a request that stays alive for the whole download, so instead the archive
- * is *built once into S3* and visitors are redirected to a presigned URL for it.
- * The download then never touches the webhost at all — it is a plain S3 GET,
- * resumable and immune to any server timeout.
- *
- * Building it is split into slices. archive_run_slice() copies as many photos as
- * fit in a time budget and returns; progress is committed to a state file after
- * every photo, so "build the archive" is just "run slices until finished". No
- * single request ever approaches the 60 s cap, and nothing depends on
- * set_time_limit() being allowed.
- *
- * Each photo is streamed S3 → buffer file → S3: the ZIP needs a local header
- * immediately before the file's bytes, and multipart parts are atomic, so the
- * bytes cannot be copied server-side with UploadPartCopy. The buffer file
- * accumulates until it passes the 5 MB multipart minimum, then becomes one part.
- *
- * Interruptions
- * -------------
- * State is written with json_write() (tmp file + rename), so it is never half
- * written — at worst it is one slice old and that slice replays. The rest is
- * handled by ordering:
- *
- * - mid photo → every slice starts by truncating the buffer back to the last
- * committed length, so a partial tail is dropped without
- * needing the failure to have been caught.
- * - mid part → the ETag is committed to state only after S3 accepts the
- * part, and the buffer is truncated only after that. A crash
- * anywhere in between re-uploads the same part number, which
- * S3 accepts until the upload is completed.
- * - mid finish → a retried CompleteMultipartUpload returns NoSuchUpload once
- * it has already succeeded; that is treated as done after a
- * HEAD confirms the object exists.
- * - abandoned → the queue entry survives on disk, and a build with no
- * progress for archive.abandon_hours is aborted and restarted.
- *
- * Staying current
- * ---------------
- * Every stored or deleted image marks its gallery dirty (archive_mark_dirty).
- * A dirty gallery's download button is disabled until the rebuild lands, rather
- * than handing out a ZIP that is missing the newest photos. Rebuilds run in the
- * background: archive_kick() on a page render dispatches worker.php, which runs
- * a slice and then dispatches its own successor, so one upload starts a chain
- * that finishes unattended. See docs/ARCHITECTURE.md.
- */
- declare(strict_types=1);
- /** Queue of galleries whose archive no longer matches their contents. */
- function archive_queue_file(): string
- {
- return DATA_DIR . '/archive-queue.json';
- }
- /** Site-wide lock: exactly one archive worker runs at a time. */
- function archive_lock_file(): string
- {
- return DATA_DIR . '/archive.lock';
- }
- /** Throttle marker so a busy site does not dispatch a worker per page view. */
- function archive_tick_file(): string
- {
- return DATA_DIR . '/archive.tick';
- }
- /**
- * Identity of a gallery's image set. A build records the hash it was made from;
- * when the gallery's current hash differs, the archive is out of date.
- */
- function archive_source_hash(array $gallery): string
- {
- return sha1(implode("\n", array_column($gallery['images'] ?? [], 'key')));
- }
- /** Whether a gallery's archive is missing or no longer matches its images. */
- function archive_is_stale(array $gallery): bool
- {
- $archive = $gallery['archive'] ?? null;
- if (empty($archive['key'])) {
- return true;
- }
- return ($archive['source_hash'] ?? '') !== archive_source_hash($gallery);
- }
- // ---------------------------------------------------------------------------
- // The queue
- // ---------------------------------------------------------------------------
- /**
- * Flag a gallery for rebuilding. Called from gallery_append_image() and from
- * image deletion, i.e. everywhere a gallery's contents can change.
- *
- * Uploads run in parallel (uploads.concurrency), so the queue is written through
- * json_update()'s exclusive lock — three uploads finishing together must not
- * drop each other's entries. Galleries without downloads enabled are skipped:
- * queueing them would give the worker chain work it can never finish.
- */
- function archive_mark_dirty(string $slug, ?array $gallery = null): void
- {
- $gallery ??= gallery_load($slug);
- if ($gallery === null || empty($gallery['downloads_enabled'])) {
- return;
- }
- json_update(archive_queue_file(), function (array $queue) use ($slug): array {
- $queue['galleries'][$slug] = time();
- return $queue;
- });
- }
- /** Drop a gallery from the queue (rebuilt, deleted, or downloads turned off). */
- function archive_unqueue(string $slug): void
- {
- json_update(archive_queue_file(), function (array $queue) use ($slug): ?array {
- if (!isset($queue['galleries'][$slug])) {
- return null; // nothing to do; leave the file untouched
- }
- unset($queue['galleries'][$slug]);
- return $queue;
- });
- }
- /**
- * The next gallery due for a rebuild, or null if none has settled yet.
- *
- * A gallery is only rebuilt once it has been quiet for archive.settle_seconds.
- * Thirty wedding guests uploading over an hour must not restart the build thirty
- * times; the delay batches an upload burst into a single rebuild.
- */
- function archive_next_due(): ?string
- {
- $queue = json_read(archive_queue_file());
- $settle = (int)config('archive.settle_seconds', 300);
- foreach ($queue['galleries'] ?? [] as $slug => $dirtyAt) {
- if (time() - (int)$dirtyAt >= $settle) {
- return (string)$slug;
- }
- }
- return null;
- }
- /**
- * Seconds until the earliest queued gallery settles: 0 if one is due now, null
- * if the queue is empty. The worker uses this to decide between doing a slice
- * and waiting out the settle window.
- */
- function archive_queue_wait(): ?int
- {
- $queue = json_read(archive_queue_file());
- $settle = (int)config('archive.settle_seconds', 300);
- $wait = null;
- foreach ($queue['galleries'] ?? [] as $dirtyAt) {
- $due = max(0, $settle - (time() - (int)$dirtyAt));
- $wait = $wait === null ? $due : min($wait, $due);
- }
- return $wait;
- }
- // ---------------------------------------------------------------------------
- // Build state
- // ---------------------------------------------------------------------------
- /**
- * Start a fresh build: abandon anything in flight, open a new multipart upload,
- * and write the initial state. Returns the state, or null if it cannot start.
- *
- * Content-Type and Content-Disposition are set on the multipart create, so S3
- * stores them as the finished object's metadata and the presigned URL downloads
- * as a properly named .zip with no extra signing work.
- */
- function archive_start(string $slug, array $gallery): ?array
- {
- if (empty($gallery['images'])) {
- return null; // nothing to archive
- }
- archive_abort($slug);
- $filename = safe_filename(($gallery['title'] ?: $slug) . '.zip', $slug);
- $key = s3_gallery_prefix($slug) . '/archive/' . random_token(6) . '-' . $filename;
- $uploadId = s3_mpu_create($key, 'application/zip', 'attachment; filename="' . $filename . '"');
- if ($uploadId === null) {
- return null;
- }
- $state = [
- 'key' => $key,
- 'upload_id' => $uploadId,
- 'source_hash' => archive_source_hash($gallery),
- 'total' => count($gallery['images']),
- 'next_index' => 0,
- // Archive length so far, and how much of it S3 already has. The
- // difference is exactly what the buffer file holds.
- 'offset' => 0,
- 'uploaded' => 0,
- 'part_number' => 1,
- 'parts' => [],
- 'entries' => [],
- 'names' => [], // filename dedupe map, carried across slices
- 'slowest' => 5.0, // seconds; grows to the slowest photo seen
- 'started_at' => time(),
- ];
- json_write(gallery_archive_file($slug), $state);
- @unlink(gallery_archive_buffer($slug));
- return $state;
- }
- /**
- * Abandon an in-flight build. Aborting the multipart upload matters: S3 keeps
- * (and bills for) the parts of an incomplete upload indefinitely.
- */
- function archive_abort(string $slug): void
- {
- $state = json_read(gallery_archive_file($slug));
- if (!empty($state['upload_id']) && !empty($state['key'])) {
- s3_mpu_abort((string)$state['key'], (string)$state['upload_id']);
- }
- @unlink(gallery_archive_file($slug));
- @unlink(gallery_archive_buffer($slug));
- }
- /** Delete a gallery's finished archive from S3 and forget it. */
- function archive_delete(string $slug): void
- {
- archive_abort($slug);
- $gallery = gallery_load($slug);
- if ($gallery !== null && !empty($gallery['archive']['key'])) {
- s3_delete((string)$gallery['archive']['key']);
- }
- json_update(gallery_file($slug), function (array $g): ?array {
- if ($g === [] || !isset($g['archive'])) {
- return null;
- }
- unset($g['archive']);
- return $g;
- });
- archive_unqueue($slug);
- }
- // ---------------------------------------------------------------------------
- // The slice
- // ---------------------------------------------------------------------------
- /**
- * Copy as many of a gallery's photos into its archive as fit in $budget seconds.
- *
- * Returns ['done' => int, 'total' => int, 'finished' => bool, 'size' => ?int,
- * 'error' => ?string]. Call again to continue; all progress is on disk.
- */
- function archive_run_slice(string $slug, ?float $budget = null): array
- {
- $started = microtime(true);
- $budget ??= (float)config('archive.step_seconds', 25);
- $gallery = gallery_load($slug);
- if ($gallery === null) {
- archive_unqueue($slug);
- return archive_result(0, 0, false, null, 'Gallery no longer exists');
- }
- $stateFile = gallery_archive_file($slug);
- $state = json_read($stateFile);
- $abandoned = $state !== []
- && time() - (int)($state['started_at'] ?? 0) > (int)config('archive.abandon_hours', 24) * 3600;
- // Restart whenever the gallery has changed under an in-flight build, or the
- // build has been stalled long enough to be considered dead.
- if ($state === [] || $abandoned || ($state['source_hash'] ?? '') !== archive_source_hash($gallery)) {
- $state = archive_start($slug, $gallery);
- if ($state === null) {
- archive_unqueue($slug);
- return archive_result(0, 0, false, null, 'Cannot start archive (empty gallery or S3 refused)');
- }
- }
- $images = $gallery['images'];
- $bufferPath = gallery_archive_buffer($slug);
- $fh = fopen($bufferPath, 'c+b');
- if ($fh === false) {
- return archive_result((int)$state['next_index'], (int)$state['total'], false, null, 'Cannot open archive buffer');
- }
- flock($fh, LOCK_EX);
- // Recovery: drop anything written past the last committed position. Doing
- // this unconditionally means a hard kill needs no cleanup of its own.
- ftruncate($fh, (int)$state['offset'] - (int)$state['uploaded']);
- fseek($fh, 0, SEEK_END);
- $partMin = (int)config('archive.part_min_bytes', 5 * 1024 * 1024);
- $mtime = strtotime((string)($gallery['created_at'] ?? '')) ?: time();
- $error = null;
- $processed = 0;
- while ($state['next_index'] < $state['total']) {
- // Check the clock before starting a photo, never during one, and leave
- // room for one that runs as long as the slowest seen so far.
- //
- // Always do at least one photo, whatever the estimate says. A gallery
- // whose photos each take longer than the whole budget must still creep
- // forward one photo per slice; refusing to start would leave the build
- // stuck for ever, which is far worse than a slice that overruns.
- if ($processed > 0 && microtime(true) - $started + $state['slowest'] * 1.5 >= $budget) {
- break;
- }
- $photoStart = microtime(true);
- $image = $images[$state['next_index']];
- // Reserve the name in a copy: a photo that fails below is retried by the
- // next slice, and a name left registered by the failed attempt would
- // make the retry rename itself to "… (2)".
- $names = $state['names'];
- $name = zip_dedupe_name(
- safe_filename((string)($image['name'] ?? 'photo.jpg'), 'photo'),
- $names
- );
- $entryOffset = (int)$state['offset'];
- $header = zip_local_header($name, $mtime);
- fwrite($fh, $header);
- // One pass: bytes go to the buffer and through the CRC at the same time,
- // so an original never has to be held in memory or read back.
- $crcContext = hash_init('crc32b');
- [$status, $bytes] = s3_get_stream((string)$image['key'], function (string $chunk) use ($fh, $crcContext): void {
- hash_update($crcContext, $chunk);
- fwrite($fh, $chunk);
- });
- if ($status < 200 || $status >= 300) {
- // Undo this photo entirely and stop; the next slice retries it.
- ftruncate($fh, $entryOffset - (int)$state['uploaded']);
- fseek($fh, 0, SEEK_END);
- $error = 'S3 returned HTTP ' . $status . ' for ' . ($image['name'] ?? $image['key']);
- break;
- }
- // The real size and CRC are only known now, so patch them into the
- // header written above. Using the streamed byte count rather than the
- // recorded one also heals a gallery whose stored size was ever wrong.
- $crc = (int)hexdec(hash_final($crcContext));
- zip_patch_local_header($fh, $entryOffset - (int)$state['uploaded'], $name, $crc, $bytes);
- $state['names'] = $names;
- $state['entries'][] = [
- 'name' => $name,
- 'crc' => $crc,
- 'size' => $bytes,
- 'offset' => $entryOffset,
- 'mtime' => $mtime,
- ];
- $state['offset'] = $entryOffset + strlen($header) + $bytes;
- $state['next_index']++;
- $state['slowest'] = max((float)$state['slowest'], microtime(true) - $photoStart);
- $processed++;
- if ((int)$state['offset'] - (int)$state['uploaded'] >= $partMin) {
- $error = archive_flush_part($state, $fh, $bufferPath, false);
- if ($error !== null) {
- break;
- }
- }
- json_write($stateFile, $state);
- }
- // Everything copied: append the central directory and close the upload.
- $finished = false;
- if ($error === null && $state['next_index'] >= $state['total']) {
- [$finished, $error] = archive_finish($slug, $state, $fh, $bufferPath);
- }
- json_write($stateFile, $state);
- flock($fh, LOCK_UN);
- fclose($fh);
- if ($finished) {
- @unlink($stateFile);
- @unlink($bufferPath);
- }
- return archive_result(
- (int)$state['next_index'],
- (int)$state['total'],
- $finished,
- $finished ? (int)$state['offset'] : null,
- $error
- );
- }
- /**
- * Send the buffered bytes to S3 as the next multipart part.
- *
- * The order here is what makes an interrupted build safe: S3 accepts the part,
- * then the ETag is committed to state, and only then is the buffer cleared. A
- * crash before the commit re-uploads the same part number with the same bytes,
- * which S3 allows until the upload is completed.
- *
- * Returns an error message, or null on success.
- *
- * @param resource $fh
- */
- function archive_flush_part(array &$state, $fh, string $bufferPath, bool $isLast): ?string
- {
- fflush($fh);
- if (!$isLast && (int)$state['offset'] - (int)$state['uploaded'] === 0) {
- return null; // nothing pending
- }
- $etag = s3_mpu_upload_part(
- (string)$state['key'],
- (string)$state['upload_id'],
- (int)$state['part_number'],
- $bufferPath
- );
- if ($etag === null) {
- return 'S3 rejected part ' . $state['part_number'];
- }
- $state['parts'][] = ['n' => (int)$state['part_number'], 'etag' => $etag];
- $state['part_number']++;
- $state['uploaded'] = (int)$state['offset'];
- ftruncate($fh, 0);
- fseek($fh, 0, SEEK_END);
- return null;
- }
- /**
- * Write the central directory, upload the last part and complete the multipart
- * upload, then record the archive on the gallery.
- *
- * Returns [finished, error].
- *
- * @param resource $fh
- */
- function archive_finish(string $slug, array &$state, $fh, string $bufferPath): array
- {
- // The central directory is built now, from the real sizes and offsets, so
- // it uses ZIP64 fields only where a value actually overflows 32 bits.
- $directory = '';
- foreach ($state['entries'] as $entry) {
- $directory .= zip_central_entry($entry);
- }
- $trailer = $directory . zip_end_of_central_directory(
- count($state['entries']),
- strlen($directory),
- (int)$state['offset']
- );
- fwrite($fh, $trailer);
- $state['offset'] = (int)$state['offset'] + strlen($trailer);
- // The 5 MB minimum does not apply to the final part, which is what lets a
- // gallery of small files work with the same buffering scheme.
- $error = archive_flush_part($state, $fh, $bufferPath, true);
- if ($error !== null) {
- return [false, $error];
- }
- [$ok, $body] = s3_mpu_complete((string)$state['key'], (string)$state['upload_id'], $state['parts']);
- if (!$ok) {
- // A completed upload no longer exists; if the object is there, an
- // earlier attempt succeeded and only the state write was lost.
- if (str_contains($body, 'NoSuchUpload') && s3_head((string)$state['key']) !== null) {
- $ok = true;
- }
- }
- if (!$ok) {
- return [false, 'S3 could not complete the archive upload'];
- }
- $summary = [
- 'key' => (string)$state['key'],
- 'size' => (int)$state['offset'],
- 'count' => count($state['entries']),
- 'built_at' => date('Y-m-d H:i:s'),
- 'source_hash' => (string)$state['source_hash'],
- ];
- // json_update, not gallery_save: an upload finishing right now must not be
- // overwritten by a gallery this function read minutes ago.
- $previousKey = null;
- json_update(gallery_file($slug), function (array $g) use ($summary, &$previousKey): ?array {
- if ($g === []) {
- return null; // deleted mid-build
- }
- $previousKey = $g['archive']['key'] ?? null;
- $g['archive'] = $summary;
- return $g;
- });
- if ($previousKey !== null && $previousKey !== $summary['key']) {
- s3_delete((string)$previousKey);
- }
- // Leave the gallery queued if it changed while this build was running — the
- // archive just written is already out of date and needs another pass.
- $current = gallery_load($slug);
- if ($current === null || !archive_is_stale($current)) {
- archive_unqueue($slug);
- }
- return [true, null];
- }
- /** Uniform slice/step result shape, shared by the worker and the admin API. */
- function archive_result(int $done, int $total, bool $finished, ?int $size, ?string $error): array
- {
- return [
- 'done' => $done,
- 'total' => $total,
- 'finished' => $finished,
- 'size' => $size,
- 'error' => $error,
- ];
- }
- /**
- * What the admin page shows for a gallery: whether an archive exists, whether it
- * is current, and how far any in-flight build has got.
- */
- function archive_status(array $gallery): array
- {
- $slug = (string)$gallery['slug'];
- $archive = $gallery['archive'] ?? null;
- $state = json_read(gallery_archive_file($slug));
- $queue = json_read(archive_queue_file());
- return [
- 'archive' => $archive,
- 'stale' => archive_is_stale($gallery),
- 'building' => $state !== [],
- 'done' => (int)($state['next_index'] ?? 0),
- 'total' => (int)($state['total'] ?? count($gallery['images'] ?? [])),
- 'queued' => isset($queue['galleries'][$slug]),
- 'due_in' => isset($queue['galleries'][$slug])
- ? max(0, (int)config('archive.settle_seconds', 300) - (time() - (int)$queue['galleries'][$slug]))
- : null,
- ];
- }
- // ---------------------------------------------------------------------------
- // Background execution
- // ---------------------------------------------------------------------------
- /** Shared secret authenticating the self-dispatched worker requests. */
- function archive_worker_key(): string
- {
- $file = DATA_DIR . '/worker-key.json';
- $data = json_read($file);
- if (empty($data['key'])) {
- $data = ['key' => random_token(32)];
- json_write($file, $data);
- }
- return (string)$data['key'];
- }
- /**
- * URL of worker.php on this installation.
- *
- * site.base_url is preferred when it has been filled in, because it does not
- * depend on the request's Host header. Otherwise the URL is derived from the
- * current request, mapping APP_ROOT against DOCUMENT_ROOT so an app installed in
- * a subdirectory still resolves.
- */
- function archive_worker_url(): ?string
- {
- $query = '/worker.php?key=' . rawurlencode(archive_worker_key());
- $configured = rtrim((string)config('site.base_url', ''), '/');
- if ($configured !== '' && !str_contains($configured, 'example.com')) {
- return $configured . $query;
- }
- $host = (string)($_SERVER['HTTP_HOST'] ?? '');
- $root = rtrim(str_replace('\\', '/', (string)($_SERVER['DOCUMENT_ROOT'] ?? '')), '/');
- $app = str_replace('\\', '/', APP_ROOT);
- if ($host === '' || $root === '' || !str_starts_with($app, $root)) {
- return null;
- }
- $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
- return $scheme . '://' . $host . rtrim(substr($app, strlen($root)), '/') . $query;
- }
- /**
- * Fire a request at worker.php and hang up without waiting for it. The worker
- * sets ignore_user_abort(), so it runs its slice regardless.
- *
- * A timeout is the expected, successful outcome here — it means the request was
- * delivered and the worker is busy with it. A *completed* response is only a
- * success if it is the worker's own 204; anything else (a 404 from a wrong key
- * or a misconfigured base URL) means nothing is running, and saying so lets the
- * caller fall back to doing the work inline instead of silently stalling.
- */
- function archive_dispatch(int $timeoutMs = 1000): bool
- {
- $url = archive_worker_url();
- if ($url === null || !function_exists('curl_init')) {
- return false;
- }
- $ch = curl_init($url);
- curl_setopt_array($ch, [
- CURLOPT_RETURNTRANSFER => true,
- CURLOPT_NOSIGNAL => true,
- CURLOPT_CONNECTTIMEOUT_MS => $timeoutMs,
- CURLOPT_TIMEOUT_MS => $timeoutMs,
- ]);
- curl_exec($ch);
- $errno = curl_errno($ch);
- $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
- return $errno === CURLE_OPERATION_TIMEOUTED || ($errno === 0 && $status === 204);
- }
- /**
- * Take the site-wide worker lock, or report that someone else holds it.
- * Returns the lock handle (which must stay open for the lock to hold) or null.
- *
- * $waitSeconds polls rather than blocking outright, so an admin asking for a
- * rebuild can wait out a background worker's short settle-sleep instead of
- * failing the moment it finds the lock taken — while a background worker, which
- * has nothing to wait for, passes 0 and steps aside immediately.
- *
- * @return resource|null
- */
- function archive_lock(int $waitSeconds = 0)
- {
- $fh = fopen(archive_lock_file(), 'c');
- if ($fh === false) {
- return null;
- }
- $deadline = time() + $waitSeconds;
- do {
- if (flock($fh, LOCK_EX | LOCK_NB)) {
- return $fh;
- }
- if (time() < $deadline) {
- sleep(1);
- }
- } while (time() < $deadline);
- fclose($fh);
- return null;
- }
- /**
- * Run one slice for whichever gallery is due, under the worker lock.
- * Returns the slice result, or null if nothing was due or another worker holds
- * the lock.
- */
- function archive_run_due(): ?array
- {
- $lock = archive_lock();
- if ($lock === null) {
- return null;
- }
- try {
- $slug = archive_next_due();
- return $slug === null ? null : archive_run_slice($slug);
- } finally {
- flock($lock, LOCK_UN);
- fclose($lock);
- }
- }
- /**
- * Called at the end of every public page render. Decides, as cheaply as
- * possible, whether any background work is pending and gets it moving.
- *
- * The page is flushed to the visitor before anything slow happens, so neither
- * the dispatch nor the inline fallback can delay it. The fallback matters on
- * hosts that cannot make an HTTP request to themselves: there, progress needs
- * one page view per slice instead of running on its own.
- */
- function archive_kick(): void
- {
- $tick = archive_tick_file();
- if (is_file($tick) && time() - (int)filemtime($tick) < 30) {
- return; // dispatched recently; do not spend anything on this request
- }
- $queue = json_read(archive_queue_file());
- if (empty($queue['galleries'])) {
- return;
- }
- @touch($tick);
- if (!function_exists('fastcgi_finish_request')) {
- // Cannot detach: keep the delay to the visitor as short as possible.
- archive_dispatch(200);
- return;
- }
- @fastcgi_finish_request();
- if (archive_dispatch(2000)) {
- return;
- }
- // No self-dispatch on this host. The visitor already has the page, so this
- // process can do the work itself.
- if (session_status() === PHP_SESSION_ACTIVE) {
- session_write_close();
- }
- archive_run_due();
- }
|