Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
95.45% covered (success)
95.45%
21 / 22
75.00% covered (warning)
75.00%
3 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
InMemoryOptionsCache
95.45% covered (success)
95.45%
21 / 22
75.00% covered (warning)
75.00%
3 / 4
12
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 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 * It holds at most `$maxEntries` entries: what has ended is dropped whenever an entry is stored, and when the cache is
36 * full the entry that was stored first makes room. An entry stands for one service on one endpoint with one TLS
37 * profile and holds at most 64 KiB of payload (field names, values and body) in at most 100 field values. The default
38 * of 64 entries is 4 MiB of payload at most, plus the arrays and strings around at most 6,400 values, and far more than
39 * a process talks to.
40 *
41 * Not safe across processes. Implement {@see OptionsCacheInterface}
42 * against your shared cache for cross-process deployments.
43 */
44final class InMemoryOptionsCache implements OptionsCacheInterface
45{
46    public const int DEFAULT_MAX_ENTRIES = 64;
47
48    /** @var array<string, OptionsEntry> in the order the entries were stored */
49    private array $entries = [];
50
51    /** @var Closure(): int */
52    private Closure $clock;
53
54    /**
55     * @param (Closure(): int)|null $clock injectable clock for deterministic tests; defaults to time()
56     *
57     * @throws \InvalidArgumentException When the capacity is below 1
58     */
59    public function __construct(
60        ?Closure $clock = null,
61        private readonly int $maxEntries = self::DEFAULT_MAX_ENTRIES,
62    ) {
63        if ($maxEntries < 1) {
64            throw new \InvalidArgumentException('The capacity of the cache must be at least 1.');
65        }
66        $this->clock = $clock ?? static fn (): int => time();
67    }
68
69    #[\Override]
70    public function get(string $key): ?IcapResponse
71    {
72        $entry = $this->entries[$key] ?? null;
73        if ($entry === null) {
74            return null;
75        }
76
77        if (!$entry->isLiveAt(($this->clock)())) {
78            unset($this->entries[$key]);
79            return null;
80        }
81
82        return $entry->response;
83    }
84
85    #[\Override]
86    public function set(string $key, IcapResponse $response, int $ttlSeconds, ?string $istag = null): void
87    {
88        $now = ($this->clock)();
89        $entry = OptionsEntry::from($key, $response, $ttlSeconds, $now);
90        if ($entry === null) {
91            return;
92        }
93
94        foreach ($this->entries as $storedKey => $stored) {
95            if (!$stored->isLiveAt($now)) {
96                unset($this->entries[$storedKey]);
97            }
98        }
99        // The key moves to the end of the line, and a full cache gives up its oldest entry.
100        unset($this->entries[$key]);
101        while (count($this->entries) >= $this->maxEntries && ($oldest = array_key_first($this->entries)) !== null) {
102            unset($this->entries[$oldest]);
103        }
104        $this->entries[$key] = $entry;
105    }
106
107    #[\Override]
108    public function delete(string $key): void
109    {
110        unset($this->entries[$key]);
111    }
112}