Lines 95.34% 41 / 43
Methods 86.66% 13 / 15
Classes 0.00% 0 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 simulateOutage 100.00% 1 / 1 100.00% 1 / 1 1
 endOutage 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 1
 remove 100.00% 2 / 2 100.00% 1 / 1 1
 exists 100.00% 1 / 1 100.00% 1 / 1 1
 increment 90.90% 10 / 11 0.00% 0 / 1 7.04
 keysWithPrefix 100.00% 6 / 6 100.00% 1 / 1 4
 removeByPrefix 100.00% 2 / 2 100.00% 1 / 1 2
 probe 66.66% 2 / 3 0.00% 0 / 1 2.15
 findLiveEntry 100.00% 8 / 8 100.00% 1 / 1 4
 computeExpiryFor 100.00% 1 / 1 100.00% 1 / 1 2
 readNowInMilliseconds 100.00% 1 / 1 100.00% 1 / 1 1
 failDuringOutage 100.00% 2 / 2 100.00% 1 / 1 2
13final class InMemoryStoreBackend implements StoreBackend
14{
15    /** @var array<string, StoredEntry> */
16    private array $entries = [];
17
18    private ?string $failure = null;
19
20    /** @var (Closure(): void)|null */
21    public ?Closure $duringProbe = null;
22
23    public function __construct(private readonly Clock $clock) {}
24
25    public function simulateOutage(string $detail): void
26    {
27        $this->failure = $detail;
28    }
29
30    public function endOutage(): void
31    {
32        $this->failure = null;
33    }
34
35    public function read(string $key): ?string
36    {
37        return $this->findLiveEntry($key)?->value;
38    }
39
40    public function write(string $key, string $value, ?Duration $ttl): void
41    {
42        $this->failDuringOutage();
43
44        $this->entries[$key] = new StoredEntry($value, $this->computeExpiryFor($ttl));
45    }
46
47    public function remove(string $key): void
48    {
49        $this->failDuringOutage();
50
51        unset($this->entries[$key]);
52    }
53
54    public function exists(string $key): bool
55    {
56        return $this->findLiveEntry($key) !== null;
57    }
58
59    public function increment(string $key, int $by): int
60    {
61        $entry = $this->findLiveEntry($key);
62
63        if ($entry === null) {
64            $this->entries[$key] = new StoredEntry((string) $by, null);
65
66            return $by;
67        }
68
69        if (preg_match('/\A-?(0|[1-9]\d*)\z/', $entry->value) !== 1) {
70            throw StoreException::valueNotIncrementable('the value is not a whole number');
71        }
72
73        $current = (int) $entry->value;
74
75        if (($by > 0 && $current > \PHP_INT_MAX - $by) || ($by < 0 && $current < \PHP_INT_MIN - $by)) {
76            throw StoreException::valueNotIncrementable('the result would overflow');
77        }
78
79        // The expiry is deliberately carried over: counting must not extend a lifetime.
80        $this->entries[$key] = new StoredEntry((string) ($current + $by), $entry->expiresAtMilliseconds);
81
82        return $current + $by;
83    }
84
85    public function keysWithPrefix(string $prefix): array
86    {
87        $this->failDuringOutage();
88
89        $found = [];
90
91        foreach (array_keys($this->entries) as $key) {
92            if (str_starts_with($key, $prefix) && $this->findLiveEntry($key) !== null) {
93                $found[] = $key;
94            }
95        }
96
97        return $found;
98    }
99
100    public function removeByPrefix(string $prefix): void
101    {
102        foreach ($this->keysWithPrefix($prefix) as $key) {
103            unset($this->entries[$key]);
104        }
105    }
106
107    public function probe(): void
108    {
109        if ($this->duringProbe !== null) {
110            ($this->duringProbe)();
111        }
112
113        $this->failDuringOutage();
114    }
115
116    private function findLiveEntry(string $key): ?StoredEntry
117    {
118        $this->failDuringOutage();
119
120        $entry = $this->entries[$key] ?? null;
121
122        if ($entry === null) {
123            return null;
124        }
125
126        if ($entry->expiresAtMilliseconds !== null && $entry->expiresAtMilliseconds <= $this->readNowInMilliseconds()) {
127            unset($this->entries[$key]);
128
129            return null;
130        }
131
132        return $entry;
133    }
134
135    private function computeExpiryFor(?Duration $ttl): ?int
136    {
137        return $ttl === null ? null : $this->readNowInMilliseconds() + $ttl->toMilliseconds();
138    }
139
140    private function readNowInMilliseconds(): int
141    {
142        return intdiv($this->clock->getNow()->toEpochMicroseconds(), 1_000);
143    }
144
145    private function failDuringOutage(): void
146    {
147        if ($this->failure !== null) {
148            throw StoreException::unreachable('memory', $this->failure);
149        }
150    }
151}