Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.00% covered (warning)
88.00%
22 / 25
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
InMemoryOptionsCache
88.00% covered (warning)
88.00%
22 / 25
50.00% covered (danger)
50.00%
2 / 4
14.34
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
6.17
 set
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
6
 delete
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
1<?php
2
3/**
4 * SPDX-License-Identifier: EUPL-1.2
5 *
6 * This file is part of icap-flow.
7 *
8 * Licensed under the EUPL, Version 1.2 only (the "Licence");
9 * you may not use this work except in compliance with the Licence.
10 * You may obtain a copy of the Licence at:
11 *
12 *     https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12
13 *
14 * Unless required by applicable law or agreed to in writing, software
15 * distributed under the Licence is distributed on an "AS IS" basis,
16 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
17 */
18
19declare(strict_types=1);
20
21namespace Ndrstmr\Icap\Cache;
22
23use Closure;
24use Ndrstmr\Icap\DTO\IcapResponse;
25
26/**
27 * Process-local OPTIONS-response cache.
28 *
29 * The default implementation. Sufficient for long-running async
30 * workers (Symfony Messenger consumers, RoadRunner workers, php-fpm
31 * with pre-warmed bytecode caches that share state across requests
32 * — note: vanilla php-fpm does NOT share state, use APCu or Redis
33 * for that).
34 *
35 * Not safe across processes. Implement {@see OptionsCacheInterface}
36 * against your shared cache for cross-process deployments.
37 */
38final class InMemoryOptionsCache implements OptionsCacheInterface
39{
40    /** @var array<string, array{response: IcapResponse, expiresAt: int, istag: ?string}> */
41    private array $entries = [];
42
43    private ?string $lastKnownIstag = null;
44
45    /** @var Closure(): int */
46    private Closure $clock;
47
48    /**
49     * @param (Closure(): int)|null $clock injectable clock for deterministic tests; defaults to time()
50     */
51    public function __construct(
52        ?Closure $clock = null,
53    ) {
54        $this->clock = $clock ?? static fn (): int => time();
55    }
56
57    #[\Override]
58    public function get(string $key): ?IcapResponse
59    {
60        $entry = $this->entries[$key] ?? null;
61        if ($entry === null) {
62            return null;
63        }
64
65        if (($this->clock)() >= $entry['expiresAt']) {
66            unset($this->entries[$key]);
67            return null;
68        }
69
70        // ISTag drift: if a newer ISTag has been observed globally,
71        // entries stored under an older ISTag are stale.
72        if (
73            $this->lastKnownIstag !== null
74            && $entry['istag'] !== null
75            && $entry['istag'] !== $this->lastKnownIstag
76        ) {
77            unset($this->entries[$key]);
78            return null;
79        }
80
81        return $entry['response'];
82    }
83
84    #[\Override]
85    public function set(string $key, IcapResponse $response, int $ttlSeconds, ?string $istag = null): void
86    {
87        if ($ttlSeconds <= 0) {
88            return;
89        }
90
91        // When the ISTag changes, all previously cached entries are
92        // potentially stale (the server updated its configuration or
93        // signature database). Flush them.
94        if ($istag !== null && $this->lastKnownIstag !== null && $istag !== $this->lastKnownIstag) {
95            $this->entries = [];
96        }
97
98        if ($istag !== null) {
99            $this->lastKnownIstag = $istag;
100        }
101
102        $this->entries[$key] = [
103            'response'  => $response,
104            'expiresAt' => ($this->clock)() + $ttlSeconds,
105            'istag'     => $istag ?? $this->lastKnownIstag,
106        ];
107    }
108
109    #[\Override]
110    public function delete(string $key): void
111    {
112        unset($this->entries[$key]);
113    }
114}