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 | } |