Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
100.00% |
69 / 69 |
|
100.00% |
10 / 10 |
CRAP | |
100.00% |
1 / 1 |
| Runner | |
100.00% |
69 / 69 |
|
100.00% |
10 / 10 |
27 | |
100.00% |
1 / 1 |
| __construct | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| scoped | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| run | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
| next | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
| claim | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| process | |
100.00% |
21 / 21 |
|
100.00% |
1 / 1 |
8 | |||
| endTransaction | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
3 | |||
| retry | |
100.00% |
12 / 12 |
|
100.00% |
1 / 1 |
2 | |||
| fail | |
100.00% |
8 / 8 |
|
100.00% |
1 / 1 |
1 | |||
| message | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace Cosray\Queue; |
| 6 | |
| 7 | use Celema\Container\Container; |
| 8 | use Celema\Quma\Database; |
| 9 | use Closure; |
| 10 | use Cosray\Console\Runtime; |
| 11 | use Cosray\Contract\Job; |
| 12 | use Cosray\Exception\JobFailed; |
| 13 | use Cosray\Exception\RuntimeException; |
| 14 | use Psr\Log\LoggerInterface as Logger; |
| 15 | use Throwable; |
| 16 | |
| 17 | /** |
| 18 | * Runs due jobs one at a time, after a response or from `queue:work`. |
| 19 | * |
| 20 | * A claimed job holds a lease, so runners in several processes never run |
| 21 | * it at once; when the lease ends before the job does, its runner is |
| 22 | * presumed dead and the job is due again. A failed attempt is retried |
| 23 | * later, with growing delays, until the job's attempts are used up. |
| 24 | * |
| 25 | * @internal |
| 26 | * |
| 27 | * @phpstan-type Claimed array{id: int, queue: string, job: string, payload: string, attempts: int, max_attempts: int} |
| 28 | */ |
| 29 | final class Runner |
| 30 | { |
| 31 | /** Seconds a claimed job may run before another runner takes it over. */ |
| 32 | public const int LEASE = 300; |
| 33 | |
| 34 | /** Seconds before the second, third and later attempts. */ |
| 35 | public const array BACKOFF = [60, 300, 1800, 7200, 43200]; |
| 36 | |
| 37 | private const int MAX_ERROR_LENGTH = 4000; |
| 38 | |
| 39 | /** |
| 40 | * @param Closure(class-string<Job>): object $resolve builds a job |
| 41 | * @param ?Closure(): float $clock seconds, for the time limit |
| 42 | */ |
| 43 | public function __construct( |
| 44 | private readonly Database $db, |
| 45 | private readonly Logger $logger, |
| 46 | private readonly Closure $resolve, |
| 47 | private readonly ?Closure $clock = null, |
| 48 | ) {} |
| 49 | |
| 50 | /** |
| 51 | * A runner that builds jobs in a request-free scope of the container, |
| 52 | * made on first use and dropped with the runner. |
| 53 | */ |
| 54 | public static function scoped(Database $db, Logger $logger, Container $container): self |
| 55 | { |
| 56 | $runtime = null; |
| 57 | |
| 58 | return new self($db, $logger, static function (string $class) use (&$runtime, $container): object { |
| 59 | $runtime ??= new Runtime($container); |
| 60 | |
| 61 | return $runtime->get($class); |
| 62 | }); |
| 63 | } |
| 64 | |
| 65 | /** |
| 66 | * Runs jobs until none is due or a limit is reached. The time limit is |
| 67 | * checked between jobs; a running job is never cut short. |
| 68 | * |
| 69 | * @param ?string $queue only jobs pushed under this name |
| 70 | * @param ?int $jobs at most this many jobs |
| 71 | * @param ?float $seconds no new job after this many seconds |
| 72 | * @return array{done: int, retried: int, failed: int} |
| 73 | */ |
| 74 | public function run(?string $queue = null, ?int $jobs = null, ?float $seconds = null): array |
| 75 | { |
| 76 | $clock = $this->clock ?? static fn(): float => hrtime(true) / 1e9; |
| 77 | $start = $clock(); |
| 78 | $counts = ['done' => 0, 'retried' => 0, 'failed' => 0]; |
| 79 | |
| 80 | while ($jobs === null || array_sum($counts) < $jobs) { |
| 81 | if ($seconds !== null && ($clock() - $start) >= $seconds) { |
| 82 | break; |
| 83 | } |
| 84 | |
| 85 | $claimed = $this->claim($queue); |
| 86 | |
| 87 | if ($claimed === null) { |
| 88 | break; |
| 89 | } |
| 90 | |
| 91 | $counts[$this->process($claimed)]++; |
| 92 | } |
| 93 | |
| 94 | return $counts; |
| 95 | } |
| 96 | |
| 97 | /** Seconds until the next job is due, 0 or less if one is; null without jobs. */ |
| 98 | public function next(?string $queue = null): ?float |
| 99 | { |
| 100 | /** @var array{seconds: ?float} $row */ |
| 101 | $row = $this->db->queue->next($queue === null ? [] : ['queue' => $queue])->one(); |
| 102 | |
| 103 | return $row['seconds']; |
| 104 | } |
| 105 | |
| 106 | /** @return ?Claimed */ |
| 107 | private function claim(?string $queue): ?array |
| 108 | { |
| 109 | $params = ['lease' => self::LEASE]; |
| 110 | |
| 111 | if ($queue !== null) { |
| 112 | $params['queue'] = $queue; |
| 113 | } |
| 114 | |
| 115 | /** @var ?Claimed */ |
| 116 | return $this->db->queue->claim($params)->first(); |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * @param Claimed $claimed |
| 121 | * @return 'done'|'retried'|'failed' |
| 122 | */ |
| 123 | private function process(array $claimed): string |
| 124 | { |
| 125 | if ($claimed['attempts'] > $claimed['max_attempts']) { |
| 126 | return $this->fail($claimed, 'Its runner stopped during the last attempt.'); |
| 127 | } |
| 128 | |
| 129 | $class = $claimed['job']; |
| 130 | |
| 131 | if (!is_a($class, Job::class, true)) { |
| 132 | return $this->fail($claimed, "{$class} is not a job class."); |
| 133 | } |
| 134 | |
| 135 | $wasInTransaction = $this->db->getConn()->inTransaction(); |
| 136 | |
| 137 | try { |
| 138 | $job = ($this->resolve)($class); |
| 139 | |
| 140 | if (!$job instanceof Job) { |
| 141 | throw new RuntimeException("Resolving {$class} returned " . get_debug_type($job) . '.'); |
| 142 | } |
| 143 | |
| 144 | $payload = json_decode($claimed['payload'], true, flags: JSON_THROW_ON_ERROR); |
| 145 | $job->handle(is_array($payload) ? $payload : []); |
| 146 | } catch (JobFailed $e) { |
| 147 | $this->endTransaction($wasInTransaction); |
| 148 | |
| 149 | return $this->fail($claimed, self::message($e)); |
| 150 | } catch (Throwable $e) { |
| 151 | $this->endTransaction($wasInTransaction); |
| 152 | |
| 153 | return $this->retry($claimed, self::message($e)); |
| 154 | } |
| 155 | |
| 156 | if ($this->endTransaction($wasInTransaction)) { |
| 157 | return $this->retry($claimed, 'The job left a transaction open.'); |
| 158 | } |
| 159 | |
| 160 | $this->db->queue->done(['id' => $claimed['id']])->run(); |
| 161 | |
| 162 | return 'done'; |
| 163 | } |
| 164 | |
| 165 | /** Rolls back a transaction the job opened and left open; returns whether there was one. */ |
| 166 | private function endTransaction(bool $wasInTransaction): bool |
| 167 | { |
| 168 | if ($wasInTransaction || !$this->db->getConn()->inTransaction()) { |
| 169 | return false; |
| 170 | } |
| 171 | |
| 172 | $this->db->rollback(); |
| 173 | |
| 174 | return true; |
| 175 | } |
| 176 | |
| 177 | /** |
| 178 | * @param Claimed $claimed |
| 179 | * @return 'retried'|'failed' |
| 180 | */ |
| 181 | private function retry(array $claimed, string $error): string |
| 182 | { |
| 183 | if ($claimed['attempts'] >= $claimed['max_attempts']) { |
| 184 | return $this->fail($claimed, $error); |
| 185 | } |
| 186 | |
| 187 | $delay = self::BACKOFF[min($claimed['attempts'], count(self::BACKOFF)) - 1]; |
| 188 | $this->db->queue->retry(['id' => $claimed['id'], 'delay' => $delay, 'error' => $error])->run(); |
| 189 | $this->logger->warning('Job {id} ({job}) failed attempt {attempt}, next in {delay} seconds: {error}', [ |
| 190 | 'id' => $claimed['id'], |
| 191 | 'job' => $claimed['job'], |
| 192 | 'attempt' => $claimed['attempts'], |
| 193 | 'delay' => $delay, |
| 194 | 'error' => $error, |
| 195 | ]); |
| 196 | |
| 197 | return 'retried'; |
| 198 | } |
| 199 | |
| 200 | /** |
| 201 | * @param Claimed $claimed |
| 202 | * @return 'failed' |
| 203 | */ |
| 204 | private function fail(array $claimed, string $error): string |
| 205 | { |
| 206 | $this->db->queue->fail(['id' => $claimed['id'], 'error' => $error])->run(); |
| 207 | $this->logger->error('Job {id} ({job}) failed after {attempts} attempts: {error}', [ |
| 208 | 'id' => $claimed['id'], |
| 209 | 'job' => $claimed['job'], |
| 210 | 'attempts' => min($claimed['attempts'], $claimed['max_attempts']), |
| 211 | 'error' => $error, |
| 212 | ]); |
| 213 | |
| 214 | return 'failed'; |
| 215 | } |
| 216 | |
| 217 | private static function message(Throwable $e): string |
| 218 | { |
| 219 | return mb_substr($e::class . ': ' . $e->getMessage(), 0, self::MAX_ERROR_LENGTH); |
| 220 | } |
| 221 | } |