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 | ||
| 24 | final 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 | } |