Lines 76.92% 20 / 26
Methods 53.84% 7 / 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 0.00% 0 / 1 0.00% 0 / 1 2
 remove 0.00% 0 / 1 0.00% 0 / 1 2
 exists 0.00% 0 / 1 0.00% 0 / 1 2
 increment 100.00% 1 / 1 100.00% 1 / 1 1
 keysWithPrefix 0.00% 0 / 1 0.00% 0 / 1 2
 removeByPrefix 0.00% 0 / 1 0.00% 0 / 1 2
 probe 0.00% 0 / 1 0.00% 0 / 1 2
 getHealth 100.00% 1 / 1 100.00% 1 / 1 1
 runGuarded 100.00% 11 / 11 100.00% 1 / 1 4
 pauseAfter 100.00% 4 / 4 100.00% 1 / 1 1
 isPaused 100.00% 1 / 1 100.00% 1 / 1 1
15final class GuardedStoreBackend implements StoreBackend
16{
17    private const array PAUSING_FAILURES = [StoreError::Unreachable, StoreError::TimedOut];
18
19    private ?MonotonicTime $pausedUntil = null;
20
21    private bool $lastOperationAnswered = true;
22
23    private ?string $lastFailure = null;
24
25    private ?Instant $lastFailureAt = null;
26
27    public function __construct(
28        private readonly StoreBackend $backend,
29        private readonly StoreDsn $dsn,
30        private readonly StoreTiming $timing,
31        private readonly Clock $clock,
32    ) {}
33
34    public function read(string $key): ?string
35    {
36        return $this->runGuarded(fn(): ?string => $this->backend->read($key));
37    }
38
39    public function write(string $key, string $value, ?Duration $ttl): void
40    {
41        $this->runGuarded(fn() => $this->backend->write($key, $value, $ttl));
42    }
43
44    public function remove(string $key): void
45    {
46        $this->runGuarded(fn() => $this->backend->remove($key));
47    }
48
49    public function exists(string $key): bool
50    {
51        return $this->runGuarded(fn(): bool => $this->backend->exists($key));
52    }
53
54    public function increment(string $key, int $by): int
55    {
56        return $this->runGuarded(fn(): int => $this->backend->increment($key, $by));
57    }
58
59    public function keysWithPrefix(string $prefix): array
60    {
61        return $this->runGuarded(fn(): array => $this->backend->keysWithPrefix($prefix));
62    }
63
64    public function removeByPrefix(string $prefix): void
65    {
66        $this->runGuarded(fn() => $this->backend->removeByPrefix($prefix));
67    }
68
69    public function probe(): void
70    {
71        $this->runGuarded($this->backend->probe(...));
72    }
73
74    public function getHealth(): StoreHealth
75    {
76        return new StoreHealth($this->lastOperationAnswered, $this->lastFailure, $this->lastFailureAt);
77    }
78
79    /**
80     * @template T
81     * @param Closure(): T $operation
82     * @return T
83     * @throws StoreException
84     */
85    private function runGuarded(Closure $operation): mixed
86    {
87        if ($this->isPaused()) {
88            throw StoreException::createForReason(StoreError::RecentlyFailed, target: (string) $this->dsn, detail: $this->lastFailure ?? '');
89        }
90
91        try {
92            $result = $operation();
93        } catch (StoreException $e) {
94            if (\in_array($e->reason, self::PAUSING_FAILURES, true)) {
95                $this->pauseAfter($e);
96            } else {
97                $this->lastOperationAnswered = true;
98            }
99
100            throw $e;
101        }
102
103        $this->pausedUntil = null;
104        $this->lastOperationAnswered = true;
105
106        return $result;
107    }
108
109    private function pauseAfter(StoreException $failure): void
110    {
111        $this->pausedUntil = $this->clock->getMonotonicTime()->plus($this->timing->recoveryInterval);
112        $this->lastOperationAnswered = false;
113        $this->lastFailure = $failure->getMessage();
114        $this->lastFailureAt = $this->clock->getNow();
115    }
116
117    private function isPaused(): bool
118    {
119        return $this->pausedUntil?->isAfter($this->clock->getMonotonicTime()) === true;
120    }
121}