Code Coverage |
||||||||||
Classes and Traits |
Functions and Methods |
Lines |
||||||||
Total | |
100.00% |
1 / 1 |
|
100.00% |
8 / 8 |
CRAP | |
100.00% |
78 / 78 |
Cache | |
100.00% |
1 / 1 |
|
100.00% |
8 / 8 |
36 | |
100.00% |
78 / 78 |
setupCache | |
100.00% |
1 / 1 |
15 | |
100.00% |
31 / 31 |
|||
set | |
100.00% |
1 / 1 |
1 | |
100.00% |
1 / 1 |
|||
get | |
100.00% |
1 / 1 |
1 | |
100.00% |
1 / 1 |
|||
delete | |
100.00% |
1 / 1 |
1 | |
100.00% |
1 / 1 |
|||
getList | |
100.00% |
1 / 1 |
11 | |
100.00% |
19 / 19 |
|||
setList | |
100.00% |
1 / 1 |
2 | |
100.00% |
9 / 9 |
|||
pushList | |
100.00% |
1 / 1 |
3 | |
100.00% |
13 / 13 |
|||
deleteList | |
100.00% |
1 / 1 |
2 | |
100.00% |
3 / 3 |
1 | <?php |
2 | |
3 | // {{{ License |
4 | |
5 | // This file is part of GNU social - https://www.gnu.org/software/social |
6 | // |
7 | // GNU social is free software: you can redistribute it and/or modify |
8 | // it under the terms of the GNU Affero General Public License as published by |
9 | // the Free Software Foundation, either version 3 of the License, or |
10 | // (at your option) any later version. |
11 | // |
12 | // GNU social is distributed in the hope that it will be useful, |
13 | // but WITHOUT ANY WARRANTY; without even the implied warranty of |
14 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
15 | // GNU Affero General Public License for more details. |
16 | // |
17 | // You should have received a copy of the GNU Affero General Public License |
18 | // along with GNU social. If not, see <http://www.gnu.org/licenses/>. |
19 | |
20 | // }}} |
21 | |
22 | namespace App\Core; |
23 | |
24 | use App\Util\Common; |
25 | use App\Util\Exception\ConfigurationException; |
26 | use Functional as F; |
27 | use Redis; |
28 | use RedisCluster; |
29 | use Symfony\Component\Cache\Adapter; |
30 | use Symfony\Component\Cache\Adapter\ChainAdapter; |
31 | |
32 | abstract class Cache |
33 | { |
34 | protected static $pools; |
35 | protected static $redis; |
36 | |
37 | /** |
38 | * Configure a cache pool, with adapters taken from `ENV_VAR`. |
39 | * We may want multiple of these in the future, but for now it seems |
40 | * unnecessary |
41 | */ |
42 | public static function setupCache() |
43 | { |
44 | self::$pools = []; |
45 | self::$redis = null; |
46 | |
47 | $adapters = []; |
48 | foreach (Common::config('cache', 'adapters') as $pool => $val) { |
49 | self::$pools[$pool] = []; |
50 | self::$redis[$pool] = []; |
51 | foreach (explode(',', $val) as $dsn) { |
52 | if (str_contains($dsn, '://')) { |
53 | [$scheme, $rest] = explode('://', $dsn); |
54 | } else { |
55 | $scheme = $dsn; |
56 | $rest = ''; |
57 | } |
58 | switch ($scheme) { |
59 | case 'redis': |
60 | // Redis can have multiple servers, but we want to take proper advantage of |
61 | // redis, not just as a key value store, but using it's datastructures |
62 | $dsns = explode(';', $dsn); |
63 | if (count($dsns) === 1) { |
64 | $class = Redis::class; |
65 | $r = new Redis(); |
66 | $r->pconnect($rest); |
67 | } else { |
68 | // @codeCoverageIgnoreStart |
69 | // This requires extra server configuration, but the code was tested |
70 | // manually and works, so it'll be excluded from automatic tests, for now, at least |
71 | if (F\Every($dsns, function ($str) { [$scheme, $rest] = explode('://', $str); return str_contains($rest, ':'); }) == false) { |
72 | throw new ConfigurationException('The configuration of a redis cluster requires specifying the ports to use'); |
73 | } |
74 | $class = RedisCluster::class; // true for persistent connection |
75 | $seeds = F\Map($dsns, fn ($str) => explode('://', $str)[1]); |
76 | $r = new RedisCluster(name: null, seeds: $seeds, timeout: null, read_timeout: null, persistent: true); |
77 | // Distribute reads randomly |
78 | $r->setOption($class::OPT_SLAVE_FAILOVER, $class::FAILOVER_DISTRIBUTE); |
79 | // @codeCoverageIgnoreEnd |
80 | } |
81 | // Improved serializer |
82 | $r->setOption($class::OPT_SERIALIZER, $class::SERIALIZER_MSGPACK); |
83 | // Persistent connection |
84 | $r->setOption($class::OPT_TCP_KEEPALIVE, true); |
85 | // Use LZ4 for the improved decompression speed (while keeping an okay compression ratio) |
86 | $r->setOption($class::OPT_COMPRESSION, $class::COMPRESSION_LZ4); |
87 | self::$redis[$pool] = $r; |
88 | $adapters[$pool][] = new Adapter\RedisAdapter($r); |
89 | break; |
90 | case 'memcached': |
91 | // @codeCoverageIgnoreStart |
92 | // These all are excluded from automatic testing, as they require an unreasonable amount |
93 | // of configuration in the testing environment. The code is really simple, so it should work |
94 | // memcached can also have multiple servers |
95 | $dsns = explode(';', $dsn); |
96 | $adapters[$pool][] = new Adapter\MemcachedAdapter($dsns); |
97 | break; |
98 | case 'filesystem': |
99 | $adapters[$pool][] = new Adapter\FilesystemAdapter($rest); |
100 | break; |
101 | case 'apcu': |
102 | $adapters[$pool][] = new Adapter\ApcuAdapter(); |
103 | break; |
104 | case 'opcache': |
105 | $adapters[$pool][] = new Adapter\PhpArrayAdapter($rest, new FilesystemAdapter($rest . '.fallback')); |
106 | break; |
107 | case 'doctrine': |
108 | $adapters[$pool][] = new Adapter\PdoAdapter($dsn); |
109 | break; |
110 | default: |
111 | Log::error("Unknown or discouraged cache scheme '{$scheme}'"); |
112 | return; |
113 | // @codeCoverageIgnoreEnd |
114 | } |
115 | } |
116 | |
117 | if (self::$redis[$pool] == null) { |
118 | unset(self::$redis[$pool]); |
119 | } |
120 | |
121 | if (count($adapters[$pool]) === 1) { |
122 | self::$pools[$pool] = array_pop($adapters[$pool]); |
123 | } else { |
124 | self::$pools[$pool] = new ChainAdapter($adapters[$pool]); |
125 | } |
126 | } |
127 | } |
128 | |
129 | public static function set(string $key, mixed $value, string $pool = 'default') |
130 | { |
131 | // there's no set method, must be done this way |
132 | return self::$pools[$pool]->get($key, function ($i) use ($value) { return $value; }, INF); |
133 | } |
134 | |
135 | public static function get(string $key, callable $calculate, string $pool = 'default', float $beta = 1.0) |
136 | { |
137 | return self::$pools[$pool]->get($key, $calculate, $beta); |
138 | } |
139 | |
140 | public static function delete(string $key, string $pool = 'default'): bool |
141 | { |
142 | return self::$pools[$pool]->delete($key); |
143 | } |
144 | |
145 | /** |
146 | * Retrieve a list from the cache, with a different implementation |
147 | * for redis and others, trimming to $max_count if given |
148 | */ |
149 | public static function getList(string $key, callable $calculate, string $pool = 'default', ?int $max_count = null, float $beta = 1.0): array |
150 | { |
151 | if (isset(self::$redis[$pool])) { |
152 | if (!($recompute = $beta === INF || !(self::$redis[$pool]->exists($key)))) { |
153 | if (is_float($er = Common::config('cache', 'early_recompute'))) { |
154 | $recompute = (mt_rand() / mt_getrandmax() > $er); |
155 | Log::info('Item "{key}" elected for early recomputation', ['key' => $key]); |
156 | } else { |
157 | if ($recompute = ($idletime = self::$redis[$pool]->object('idletime', $key) ?? false) && ($expiry = self::$redis[$pool]->ttl($key) ?? false) && $expiry <= $idletime / 1000 * $beta * log(random_int(1, PHP_INT_MAX) / PHP_INT_MAX)) { |
158 | // @codeCoverageIgnoreStart |
159 | Log::info('Item "{key}" elected for early recomputation {delta}s before its expiration', [ |
160 | 'key' => $key, |
161 | 'delta' => sprintf('%.1f', $expiry - microtime(true)), |
162 | ]); |
163 | // @codeCoverageIgnoreEnd |
164 | } |
165 | } |
166 | } |
167 | if ($recompute) { |
168 | $save = true; // Pass by reference |
169 | $res = $calculate(null, $save); |
170 | if ($save) { |
171 | self::setList($key, $res, $pool, $max_count, $beta); |
172 | return $res; |
173 | } |
174 | } |
175 | return self::$redis[$pool]->lRange($key, 0, $max_count ?? -1); |
176 | } else { |
177 | return self::get($key, function () use ($calculate, $max_count) { |
178 | $res = $calculate(null); |
179 | if ($max_count != -1) { |
180 | $res = array_slice($res, 0, $max_count); |
181 | } |
182 | return $res; |
183 | }, $pool, $beta); |
184 | } |
185 | } |
186 | |
187 | /** |
188 | * Set the list |
189 | */ |
190 | public static function setList(string $key, array $value, string $pool = 'default', ?int $max_count = null, float $beta = 1.0): void |
191 | { |
192 | if (isset(self::$redis[$pool])) { |
193 | self::$redis[$pool] |
194 | // Ensure atomic |
195 | ->multi(Redis::MULTI) |
196 | ->del($key) |
197 | ->rPush($key, ...$value) |
198 | // trim to $max_count, unless it's 0 |
199 | ->lTrim($key, -$max_count ?? 0, -1) |
200 | ->exec(); |
201 | } else { |
202 | self::set($key, $value, $pool, $beta); |
203 | } |
204 | } |
205 | |
206 | /** |
207 | * Push a value to the list |
208 | */ |
209 | public static function pushList(string $key, mixed $value, string $pool = 'default', ?int $max_count = null, float $beta = 1.0): void |
210 | { |
211 | if (isset(self::$redis[$pool])) { |
212 | self::$redis[$pool] |
213 | // doesn't need to be atomic, adding at one end, deleting at the other |
214 | ->multi(Redis::PIPELINE) |
215 | ->rPush($key, $value) |
216 | // trim to $max_count, if given |
217 | ->lTrim($key, -$max_count ?? 0, -1) |
218 | ->exec(); |
219 | } else { |
220 | $res = self::get($key, function () { return []; }, $pool, $beta); |
221 | $res[] = $value; |
222 | if ($max_count != null) { |
223 | $count = count($res); |
224 | $res = array_slice($res, $count - $max_count, $count); // Trim the older values |
225 | } |
226 | self::set($key, $res, $pool, $beta); |
227 | } |
228 | } |
229 | |
230 | /** |
231 | * Delete a whole list at $key |
232 | */ |
233 | public static function deleteList(string $key, string $pool = 'default'): bool |
234 | { |
235 | if (isset(self::$redis[$pool])) { |
236 | return self::$redis[$pool]->del($key) == 1; |
237 | } else { |
238 | return self::delete($key, $pool); |
239 | } |
240 | } |
241 | } |