Code Coverage
 
Classes and Traits
Functions and Methods
Lines
Total
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
8 / 8
CRAP
100.00% covered (success)
100.00%
78 / 78
Cache
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
8 / 8
36
100.00% covered (success)
100.00%
78 / 78
 setupCache
100.00% covered (success)
100.00%
1 / 1
15
100.00% covered (success)
100.00%
31 / 31
 set
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 get
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 delete
100.00% covered (success)
100.00%
1 / 1
1
100.00% covered (success)
100.00%
1 / 1
 getList
100.00% covered (success)
100.00%
1 / 1
11
100.00% covered (success)
100.00%
19 / 19
 setList
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
100.00%
9 / 9
 pushList
100.00% covered (success)
100.00%
1 / 1
3
100.00% covered (success)
100.00%
13 / 13
 deleteList
100.00% covered (success)
100.00%
1 / 1
2
100.00% covered (success)
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
22namespace App\Core;
23
24use App\Util\Common;
25use App\Util\Exception\ConfigurationException;
26use Functional as F;
27use Redis;
28use RedisCluster;
29use Symfony\Component\Cache\Adapter;
30use Symfony\Component\Cache\Adapter\ChainAdapter;
31
32abstract 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}