cron.php 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  1. <?php
  2. /**
  3. * One cron line for every background job, for hosts that do have cron.
  4. *
  5. * Why this exists next to the web-driven flow
  6. * ------------------------------------------
  7. * The application assumes no cron and drives itself: a public page render calls
  8. * archive_kick() and a chain of worker.php requests builds the gallery archives
  9. * (see app/archive.php); an admin page render calls manage_kick() and
  10. * manage-worker.php makes the scheduled backup and sends the heartbeat (see
  11. * app/manage.php). That works, but it moves one slice or one job per firing and
  12. * only while somebody is looking at the site.
  13. *
  14. * Where real cron exists it can do better, and cron.php is the single entry
  15. * point for it: one invocation drains *everything* outstanding — every settled
  16. * gallery, not just the first, plus whatever the schedule owes — bounded only by
  17. * cron.max_seconds.
  18. *
  19. * Running alongside the web flow, not instead of it
  20. * ------------------------------------------------
  21. * Nothing here switches the kicks off. Both drivers stay live and may run at the
  22. * same moment, because the locks they already share decide who does what:
  23. *
  24. * - data/archive.lock — exactly one process works on an archive. Taken and
  25. * released again *per slice* rather than held across the drain, so a cron
  26. * busy for four minutes never starves the worker chain and never makes an
  27. * admin's "Rebuild now" (which waits only archive_lock(15)) fail.
  28. * - data/manage.lock — one scheduled backup at a time, the same lock
  29. * manage-worker.php takes. Held elsewhere means somebody else is on it, so
  30. * this run leaves it alone.
  31. * - data/cron.lock — one cron invocation at a time. A gallery that takes
  32. * longer to archive than the cron interval must not accumulate a queue of
  33. * invocations waiting behind it.
  34. *
  35. * Off by default: cron.enabled has to be set. A copy of this site restored onto
  36. * a staging host brings the crontab with it, and it must not start rebuilding
  37. * archives and uploading backups on its own.
  38. *
  39. * What it deliberately does not do is update the software — same reason as
  40. * app/manage.php: that belongs in front of a human, at Admin -> Maintenance.
  41. */
  42. declare(strict_types=1);
  43. /** Whether cron.php is allowed to do anything. Opt-in; see the header. */
  44. function cron_enabled(): bool
  45. {
  46. return (bool)config('cron.enabled', false);
  47. }
  48. /**
  49. * Seconds of work per invocation. Floored at 30 because a budget below one
  50. * archive slice would make no progress at all on a large gallery.
  51. */
  52. function cron_max_seconds(): int
  53. {
  54. return max(30, (int)config('cron.max_seconds', 240));
  55. }
  56. /** One invocation at a time. */
  57. function cron_lock_file(): string
  58. {
  59. return DATA_DIR . '/cron.lock';
  60. }
  61. /** What the last run did, for Admin -> Maintenance -> Schedule. */
  62. function cron_state_file(): string
  63. {
  64. return DATA_DIR . '/cron.json';
  65. }
  66. /** Last run: ['last_run_at' => int, 'duration' => float, 'jobs' => [], 'remaining' => int]. */
  67. function cron_state(): array
  68. {
  69. return json_read(cron_state_file());
  70. }
  71. /**
  72. * Galleries still waiting in the archive queue, settled or not. Reported at the
  73. * end of a run so a cron that ran out of budget says so.
  74. */
  75. function cron_queued_count(): int
  76. {
  77. $queue = json_read(archive_queue_file());
  78. return count($queue['galleries'] ?? []);
  79. }
  80. /**
  81. * The next settled gallery that is not in $skip.
  82. *
  83. * archive_next_due() always returns the *first* settled entry, which is right
  84. * for a worker that only ever does one slice but would hand this loop the same
  85. * failing gallery forever. Same settle test, and deliberately a separate
  86. * function: archive_next_due() is part of worker.php's contract.
  87. *
  88. * @param array<string,bool> $skip
  89. */
  90. function cron_next_due(array $skip): ?string
  91. {
  92. $queue = json_read(archive_queue_file());
  93. $settle = (int)config('archive.settle_seconds', 300);
  94. foreach ($queue['galleries'] ?? [] as $slug => $dirtyAt) {
  95. if (isset($skip[(string)$slug])) {
  96. continue;
  97. }
  98. if (time() - (int)$dirtyAt >= $settle) {
  99. return (string)$slug;
  100. }
  101. }
  102. return null;
  103. }
  104. /**
  105. * Run the scheduled backup and heartbeat, if either is due and nobody else is
  106. * already on them. The body is manage-worker.php's, sharing its lock.
  107. *
  108. * @return string[] a line per job, for the log and the cron mail
  109. */
  110. function cron_run_manage(): array
  111. {
  112. if (!manage_available()) {
  113. return [];
  114. }
  115. $lock = fopen(DATA_DIR . '/manage.lock', 'c');
  116. if ($lock === false || !flock($lock, LOCK_EX | LOCK_NB)) {
  117. return []; // a manage_kick() from the backoffice has it; leave quietly
  118. }
  119. try {
  120. // Never throws: every job inside reports its own failure as a line.
  121. return manage_run_due();
  122. } finally {
  123. flock($lock, LOCK_UN);
  124. fclose($lock);
  125. }
  126. }
  127. /**
  128. * Build every gallery archive that is due, until the queue is drained or the
  129. * budget runs out.
  130. *
  131. * The budget is checked before each slice, never inside one: archive_run_slice()
  132. * stops *starting* photos at archive.step_seconds but still has to finish the one
  133. * in flight, so a single large original can overrun the total. A soft ceiling is
  134. * the only honest kind here, which is why cron.max_seconds defaults well below a
  135. * URL fetcher's timeout.
  136. *
  137. * @return string[] a line per finished or abandoned archive
  138. */
  139. function cron_run_archives(float $deadline): array
  140. {
  141. $lines = [];
  142. $skip = []; // slug => true, for this invocation only
  143. $progress = []; // slug => photos done when last seen, to catch a stall
  144. $step = (float)config('archive.step_seconds', 25);
  145. $worked = false;
  146. while (microtime(true) < $deadline) {
  147. $slug = cron_next_due($skip);
  148. if ($slug === null) {
  149. break; // nothing settled; the next tick will look again
  150. }
  151. // Non-blocking: a worker or an admin holding it is already doing this
  152. // work, and there is nothing useful to do meanwhile.
  153. $lock = archive_lock();
  154. if ($lock === null) {
  155. $lines[] = 'archive: another worker holds the lock';
  156. break;
  157. }
  158. try {
  159. $budget = min($step, $deadline - microtime(true));
  160. $result = archive_run_slice($slug, max(1.0, $budget));
  161. } finally {
  162. flock($lock, LOCK_UN);
  163. fclose($lock);
  164. }
  165. $worked = true;
  166. if ($result['error'] !== null) {
  167. // Left queued on purpose where the slice left it queued: the next
  168. // invocation retries, this one stops paying for it.
  169. $skip[$slug] = true;
  170. $lines[] = 'archive ' . $slug . ' failed: ' . $result['error'];
  171. continue;
  172. }
  173. if ($result['finished']) {
  174. $lines[] = 'archive ' . $slug . ' built, ' . $result['total'] . ' photos'
  175. . ($result['size'] !== null ? ', ' . human_bytes((int)$result['size']) : '');
  176. // Not unqueued when the gallery changed under the build (see
  177. // archive_finish): it is due again immediately and the loop rebuilds
  178. // it, which is the point of draining.
  179. continue;
  180. }
  181. if ($result['done'] <= ($progress[$slug] ?? -1)) {
  182. // A slice that copied nothing and reported no error would spin.
  183. $skip[$slug] = true;
  184. $lines[] = 'archive ' . $slug . ' made no progress; skipped';
  185. continue;
  186. }
  187. $progress[$slug] = (int)$result['done'];
  188. }
  189. if ($worked) {
  190. // The queue is empty, or as empty as this run can make it. Reset the
  191. // chain counter the same way worker.php does when a chain ends, so one
  192. // left high by an interrupted chain cannot trip archive.max_chain on the
  193. // next web-driven build.
  194. if (cron_queued_count() === 0) {
  195. json_update(archive_queue_file(), function (array $queue): array {
  196. $queue['chain'] = 0;
  197. return $queue;
  198. });
  199. }
  200. // The next page view can skip its dispatch: whatever it would have
  201. // started has just been done.
  202. @touch(archive_tick_file());
  203. }
  204. return $lines;
  205. }
  206. /**
  207. * Everything, in order: the cheap bounded schedule first, then the open-ended
  208. * archive drain with whatever budget is left.
  209. *
  210. * @return array{jobs: string[], remaining: int, duration: float}
  211. */
  212. function cron_run_all(?float $budget = null): array
  213. {
  214. $started = microtime(true);
  215. $budget ??= (float)cron_max_seconds();
  216. $deadline = $started + $budget;
  217. $jobs = cron_run_manage();
  218. foreach (cron_run_archives($deadline) as $line) {
  219. $jobs[] = $line;
  220. }
  221. return [
  222. 'jobs' => $jobs,
  223. 'remaining' => cron_queued_count(),
  224. 'duration' => microtime(true) - $started,
  225. ];
  226. }