Lines 95.55% 43 / 45
Methods 84.61% 11 / 13
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 read 100.00% 1 / 1 100.00% 1 / 1 1
 write 100.00% 2 / 2 100.00% 1 / 1 2
 remove 100.00% 1 / 1 100.00% 1 / 1 1
 exists 100.00% 1 / 1 100.00% 1 / 1 1
 increment 85.71% 6 / 7 0.00% 0 / 1 3.03
 keysWithPrefix 100.00% 5 / 5 100.00% 1 / 1 3
 removeByPrefix 100.00% 3 / 3 100.00% 1 / 1 3
 probe 100.00% 4 / 4 100.00% 1 / 1 1
 runWithTimeout 90.00% 9 / 10 0.00% 0 / 1 4.02
 scanKeyPages 100.00% 8 / 8 100.00% 1 / 1 1
 escapeGlobPattern 100.00% 1 / 1 100.00% 1 / 1 1
 withoutErrorCode 100.00% 1 / 1 100.00% 1 / 1 1
24final class RedisStoreBackend implements StoreBackend
25{
26    private const int BATCH = 500;
27
28    private const string NOT_A_NUMBER = '/not an integer|overflow|WRONGTYPE/i';
29
30    public function __construct(
31        private readonly RedisClient $client,
32        private readonly StoreDsn $dsn,
33        private readonly StoreTiming $timing,
34        private readonly Deadlines $deadlines,
35    ) {}
36
37    public function read(string $key): ?string
38    {
39        return $this->runWithTimeout(fn(): ?string => $this->client->get($key));
40    }
41
42    public function write(string $key, string $value, ?Duration $ttl): void
43    {
44        $options = $ttl === null ? null : new SetOptions()->withTtlInMillis($ttl->toMilliseconds());
45
46        $this->runWithTimeout(fn(): bool => $this->client->set($key, $value, $options));
47    }
48
49    public function remove(string $key): void
50    {
51        $this->runWithTimeout(fn(): int => $this->client->delete($key));
52    }
53
54    public function exists(string $key): bool
55    {
56        return $this->runWithTimeout(fn(): bool => $this->client->has($key));
57    }
58
59    public function increment(string $key, int $by): int
60    {
61        return $this->runWithTimeout(function () use ($key, $by): int {
62            try {
63                return $this->client->increment($key, $by);
64            } catch (QueryException $e) {
65                if (preg_match(self::NOT_A_NUMBER, $e->getMessage()) !== 1) {
66                    throw $e;
67                }
68
69                throw StoreException::valueNotIncrementable(self::withoutErrorCode($e->getMessage()), $e);
70            }
71        });
72    }
73
74    public function keysWithPrefix(string $prefix): array
75    {
76        $found = [];
77
78        foreach ($this->scanKeyPages($prefix) as $page) {
79            foreach ($page->keys as $key) {
80                // SCAN can return a key more than once across pages.
81                $found[$key] = true;
82            }
83        }
84
85        return array_keys($found);
86    }
87
88    public function removeByPrefix(string $prefix): void
89    {
90        foreach ($this->scanKeyPages($prefix) as $page) {
91            if ($page->keys !== []) {
92                $this->runWithTimeout(fn(): mixed => $this->client->execute('UNLINK', ...$page->keys));
93            }
94        }
95    }
96
97    public function probe(): void
98    {
99        $this->runWithTimeout(function (): null {
100            $this->client->ping();
101
102            return null;
103        });
104    }
105
106    /**
107     * @template T
108     *
109     * @param Closure(): T $operation
110     * @return T
111     * @throws StoreException
112     */
113    private function runWithTimeout(Closure $operation): mixed
114    {
115        $target = (string) $this->dsn;
116        $pending = async($operation);
117
118        try {
119            return $pending->await($this->deadlines->timeout($this->timing->timeout));
120        } catch (CancelledException $e) {
121            // The command still runs; its reply is discarded when it arrives.
122            $pending->ignore();
123
124            throw StoreException::createForReason(StoreError::TimedOut, $e, target: $target, timeout: (string) $this->timing->timeout);
125        } catch (QueryException $e) {
126            throw StoreException::createForReason(StoreError::Refused, $e, target: $target, detail: self::withoutErrorCode($e->getMessage()));
127        } catch (RedisException $e) {
128            throw StoreException::unreachable($target, $e->getMessage(), $e);
129        }
130    }
131
132    /**
133     * @return Generator<int, ScanPage>
134     * @throws StoreException
135     */
136    private function scanKeyPages(string $prefix): Generator
137    {
138        $pattern = self::escapeGlobPattern($prefix) . '*';
139        $cursor = '0';
140
141        do {
142            // Timeout per page: a large keyspace is slow, not unhealthy.
143            $page = $this->runWithTimeout(fn(): ScanPage => ScanPage::fromReply(
144                $this->client->execute('SCAN', $cursor, 'MATCH', $pattern, 'COUNT', self::BATCH),
145            ));
146            $cursor = $page->cursor;
147
148            yield $page;
149        } while (!$page->isLast());
150    }
151
152    // SCAN MATCH reads the prefix as a glob, so its glob characters are escaped.
153    private static function escapeGlobPattern(string $literal): string
154    {
155        return str_replace(['\\', '*', '?', '[', ']'], ['\\\\', '\*', '\?', '\[', '\]'], $literal);
156    }
157
158    private static function withoutErrorCode(string $message): string
159    {
160        return preg_replace('/^(ERR|WRONGTYPE) /', '', $message) ?? $message;
161    }
162}