worker.php 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. <?php
  2. /**
  3. * Background archive worker.
  4. *
  5. * Shared hosting has no dependable cron, so gallery archives are rebuilt by a
  6. * chain of short requests instead: a page render calls archive_kick(), which
  7. * fires one request at this script and hangs up; this script does a slice of
  8. * work and then dispatches its own successor. One guest upload therefore starts
  9. * a chain that runs to completion with no further visitors.
  10. *
  11. * Each request does exactly one of two things, never both, so it cannot
  12. * approach max_execution_time:
  13. *
  14. * - a gallery is due → run one slice (archive.step_seconds), then dispatch
  15. * - only unsettled → sleep out the rest of the settle window (capped at
  16. * entries remain 30 s), then dispatch
  17. *
  18. * The site-wide lock is held across the whole body, including the sleep. That
  19. * is what keeps the chain single: a worker that cannot take the lock exits
  20. * without dispatching, because the worker that holds it will dispatch the next
  21. * one itself. The chain also stops as soon as the queue is empty, so it can
  22. * never become a perpetual heartbeat, and archive.max_chain bounds it even if
  23. * something goes wrong.
  24. *
  25. * Authenticated by the key in data/worker-key.json: the caller is this server
  26. * making an HTTP request to itself, so there is no admin session to check. A
  27. * wrong or missing key is indistinguishable from the script not existing.
  28. *
  29. * If the host offers real cron, calling this URL every few minutes works just as
  30. * well and needs no code change (see docs/SETUP.md).
  31. */
  32. require __DIR__ . '/app/bootstrap.php';
  33. if (!hash_equals(archive_worker_key(), (string)($_GET['key'] ?? ''))) {
  34. http_response_code(404);
  35. exit;
  36. }
  37. // The dispatcher hung up after a fraction of a second. Without this, PHP would
  38. // kill this process the moment it noticed the disconnect.
  39. ignore_user_abort(true);
  40. @set_time_limit(0); // honoured on some hosts; the design never relies on it.
  41. // Nothing is ever read from the response — the caller is not listening.
  42. http_response_code(204);
  43. $lock = archive_lock();
  44. if ($lock === null) {
  45. exit; // another worker owns the chain and will dispatch its successor
  46. }
  47. $chain = json_update(archive_queue_file(), function (array $queue): array {
  48. $queue['chain'] = (int)($queue['chain'] ?? 0) + 1;
  49. return $queue;
  50. });
  51. if ((int)($chain['chain'] ?? 0) > (int)config('archive.max_chain', 500)) {
  52. exit; // runaway guard; the next page view starts a fresh chain
  53. }
  54. $wait = archive_queue_wait();
  55. if ($wait === null) {
  56. // Queue empty: the chain ends here, and its counter resets with it.
  57. json_update(archive_queue_file(), function (array $queue): array {
  58. $queue['chain'] = 0;
  59. return $queue;
  60. });
  61. exit;
  62. }
  63. if ($wait > 0) {
  64. // Nothing has settled yet. Waiting here rather than exiting is what lets a
  65. // gallery nobody is looking at still rebuild on its own.
  66. //
  67. // Kept short: this holds both a PHP process and the worker lock, and an
  68. // admin clicking "Rebuild now" has to wait it out. A settle window is
  69. // bridged by a chain of these short waits instead of one long one.
  70. sleep(min($wait, 15));
  71. } else {
  72. $slug = archive_next_due();
  73. if ($slug !== null) {
  74. archive_run_slice($slug);
  75. }
  76. }
  77. // Release before handing off, so the successor can start immediately.
  78. flock($lock, LOCK_UN);
  79. fclose($lock);
  80. if (archive_queue_wait() !== null) {
  81. archive_dispatch(2000);
  82. }