Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.42% covered (success)
95.42%
146 / 153
86.11% covered (success)
86.11%
31 / 36
CRAP
0.00% covered (danger)
0.00%
0 / 1
Redis
95.42% covered (success)
95.42%
146 / 153
86.11% covered (success)
86.11%
31 / 36
76
0.00% covered (danger)
0.00%
0 / 1
 __construct
70.00% covered (success)
70.00%
7 / 10
0.00% covered (danger)
0.00%
0 / 1
5.68
 create
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getRedis
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 redis
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getPrefix
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 taskSetKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 deadSetKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureIndexSets
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 removeFromReserved
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
5
 atomicReclaimIfStillExpired
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 reclaimExpiredLeases
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 claimTaskRun
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 push
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 reserve
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
8.01
 release
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 delete
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 bury
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 hasJobs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 hasDeadJobs
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 countDead
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getDeadJobs
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getDeadJob
85.71% covered (success)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 retryDeadJob
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 deleteDeadJob
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 clearDead
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 schedule
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getTasks
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getTask
83.33% covered (success)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
4.07
 getAllTasks
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 updateTask
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 removeTask
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getTaskCount
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 hasTasks
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clearTasks
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2declare(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 */
15namespace Pop\Queue\Adapter;
16
17use Pop\Queue\Process\AbstractJob;
18use Pop\Queue\Process\PayloadSigner;
19use 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 */
31class 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'
256local score = redis.call('ZSCORE', KEYS[1], ARGV[1])
257if score and tonumber(score) <= tonumber(ARGV[2]) then
258    redis.call('ZREM', KEYS[1], ARGV[1])
259    return 1
260end
261return 0
262LUA;
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'
306local key    = KEYS[1]
307local window = ARGV[1]
308local now    = tonumber(ARGV[2])
309local ttl    = tonumber(ARGV[3])
310
311local current = redis.call('GET', key)
312if 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
321end
322
323local expiresAt = now + ttl
324redis.call('SET', key, window .. ':' .. tostring(expiresAt))
325redis.call('EXPIRE', key, ttl)
326return 1
327LUA;
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}