24) { throw new \InvalidArgumentException('Every 1 to 24 hours'); } return $hours === 24 ? $m[1] : $m[1] . '/' . $hours; } /** * The most recent scheduled moment at or before $now; null without a schedule. * * @param non-empty-string $zone */ public static function lastMoment(string $schedule, string $zone, int $now): ?int { if ($schedule === '') { return null; } $tz = new \DateTimeZone($zone); $today = (new \DateTimeImmutable('@' . $now))->setTimezone($tz); $moments = array_merge(self::moments($schedule, $today->modify('-1 day'), $tz), self::moments($schedule, $today, $tz)); $last = null; foreach ($moments as $moment) { if ($moment <= $now && ($last === null || $moment > $last)) { $last = $moment; } } return $last; } /** @return list the moments of one day */ private static function moments(string $schedule, \DateTimeImmutable $day, \DateTimeZone $tz): array { [$time, $every] = array_pad(explode('/', $schedule), 2, '24'); [$hour, $minute] = array_map('intval', explode(':', $time)); $moments = []; for ($h = $hour; $h < 24; $h += (int)$every) { $moments[] = (new \DateTimeImmutable($day->format('Y-m-d') . sprintf(' %02d:%02d', $h, $minute), $tz))->getTimestamp(); } return $moments; } /** * Whether a backup has to start for $moment. One that was made since then is enough, also when * several moments went by during a long backup. A moment from before the schedule was set does * not count: choosing a time must not start a backup right away. */ public static function isDue(int $moment, int $since, ?int $lastDoneStarted, int $attempts, ?int $lastAttemptFinished, int $now): bool { if ($moment < $since || ($lastDoneStarted !== null && $lastDoneStarted >= $moment) || $attempts >= self::MAX_ATTEMPTS) { return false; } return $attempts === 0 || $now - (int)$lastAttemptFinished >= self::RETRY_AFTER; } }