Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
95.42% |
146 / 153 |
|
86.11% |
31 / 36 |
CRAP | |
0.00% |
0 / 1 |
| Redis | |
95.42% |
146 / 153 |
|
86.11% |
31 / 36 |
76 | |
0.00% |
0 / 1 |
| __construct | |
70.00% |
7 / 10 |
|
0.00% |
0 / 1 |
5.68 | |||
| create | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getRedis | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| redis | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| getPrefix | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| taskSetKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| deadSetKey | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| ensureIndexSets | |
100.00% |
11 / 11 |
|
100.00% |
1 / 1 |
6 | |||
| removeFromReserved | |
100.00% |
7 / 7 |
|
100.00% |
1 / 1 |
5 | |||
| atomicReclaimIfStillExpired | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| reclaimExpiredLeases | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
3 | |||
| claimTaskRun | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
1 | |||
| push | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| reserve | |
94.44% |
17 / 18 |
|
0.00% |
0 / 1 |
8.01 | |||
| release | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| delete | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| bury | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| hasJobs | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| count | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| clear | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| hasDeadJobs | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| countDead | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getDeadJobs | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| getDeadJob | |
85.71% |
6 / 7 |
|
0.00% |
0 / 1 |
4.05 | |||
| retryDeadJob | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| deleteDeadJob | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
| clearDead | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| schedule | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| getTasks | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| getTask | |
83.33% |
5 / 6 |
|
0.00% |
0 / 1 |
4.07 | |||
| getAllTasks | |
92.31% |
12 / 13 |
|
0.00% |
0 / 1 |
6.02 | |||
| updateTask | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 | |||
| removeTask | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
| getTaskCount | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
| hasTasks | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| clearTasks | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| 1 | <?php |
| 2 | declare(strict_types=1); |
| 3 | /** |
| 4 | * Pop PHP Framework (https://www.popphp.org/) |
| 5 | * |
| 6 | * @link https://github.com/popphp/popphp-framework |
| 7 | * @author Nick Sagona, III <nick@popphp.org> |
| 8 | * @copyright Copyright (c) 2009-2026 Nick Sagona, III |
| 9 | * @license https://www.popphp.org/license New BSD License |
| 10 | */ |
| 11 | |
| 12 | /** |
| 13 | * @namespace |
| 14 | */ |
| 15 | namespace Pop\Queue\Adapter; |
| 16 | |
| 17 | use Pop\Queue\Process\AbstractJob; |
| 18 | use Pop\Queue\Process\PayloadSigner; |
| 19 | use Pop\Queue\Process\Task; |
| 20 | |
| 21 | /** |
| 22 | * Redis adapter class |
| 23 | * |
| 24 | * @category Pop |
| 25 | * @package Pop\Queue |
| 26 | * @author Nick Sagona, III <nick@popphp.org> |
| 27 | * @copyright Copyright (c) 2009-2026 Nick Sagona, III |
| 28 | * @license https://www.popphp.org/license New BSD License |
| 29 | * @version 3.0.0 |
| 30 | */ |
| 31 | class Redis extends AbstractTaskAdapter |
| 32 | { |
| 33 | |
| 34 | /** |
| 35 | * Redis object |
| 36 | * @var \Redis|null |
| 37 | */ |
| 38 | protected \Redis|null $redis = null; |
| 39 | |
| 40 | /** |
| 41 | * Queue prefix |
| 42 | * @var string |
| 43 | */ |
| 44 | protected string $prefix = 'pop-queue'; |
| 45 | |
| 46 | /** |
| 47 | * Reservation lease length, in seconds |
| 48 | * @var int |
| 49 | */ |
| 50 | protected int $leaseSeconds = 60; |
| 51 | |
| 52 | /** |
| 53 | * Whether this instance has already reconciled the task and dead-letter |
| 54 | * index sets against any keys written before those sets existed. See |
| 55 | * ensureIndexSets(). |
| 56 | * @var bool |
| 57 | */ |
| 58 | protected bool $indexSetsChecked = false; |
| 59 | |
| 60 | /** |
| 61 | * Constructor |
| 62 | * |
| 63 | * Instantiate the redis adapter |
| 64 | * |
| 65 | * @param string $host |
| 66 | * @param int|string $port |
| 67 | * @param string $prefix |
| 68 | * @param ?string $priority |
| 69 | * @param int $leaseSeconds |
| 70 | * @param ?string $password Optional password for AUTH |
| 71 | * @param ?array $context Optional stream context (e.g. ['stream' => ['ssl' => [...]]] for TLS) |
| 72 | * @throws Exception|\RedisException |
| 73 | */ |
| 74 | public function __construct( |
| 75 | string $host = 'localhost', int|string $port = 6379, string $prefix = 'pop-queue', ?string $priority = null, |
| 76 | int $leaseSeconds = 60, ?string $password = null, ?array $context = null |
| 77 | ) |
| 78 | { |
| 79 | if (!class_exists('Redis', false)) { |
| 80 | throw new Exception('Error: Redis is not available.'); |
| 81 | } |
| 82 | |
| 83 | $this->redis = new \Redis(); |
| 84 | $this->prefix = $prefix; |
| 85 | $this->leaseSeconds = $leaseSeconds; |
| 86 | |
| 87 | if (!$this->redis->connect($host, (int)$port, context: $context)) { |
| 88 | throw new Exception('Error: Unable to connect to the redis server.'); |
| 89 | } |
| 90 | |
| 91 | if (($password !== null) && !$this->redis->auth($password)) { |
| 92 | throw new Exception('Error: Unable to authenticate with the redis server.'); |
| 93 | } |
| 94 | |
| 95 | parent::__construct($priority); |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Create Redis adapter |
| 100 | * |
| 101 | * @param string $host |
| 102 | * @param int|string $port |
| 103 | * @param string $prefix |
| 104 | * @param ?string $priority |
| 105 | * @param int $leaseSeconds |
| 106 | * @param ?string $password |
| 107 | * @param ?array $context |
| 108 | * @throws Exception|\RedisException |
| 109 | * @return Redis |
| 110 | */ |
| 111 | public static function create( |
| 112 | string $host = 'localhost', int|string $port = 6379, string $prefix = 'pop-queue', ?string $priority = null, |
| 113 | int $leaseSeconds = 60, ?string $password = null, ?array $context = null |
| 114 | ): Redis |
| 115 | { |
| 116 | return new self($host, $port, $prefix, $priority, $leaseSeconds, $password, $context); |
| 117 | } |
| 118 | |
| 119 | /** |
| 120 | * Get Redis object |
| 121 | * |
| 122 | * @return \Redis|null |
| 123 | */ |
| 124 | public function getRedis(): \Redis|null |
| 125 | { |
| 126 | return $this->redis; |
| 127 | } |
| 128 | |
| 129 | /** |
| 130 | * Get Redis object (alias) |
| 131 | * |
| 132 | * @return \Redis|null |
| 133 | */ |
| 134 | public function redis(): \Redis|null |
| 135 | { |
| 136 | return $this->redis; |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Get prefix |
| 141 | * |
| 142 | * @return string |
| 143 | */ |
| 144 | public function getPrefix(): string |
| 145 | { |
| 146 | return $this->prefix; |
| 147 | } |
| 148 | |
| 149 | /** |
| 150 | * Key of the set indexing scheduled task IDs |
| 151 | * |
| 152 | * @return string |
| 153 | */ |
| 154 | protected function taskSetKey(): string |
| 155 | { |
| 156 | return $this->prefix . ':tasks'; |
| 157 | } |
| 158 | |
| 159 | /** |
| 160 | * Key of the set indexing dead-letter job IDs |
| 161 | * |
| 162 | * @return string |
| 163 | */ |
| 164 | protected function deadSetKey(): string |
| 165 | { |
| 166 | return $this->prefix . ':dead'; |
| 167 | } |
| 168 | |
| 169 | /** |
| 170 | * Populate the task and dead-letter index sets from any keys written before |
| 171 | * those sets existed, once per adapter instance. |
| 172 | * |
| 173 | * Those two collections used to be enumerated with KEYS, which Redis |
| 174 | * evaluates against its entire keyspace while blocking every other client on |
| 175 | * the server - and this adapter reached for it constantly, including to |
| 176 | * answer questions as small as hasTasks(). Maintaining the membership in a |
| 177 | * set instead turns all of it into SMEMBERS/SCARD/SISMEMBER against one key. |
| 178 | * |
| 179 | * Which leaves the keys an older version already wrote and never indexed. A |
| 180 | * marker key records that the reconciliation has happened, so KEYS runs at |
| 181 | * most once per Redis database, ever, rather than never running and quietly |
| 182 | * orphaning every task and dead job that predates the upgrade. Two processes |
| 183 | * racing here is harmless: SADD is idempotent, so the worst case is the same |
| 184 | * work done twice. |
| 185 | * |
| 186 | * @return void |
| 187 | */ |
| 188 | protected function ensureIndexSets(): void |
| 189 | { |
| 190 | if ($this->indexSetsChecked) { |
| 191 | return; |
| 192 | } |
| 193 | $this->indexSetsChecked = true; |
| 194 | |
| 195 | if ($this->redis->exists($this->prefix . ':index-built')) { |
| 196 | return; |
| 197 | } |
| 198 | |
| 199 | foreach ([':task-' => $this->taskSetKey(), ':dead-' => $this->deadSetKey()] as $marker => $setKey) { |
| 200 | foreach ($this->redis->keys($this->prefix . $marker . '*') as $key) { |
| 201 | $id = substr($key, (strrpos($key, $marker) + strlen($marker))); |
| 202 | if ($id !== '') { |
| 203 | $this->redis->sAdd($setKey, $id); |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | |
| 208 | $this->redis->set($this->prefix . ':index-built', '1'); |
| 209 | } |
| 210 | |
| 211 | /** |
| 212 | * Find a job matching a job ID in the reserved sorted set and remove it |
| 213 | * |
| 214 | * @param string $jobId |
| 215 | * @return ?string the removed member's serialized value, if found |
| 216 | */ |
| 217 | protected function removeFromReserved(string $jobId): ?string |
| 218 | { |
| 219 | foreach ($this->redis->zRange($this->prefix . ':reserved', 0, -1) as $value) { |
| 220 | $raw = PayloadSigner::verify($value); |
| 221 | // Suppressed: a corrupt/tampered payload makes unserialize() emit |
| 222 | // a warning and return false, which the instanceof check below |
| 223 | // handles. |
| 224 | $stored = ($raw !== false) ? @unserialize($raw) : false; |
| 225 | if (($stored instanceof AbstractJob) && ($stored->getJobId() === $jobId)) { |
| 226 | $this->redis->zRem($this->prefix . ':reserved', $value); |
| 227 | return $value; |
| 228 | } |
| 229 | } |
| 230 | |
| 231 | return null; |
| 232 | } |
| 233 | |
| 234 | /** |
| 235 | * Atomically remove a reserved-set member if, and only if, its *current* |
| 236 | * score (re-checked server-side at the moment this runs, not a stale |
| 237 | * snapshot from an earlier zRangeByScore() read) is still <= $now. |
| 238 | * |
| 239 | * This closes an ABA race: a worker's earlier "this looks expired" read |
| 240 | * can go stale if another worker reclaims and freshly re-claims the |
| 241 | * same entry before the first worker acts on it. Because a reclaim |
| 242 | * never re-serializes the job, the serialized value alone can't tell |
| 243 | * "the same expired claim" apart from "a fresh claim that happens to |
| 244 | * match" - a plain zRem($key, $value) would remove the fresh claim too, |
| 245 | * since it matches by value only and ignores the current score. Redis |
| 246 | * executes Lua scripts atomically, so this re-check-and-remove can't be |
| 247 | * interleaved by another command. |
| 248 | * |
| 249 | * @param string $value |
| 250 | * @param int $now |
| 251 | * @return int 1 if removed, 0 if the entry is missing or no longer expired |
| 252 | */ |
| 253 | protected function atomicReclaimIfStillExpired(string $value, int $now): int |
| 254 | { |
| 255 | $script = <<<'LUA' |
| 256 | local score = redis.call('ZSCORE', KEYS[1], ARGV[1]) |
| 257 | if score and tonumber(score) <= tonumber(ARGV[2]) then |
| 258 | redis.call('ZREM', KEYS[1], ARGV[1]) |
| 259 | return 1 |
| 260 | end |
| 261 | return 0 |
| 262 | LUA; |
| 263 | |
| 264 | return (int)$this->redis->eval($script, [$this->prefix . ':reserved', $value, $now], 1); |
| 265 | } |
| 266 | |
| 267 | /** |
| 268 | * Move any reserved job whose lease has expired back to the pending list, |
| 269 | * so a crashed worker's claim self-heals instead of being stuck forever. |
| 270 | * |
| 271 | * @return void |
| 272 | */ |
| 273 | protected function reclaimExpiredLeases(): void |
| 274 | { |
| 275 | $now = time(); |
| 276 | $expired = $this->redis->zRangeByScore($this->prefix . ':reserved', '-inf', (string)$now); |
| 277 | |
| 278 | foreach ($expired as $value) { |
| 279 | // atomicReclaimIfStillExpired() re-verifies the *current* score |
| 280 | // at removal time; if it reports 0, another worker already |
| 281 | // reclaimed (and possibly freshly re-claimed) this same expired |
| 282 | // lease first - skip it rather than trust our stale read. |
| 283 | if ($this->atomicReclaimIfStillExpired($value, $now) === 1) { |
| 284 | $this->redis->lPush($this->prefix, $value); |
| 285 | } |
| 286 | } |
| 287 | } |
| 288 | |
| 289 | /** |
| 290 | * Atomically claim a task's current due-window. Redis has no single |
| 291 | * native command for "compare stored value, swap if different-window- |
| 292 | * or-expired", so this uses a small Lua eval() script - the same |
| 293 | * approach atomicReclaimIfStillExpired() already uses for job-lease |
| 294 | * reclaim. The key's value format is "<window>:<expiresAtUnixTimestamp>"; |
| 295 | * a native Redis EXPIRE is also set on a successful claim so garbage |
| 296 | * collection happens even if removeTask() is somehow skipped, on top |
| 297 | * of the explicit del() removeTask() performs. |
| 298 | * |
| 299 | * @param string $taskId |
| 300 | * @param string $window |
| 301 | * @return bool |
| 302 | */ |
| 303 | public function claimTaskRun(string $taskId, string $window): bool |
| 304 | { |
| 305 | $script = <<<'LUA' |
| 306 | local key = KEYS[1] |
| 307 | local window = ARGV[1] |
| 308 | local now = tonumber(ARGV[2]) |
| 309 | local ttl = tonumber(ARGV[3]) |
| 310 | |
| 311 | local current = redis.call('GET', key) |
| 312 | if current then |
| 313 | local sep = string.find(current, ':') |
| 314 | if sep then |
| 315 | local storedWindow = string.sub(current, 1, sep - 1) |
| 316 | local storedExpiry = tonumber(string.sub(current, sep + 1)) |
| 317 | if storedWindow == window and storedExpiry and storedExpiry > now then |
| 318 | return 0 |
| 319 | end |
| 320 | end |
| 321 | end |
| 322 | |
| 323 | local expiresAt = now + ttl |
| 324 | redis.call('SET', key, window .. ':' .. tostring(expiresAt)) |
| 325 | redis.call('EXPIRE', key, ttl) |
| 326 | return 1 |
| 327 | LUA; |
| 328 | |
| 329 | $key = $this->prefix . ':claim-task-' . $taskId; |
| 330 | $result = $this->redis->eval($script, [$key, $window, time(), self::TASK_CLAIM_TTL], 1); |
| 331 | |
| 332 | return ((int)$result === 1); |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Push job on to queue |
| 337 | * |
| 338 | * @param AbstractJob $job |
| 339 | * @return Redis |
| 340 | */ |
| 341 | public function push(AbstractJob $job): Redis |
| 342 | { |
| 343 | $job->getJobId(); |
| 344 | $this->redis->lPush($this->prefix, PayloadSigner::sign(serialize(clone $job))); |
| 345 | return $this; |
| 346 | } |
| 347 | |
| 348 | /** |
| 349 | * Atomically claim the next eligible job. Reclaims any reserved job whose |
| 350 | * lease has expired first, then scans the pending list in queue order and |
| 351 | * skips any job that isn't yet available, atomically claiming the first |
| 352 | * eligible one via a checked lRem() - if lRem() reports it removed |
| 353 | * nothing, another worker already claimed this same entry first, so this |
| 354 | * moves on to the next candidate instead of assuming success. |
| 355 | * |
| 356 | * @return ?AbstractJob |
| 357 | */ |
| 358 | public function reserve(): ?AbstractJob |
| 359 | { |
| 360 | $this->reclaimExpiredLeases(); |
| 361 | |
| 362 | $values = $this->redis->lRange($this->prefix, 0, -1); |
| 363 | |
| 364 | if (empty($values)) { |
| 365 | return null; |
| 366 | } |
| 367 | |
| 368 | // push() lPushes, so the oldest entry is at the tail of the list |
| 369 | if ($this->isFifo()) { |
| 370 | $values = array_reverse($values); |
| 371 | } |
| 372 | |
| 373 | foreach ($values as $value) { |
| 374 | // Suppressed: a corrupt/truncated payload makes unserialize() emit a |
| 375 | // warning and return false, and a payload whose class no longer |
| 376 | // exists (renamed/removed in a deploy) yields a |
| 377 | // __PHP_Incomplete_Class - the instanceof check below handles both. |
| 378 | // A payload that fails PayloadSigner::verify() is treated identically |
| 379 | // - $raw is false, so unserialize() is never called on it at all. |
| 380 | $raw = PayloadSigner::verify($value); |
| 381 | $job = ($raw !== false) ? @unserialize($raw) : false; |
| 382 | |
| 383 | if (!($job instanceof AbstractJob)) { |
| 384 | // Corrupt/unloadable payload - skip rather than claim it and |
| 385 | // then blow up returning a non-AbstractJob. Claiming it would |
| 386 | // be worse than a one-shot crash now that leases exist: the |
| 387 | // claim would expire, get reclaimed back to eligible, and |
| 388 | // poison the next worker too, forever. |
| 389 | continue; |
| 390 | } |
| 391 | |
| 392 | if (!$job->isAvailable()) { |
| 393 | continue; |
| 394 | } |
| 395 | |
| 396 | if ($this->redis->lRem($this->prefix, $value, 1) !== 1) { |
| 397 | continue; |
| 398 | } |
| 399 | |
| 400 | $this->redis->zAdd($this->prefix . ':reserved', time() + $this->leaseSeconds, $value); |
| 401 | |
| 402 | return $job; |
| 403 | } |
| 404 | |
| 405 | return null; |
| 406 | } |
| 407 | |
| 408 | /** |
| 409 | * Put a job back to pending, honoring its backoff schedule unless an |
| 410 | * explicit delay is given |
| 411 | * |
| 412 | * @param AbstractJob $job |
| 413 | * @param ?int $delay |
| 414 | * @return Redis |
| 415 | */ |
| 416 | public function release(AbstractJob $job, ?int $delay = null): Redis |
| 417 | { |
| 418 | if ($this->removeFromReserved($job->getJobId()) === null) { |
| 419 | return $this; |
| 420 | } |
| 421 | |
| 422 | $job->delay($delay ?? $job->getBackoffDelay()); |
| 423 | $this->redis->lPush($this->prefix, PayloadSigner::sign(serialize(clone $job))); |
| 424 | |
| 425 | return $this; |
| 426 | } |
| 427 | |
| 428 | /** |
| 429 | * Permanently remove a job |
| 430 | * |
| 431 | * @param AbstractJob $job |
| 432 | * @return Redis |
| 433 | */ |
| 434 | public function delete(AbstractJob $job): Redis |
| 435 | { |
| 436 | $this->removeFromReserved($job->getJobId()); |
| 437 | return $this; |
| 438 | } |
| 439 | |
| 440 | /** |
| 441 | * Move a job to the dead-letter store |
| 442 | * |
| 443 | * @param AbstractJob $job |
| 444 | * @param ?string $reason |
| 445 | * @return Redis |
| 446 | */ |
| 447 | public function bury(AbstractJob $job, ?string $reason = null): Redis |
| 448 | { |
| 449 | $this->removeFromReserved($job->getJobId()); |
| 450 | $this->redis->set($this->prefix . ':dead-' . $job->getJobId(), PayloadSigner::sign(serialize(clone $job))); |
| 451 | $this->redis->sAdd($this->deadSetKey(), $job->getJobId()); |
| 452 | |
| 453 | return $this; |
| 454 | } |
| 455 | |
| 456 | /** |
| 457 | * Check if adapter has pending or reserved jobs |
| 458 | * |
| 459 | * @return bool |
| 460 | */ |
| 461 | public function hasJobs(): bool |
| 462 | { |
| 463 | return ($this->count() > 0); |
| 464 | } |
| 465 | |
| 466 | /** |
| 467 | * Count of pending + reserved jobs |
| 468 | * |
| 469 | * @return int |
| 470 | */ |
| 471 | public function count(): int |
| 472 | { |
| 473 | return $this->redis->lLen($this->prefix) + $this->redis->zCard($this->prefix . ':reserved'); |
| 474 | } |
| 475 | |
| 476 | /** |
| 477 | * Clear pending and reserved jobs (not tasks or dead-letter jobs) |
| 478 | * |
| 479 | * @return Redis |
| 480 | */ |
| 481 | public function clear(): Redis |
| 482 | { |
| 483 | $this->redis->del($this->prefix); |
| 484 | $this->redis->del($this->prefix . ':reserved'); |
| 485 | |
| 486 | return $this; |
| 487 | } |
| 488 | |
| 489 | /** |
| 490 | * Check if adapter has dead jobs |
| 491 | * |
| 492 | * @return bool |
| 493 | */ |
| 494 | public function hasDeadJobs(): bool |
| 495 | { |
| 496 | return ($this->countDead() > 0); |
| 497 | } |
| 498 | |
| 499 | /** |
| 500 | * Count of dead jobs |
| 501 | * |
| 502 | * @return int |
| 503 | */ |
| 504 | public function countDead(): int |
| 505 | { |
| 506 | $this->ensureIndexSets(); |
| 507 | |
| 508 | return $this->redis->sCard($this->deadSetKey()); |
| 509 | } |
| 510 | |
| 511 | /** |
| 512 | * Get dead jobs |
| 513 | * |
| 514 | * @param bool $unserialize |
| 515 | * @return array |
| 516 | */ |
| 517 | public function getDeadJobs(bool $unserialize = true): array |
| 518 | { |
| 519 | $this->ensureIndexSets(); |
| 520 | |
| 521 | $jobs = []; |
| 522 | foreach ($this->redis->sMembers($this->deadSetKey()) as $jobId) { |
| 523 | $jobs[$jobId] = $this->getDeadJob($jobId, $unserialize); |
| 524 | } |
| 525 | |
| 526 | return $jobs; |
| 527 | } |
| 528 | |
| 529 | /** |
| 530 | * Get a dead job |
| 531 | * |
| 532 | * @param string $jobId |
| 533 | * @param bool $unserialize |
| 534 | * @return mixed |
| 535 | */ |
| 536 | public function getDeadJob(string $jobId, bool $unserialize = true): mixed |
| 537 | { |
| 538 | $value = $this->redis->get($this->prefix . ':dead-' . $jobId); |
| 539 | if ($value === false) { |
| 540 | return null; |
| 541 | } |
| 542 | |
| 543 | if (!$unserialize) { |
| 544 | return $value; |
| 545 | } |
| 546 | |
| 547 | $raw = PayloadSigner::verify($value); |
| 548 | return ($raw !== false) ? unserialize($raw) : false; |
| 549 | } |
| 550 | |
| 551 | /** |
| 552 | * Retry a dead job by pushing it back on to the queue |
| 553 | * |
| 554 | * @param string $jobId |
| 555 | * @return Redis |
| 556 | */ |
| 557 | public function retryDeadJob(string $jobId): Redis |
| 558 | { |
| 559 | $job = $this->getDeadJob($jobId); |
| 560 | if ($job instanceof AbstractJob) { |
| 561 | $this->deleteDeadJob($jobId); |
| 562 | $this->push($job); |
| 563 | } |
| 564 | |
| 565 | return $this; |
| 566 | } |
| 567 | |
| 568 | /** |
| 569 | * Permanently delete a dead job |
| 570 | * |
| 571 | * @param string $jobId |
| 572 | * @return Redis |
| 573 | */ |
| 574 | public function deleteDeadJob(string $jobId): Redis |
| 575 | { |
| 576 | $this->redis->del($this->prefix . ':dead-' . $jobId); |
| 577 | $this->redis->sRem($this->deadSetKey(), $jobId); |
| 578 | |
| 579 | return $this; |
| 580 | } |
| 581 | |
| 582 | /** |
| 583 | * Clear all dead jobs |
| 584 | * |
| 585 | * @return Redis |
| 586 | */ |
| 587 | public function clearDead(): Redis |
| 588 | { |
| 589 | $this->ensureIndexSets(); |
| 590 | |
| 591 | foreach ($this->redis->sMembers($this->deadSetKey()) as $jobId) { |
| 592 | $this->redis->del($this->prefix . ':dead-' . $jobId); |
| 593 | } |
| 594 | |
| 595 | $this->redis->del($this->deadSetKey()); |
| 596 | |
| 597 | return $this; |
| 598 | } |
| 599 | |
| 600 | /** |
| 601 | * Push job on to queue |
| 602 | * |
| 603 | * @param Task $task |
| 604 | * @return Redis |
| 605 | */ |
| 606 | public function schedule(Task $task): Redis |
| 607 | { |
| 608 | if ($task->isValid()) { |
| 609 | $this->redis->set($this->prefix . ':task-' . $task->getJobId(), PayloadSigner::sign(serialize(clone $task))); |
| 610 | $this->redis->sAdd($this->taskSetKey(), $task->getJobId()); |
| 611 | } |
| 612 | return $this; |
| 613 | } |
| 614 | |
| 615 | /** |
| 616 | * Get scheduled tasks |
| 617 | * |
| 618 | * @return array |
| 619 | */ |
| 620 | public function getTasks(): array |
| 621 | { |
| 622 | $this->ensureIndexSets(); |
| 623 | |
| 624 | return $this->redis->sMembers($this->taskSetKey()); |
| 625 | } |
| 626 | |
| 627 | /** |
| 628 | * Get scheduled task |
| 629 | * |
| 630 | * @param string $taskId |
| 631 | * @return ?Task |
| 632 | */ |
| 633 | public function getTask(string $taskId): ?Task |
| 634 | { |
| 635 | $value = $this->redis->get($this->prefix . ':task-' . $taskId); |
| 636 | if ($value === false) { |
| 637 | return null; |
| 638 | } |
| 639 | |
| 640 | // Guarded with instanceof rather than returning unserialize()'s result |
| 641 | // directly, the same way reserve() does: a corrupt or tampered payload |
| 642 | // makes unserialize() return false, and false out of a ": ?Task" method |
| 643 | // is a TypeError, not a null. |
| 644 | $raw = PayloadSigner::verify($value); |
| 645 | $task = ($raw !== false) ? @unserialize($raw) : false; |
| 646 | |
| 647 | return ($task instanceof Task) ? $task : null; |
| 648 | } |
| 649 | |
| 650 | /** |
| 651 | * Get every scheduled task, keyed by task ID. |
| 652 | * |
| 653 | * One SMEMBERS plus one MGET, instead of the inherited "list the IDs, then |
| 654 | * GET each payload" - which is a round trip per scheduled task, paid on |
| 655 | * every Queue::run() and so on every tick of a worker's schedule loop. |
| 656 | * |
| 657 | * @return array taskId => Task |
| 658 | */ |
| 659 | public function getAllTasks(): array |
| 660 | { |
| 661 | // Repacked so the positional alignment with mGet()'s reply below rests |
| 662 | // on this call rather than on getTasks() happening to return a list. |
| 663 | $taskIds = array_values($this->getTasks()); |
| 664 | if (empty($taskIds)) { |
| 665 | return []; |
| 666 | } |
| 667 | |
| 668 | $values = $this->redis->mGet(array_map(fn($id) => $this->prefix . ':task-' . $id, $taskIds)); |
| 669 | $tasks = []; |
| 670 | |
| 671 | foreach ($taskIds as $i => $taskId) { |
| 672 | // A member indexed but no longer stored comes back false from MGET, |
| 673 | // the same signal getTask() reads off a missing key. |
| 674 | if (!is_string($values[$i] ?? false)) { |
| 675 | continue; |
| 676 | } |
| 677 | |
| 678 | $raw = PayloadSigner::verify($values[$i]); |
| 679 | $task = ($raw !== false) ? @unserialize($raw) : false; |
| 680 | |
| 681 | if ($task instanceof Task) { |
| 682 | $tasks[$taskId] = $task; |
| 683 | } |
| 684 | } |
| 685 | |
| 686 | return $tasks; |
| 687 | } |
| 688 | |
| 689 | /** |
| 690 | * Update scheduled task |
| 691 | * |
| 692 | * @param Task $task |
| 693 | * @return Redis |
| 694 | */ |
| 695 | public function updateTask(Task $task): Redis |
| 696 | { |
| 697 | if ($task->isValid()) { |
| 698 | $this->redis->set($this->prefix . ':task-' . $task->getJobId(), PayloadSigner::sign(serialize(clone $task))); |
| 699 | $this->redis->sAdd($this->taskSetKey(), $task->getJobId()); |
| 700 | } else { |
| 701 | $this->removeTask($task->getJobId()); |
| 702 | } |
| 703 | return $this; |
| 704 | } |
| 705 | |
| 706 | /** |
| 707 | * Remove scheduled task |
| 708 | * |
| 709 | * @param string $taskId |
| 710 | * @return Redis |
| 711 | */ |
| 712 | public function removeTask(string $taskId): Redis |
| 713 | { |
| 714 | $this->redis->del($this->prefix . ':task-' . $taskId); |
| 715 | $this->redis->del($this->prefix . ':claim-task-' . $taskId); |
| 716 | $this->redis->sRem($this->taskSetKey(), $taskId); |
| 717 | |
| 718 | return $this; |
| 719 | } |
| 720 | |
| 721 | /** |
| 722 | * Get scheduled tasks count |
| 723 | * |
| 724 | * @return int |
| 725 | */ |
| 726 | public function getTaskCount(): int |
| 727 | { |
| 728 | $this->ensureIndexSets(); |
| 729 | |
| 730 | return $this->redis->sCard($this->taskSetKey()); |
| 731 | } |
| 732 | |
| 733 | /** |
| 734 | * Has scheduled tasks |
| 735 | * |
| 736 | * @return bool |
| 737 | */ |
| 738 | public function hasTasks(): bool |
| 739 | { |
| 740 | return ($this->getTaskCount() > 0); |
| 741 | } |
| 742 | |
| 743 | /** |
| 744 | * Clear all scheduled task |
| 745 | * |
| 746 | * @return Redis |
| 747 | */ |
| 748 | public function clearTasks(): Redis |
| 749 | { |
| 750 | foreach ($this->getTasks() as $taskId) { |
| 751 | $this->removeTask($taskId); |
| 752 | } |
| 753 | |
| 754 | // removeTask() already SREMs each member, so the set is empty by now - |
| 755 | // deleted rather than left behind so an unused empty key doesn't linger. |
| 756 | $this->redis->del($this->taskSetKey()); |
| 757 | |
| 758 | return $this; |
| 759 | } |
| 760 | |
| 761 | } |