Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.23% covered (warning)
89.23%
116 / 130
71.43% covered (warning)
71.43%
10 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
MemcachedHandler
89.23% covered (warning)
89.23%
116 / 130
71.43% covered (warning)
71.43%
10 / 14
47.53
0.00% covered (danger)
0.00%
0 / 1
 prepareConfig
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
3
 setMemcached
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMemcached
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getExpiration
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 open
89.29% covered (warning)
89.29%
25 / 28
0.00% covered (danger)
0.00%
0 / 1
7.06
 read
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
4
 write
90.48% covered (success)
90.48%
19 / 21
0.00% covered (danger)
0.00%
0 / 1
8.06
 updateTimestamp
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 close
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 destroy
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 gc
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 lock
61.90% covered (warning)
61.90%
13 / 21
0.00% covered (danger)
0.00%
0 / 1
9.71
 unlock
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Session Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Session\SaveHandlers;
11
12use Framework\Log\LogLevel;
13use Framework\Session\SaveHandler;
14use Memcached;
15use OutOfBoundsException;
16use SensitiveParameter;
17
18/**
19 * Class MemcachedHandler.
20 *
21 * @package session
22 */
23class MemcachedHandler extends SaveHandler
24{
25    protected ?Memcached $memcached;
26
27    /**
28     * Prepare configurations to be used by the MemcachedHandler.
29     *
30     * @param array<string,mixed> $config Custom configs
31     *
32     * The custom configs are:
33     *
34     * ```php
35     * $configs = [
36     *     // A custom prefix prepended in the keys
37     *     'prefix' => '',
38     *     // A list of Memcached servers
39     *     'servers' => [
40     *         [
41     *             'host' => '127.0.0.1', // host always is required
42     *             'port' => 11211, // port is optional, default to 11211
43     *             'weight' => 0, // weight is optional, default to 0
44     *         ],
45     *     ],
46     *     // An associative array of Memcached::OPT_* constants
47     *     'options' => [
48     *         Memcached::OPT_BINARY_PROTOCOL => true,
49     *     ],
50     *     // Maximum attempts to try lock a session id
51     *     'lock_attempts' => 60,
52     *     // Interval between the lock attempts in microseconds
53     *     'lock_sleep' => 1_000_000,
54     *     // TTL to the lock (valid for the current session only)
55     *     'lock_ttl' => 600,
56     *     // The maxlifetime (TTL) used for cache item expiration
57     *     'maxlifetime' => null, // Null to use the ini value of session.gc_maxlifetime
58     *     // Match IP?
59     *     'match_ip' => false,
60     *     // Match User-Agent?
61     *     'match_ua' => false,
62     * ];
63     * ```
64     */
65    protected function prepareConfig(#[SensitiveParameter] array $config) : void
66    {
67        $this->config = \array_replace_recursive([
68            'prefix' => '',
69            'servers' => [
70                [
71                    'host' => '127.0.0.1',
72                    'port' => 11211,
73                    'weight' => 0,
74                ],
75            ],
76            'options' => [
77                Memcached::OPT_BINARY_PROTOCOL => true,
78            ],
79            'lock_attempts' => 60,
80            'lock_sleep' => 1_000_000,
81            'lock_ttl' => 600,
82            'maxlifetime' => null,
83            'match_ip' => false,
84            'match_ua' => false,
85        ], $config);
86        foreach ($this->config['servers'] as $index => $server) {
87            if ( ! isset($server['host'])) {
88                throw new OutOfBoundsException(
89                    "Memcached host not set on server config '{$index}'"
90                );
91            }
92        }
93    }
94
95    public function setMemcached(Memcached $memcached) : static
96    {
97        $this->memcached = $memcached;
98        return $this;
99    }
100
101    public function getMemcached() : ?Memcached
102    {
103        return $this->memcached ?? null;
104    }
105
106    /**
107     * Get expiration as a timestamp.
108     *
109     * Useful for Time To Live greater than a month (`60*60*24*30`).
110     *
111     * @param int $seconds
112     *
113     * @see https://www.php.net/manual/en/memcached.expiration.php
114     *
115     * @return int
116     */
117    protected function getExpiration(int $seconds) : int
118    {
119        return \time() + $seconds;
120    }
121
122    /**
123     * Get a key for Memcached, using the optional
124     * prefix, match IP and match User-Agent configs.
125     *
126     * NOTE: The max key length allowed by Memcached is 250 bytes.
127     *
128     * @param string $id The session id
129     *
130     * @return string The final key
131     */
132    protected function getKey(string $id) : string
133    {
134        return $this->config['prefix'] . $id . $this->getKeySuffix();
135    }
136
137    public function open($path, $name) : bool
138    {
139        if (isset($this->memcached)) {
140            return true;
141        }
142        $this->memcached = new Memcached();
143        $pool = [];
144        foreach ($this->config['servers'] as $server) {
145            $host = $server['host'] . ':' . ($server['port'] ?? 11211);
146            if (\in_array($host, $pool, true)) {
147                $this->log(
148                    'Session (memcached): Server pool already has ' . $host,
149                    LogLevel::DEBUG
150                );
151                continue;
152            }
153            $result = $this->memcached->addServer(
154                $server['host'],
155                $server['port'] ?? 11211,
156                $server['weight'] ?? 0,
157            );
158            if ($result === false) {
159                $this->log("Session (memcached): Could not add {$host} to server pool");
160                continue;
161            }
162            $pool[] = $host;
163        }
164        $result = $this->memcached->setOptions($this->config['options']);
165        if ($result === false) {
166            $this->log('Session (memcached): ' . $this->memcached->getLastErrorMessage());
167        }
168        if ( ! $this->memcached->getStats()) {
169            $this->log('Session (memcached): Could not connect to any server');
170            return false;
171        }
172        return true;
173    }
174
175    public function read($id) : string
176    {
177        if ( ! isset($this->memcached) || ! $this->lock($id)) {
178            return '';
179        }
180        if ( ! isset($this->sessionId)) {
181            $this->sessionId = $id;
182        }
183        $data = (string) $this->memcached->get($this->getKey($id));
184        $this->setFingerprint($data);
185        return $data;
186    }
187
188    public function write($id, $data) : bool
189    {
190        if ( ! isset($this->memcached)) {
191            return false;
192        }
193        if ($id !== $this->sessionId) {
194            if ( ! $this->unlock() || ! $this->lock($id)) {
195                return false;
196            }
197            $this->setFingerprint('');
198            $this->sessionId = $id;
199        }
200        if ($this->lockId === false) {
201            return false;
202        }
203        $this->memcached->replace(
204            $this->lockId,
205            \time(),
206            $this->getExpiration($this->config['lock_ttl'])
207        );
208        $maxlifetime = $this->getExpiration($this->getMaxlifetime());
209        if ($this->hasSameFingerprint($data)) {
210            return $this->memcached->touch($this->getKey($id), $maxlifetime);
211        }
212        if ($this->memcached->set($this->getKey($id), $data, $maxlifetime)) {
213            $this->setFingerprint($data);
214            return true;
215        }
216        return false;
217    }
218
219    public function updateTimestamp($id, $data) : bool
220    {
221        return $this->memcached->touch(
222            $this->getKey($id),
223            $this->getExpiration($this->getMaxlifetime())
224        );
225    }
226
227    public function close() : bool
228    {
229        if ($this->lockId) {
230            $this->memcached->delete($this->lockId);
231        }
232        if ( ! $this->memcached->quit()) {
233            return false;
234        }
235        $this->memcached = null;
236        return true;
237    }
238
239    public function destroy($id) : bool
240    {
241        if ( ! $this->lockId) {
242            return false;
243        }
244        $destroyed = $this->memcached->delete($this->getKey($id));
245        return ! ($destroyed === false
246            && $this->memcached->getResultCode() !== Memcached::RES_NOTFOUND);
247    }
248
249    public function gc($max_lifetime) : int | false
250    {
251        return 0;
252    }
253
254    protected function lock(string $id) : bool
255    {
256        $expiration = $this->getExpiration($this->config['lock_ttl']);
257        if ($this->lockId && $this->memcached->get($this->lockId)) {
258            return $this->memcached->replace($this->lockId, \time(), $expiration);
259        }
260        $lockId = $this->getKey($id) . ':lock';
261        $attempt = 0;
262        while ($attempt < $this->config['lock_attempts']) {
263            $attempt++;
264            if ($this->memcached->get($lockId)) {
265                \usleep($this->config['lock_sleep']);
266                continue;
267            }
268            if ( ! $this->memcached->set($lockId, \time(), $expiration)) {
269                $this->log('Session (memcached): Error while trying to lock ' . $lockId);
270                return false;
271            }
272            $this->lockId = $lockId;
273            break;
274        }
275        if ($attempt === $this->config['lock_attempts']) {
276            $this->log(
277                "Session (memcached): Unable to lock {$lockId} after {$attempt} attempts"
278            );
279            return false;
280        }
281        return true;
282    }
283
284    protected function unlock() : bool
285    {
286        if ($this->lockId === false) {
287            return true;
288        }
289        if ( ! $this->memcached->delete($this->lockId) &&
290            $this->memcached->getResultCode() !== Memcached::RES_NOTFOUND
291        ) {
292            $this->log('Session (memcached): Error while trying to unlock ' . $this->lockId);
293            return false;
294        }
295        $this->lockId = false;
296        return true;
297    }
298}