• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

aplus-framework / session / 14274562555

04 Apr 2025 09:29PM UTC coverage: 93.732% (+0.04%) from 93.695%
14274562555

push

github

natanfelles
Do not remove the handler object if it is created externally

15 of 18 new or added lines in 3 files covered. (83.33%)

972 of 1037 relevant lines covered (93.73%)

39.68 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

90.15
/src/SaveHandlers/MemcachedHandler.php
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
 */
10
namespace Framework\Session\SaveHandlers;
11

12
use Framework\Log\LogLevel;
13
use Framework\Session\SaveHandler;
14
use Memcached;
15
use OutOfBoundsException;
16
use SensitiveParameter;
17

18
/**
19
 * Class MemcachedHandler.
20
 *
21
 * @package session
22
 */
23
class 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([
64✔
68
            'prefix' => '',
64✔
69
            'servers' => [
64✔
70
                [
64✔
71
                    'host' => '127.0.0.1',
64✔
72
                    'port' => 11211,
64✔
73
                    'weight' => 0,
64✔
74
                ],
64✔
75
            ],
64✔
76
            'options' => [
64✔
77
                Memcached::OPT_BINARY_PROTOCOL => true,
64✔
78
            ],
64✔
79
            'lock_attempts' => 60,
64✔
80
            'lock_sleep' => 1_000_000,
64✔
81
            'lock_ttl' => 600,
64✔
82
            'maxlifetime' => null,
64✔
83
            'match_ip' => false,
64✔
84
            'match_ua' => false,
64✔
85
        ], $config);
64✔
86
        foreach ($this->config['servers'] as $index => $server) {
64✔
87
            if (!isset($server['host'])) {
64✔
88
                throw new OutOfBoundsException(
2✔
89
                    "Memcached host not set on server config '{$index}'"
2✔
90
                );
2✔
91
            }
92
        }
93
    }
94

95
    public function setMemcached(Memcached $memcached) : static
96
    {
97
        $this->setByExternal = true;
2✔
98
        $this->memcached = $memcached;
2✔
99
        return $this;
2✔
100
    }
101

102
    public function getMemcached() : ?Memcached
103
    {
104
        return $this->memcached ?? null;
3✔
105
    }
106

107
    /**
108
     * Get expiration as a timestamp.
109
     *
110
     * Useful for Time To Live greater than a month (`60*60*24*30`).
111
     *
112
     * @param int $seconds
113
     *
114
     * @see https://www.php.net/manual/en/memcached.expiration.php
115
     *
116
     * @return int
117
     */
118
    protected function getExpiration(int $seconds) : int
119
    {
120
        return \time() + $seconds;
65✔
121
    }
122

123
    /**
124
     * Get a key for Memcached, using the optional
125
     * prefix, match IP and match User-Agent configs.
126
     *
127
     * NOTE: The max key length allowed by Memcached is 250 bytes.
128
     *
129
     * @param string $id The session id
130
     *
131
     * @return string The final key
132
     */
133
    protected function getKey(string $id) : string
134
    {
135
        return $this->config['prefix'] . $id . $this->getKeySuffix();
65✔
136
    }
137

138
    public function open($path, $name) : bool
139
    {
140
        if (isset($this->memcached)) {
65✔
141
            return true;
6✔
142
        }
143
        $this->memcached = new Memcached();
65✔
144
        $pool = [];
65✔
145
        foreach ($this->config['servers'] as $server) {
65✔
146
            $host = $server['host'] . ':' . ($server['port'] ?? 11211);
65✔
147
            if (\in_array($host, $pool, true)) {
65✔
148
                $this->log(
3✔
149
                    'Session (memcached): Server pool already has ' . $host,
3✔
150
                    LogLevel::DEBUG
3✔
151
                );
3✔
152
                continue;
3✔
153
            }
154
            $result = $this->memcached->addServer(
65✔
155
                $server['host'],
65✔
156
                $server['port'] ?? 11211,
65✔
157
                $server['weight'] ?? 0,
65✔
158
            );
65✔
159
            if ($result === false) {
65✔
160
                $this->log("Session (memcached): Could not add {$host} to server pool");
×
161
                continue;
×
162
            }
163
            $pool[] = $host;
65✔
164
        }
165
        $result = $this->memcached->setOptions($this->config['options']);
65✔
166
        if ($result === false) {
65✔
167
            $this->log('Session (memcached): ' . $this->memcached->getLastErrorMessage());
2✔
168
        }
169
        if (!$this->memcached->getStats()) {
65✔
170
            $this->log('Session (memcached): Could not connect to any server');
2✔
171
            return false;
2✔
172
        }
173
        return true;
65✔
174
    }
175

176
    public function read($id) : string
177
    {
178
        if (!isset($this->memcached) || !$this->lock($id)) {
65✔
179
            return '';
2✔
180
        }
181
        if (!isset($this->sessionId)) {
65✔
182
            $this->sessionId = $id;
65✔
183
        }
184
        $data = (string) $this->memcached->get($this->getKey($id));
65✔
185
        $this->setFingerprint($data);
65✔
186
        return $data;
65✔
187
    }
188

189
    public function write($id, $data) : bool
190
    {
191
        if (!isset($this->memcached)) {
32✔
192
            return false;
2✔
193
        }
194
        if ($id !== $this->sessionId) {
32✔
195
            if (!$this->unlock() || !$this->lock($id)) {
4✔
196
                return false;
2✔
197
            }
198
            $this->setFingerprint('');
2✔
199
            $this->sessionId = $id;
2✔
200
        }
201
        if ($this->lockId === false) {
32✔
202
            return false;
2✔
203
        }
204
        $this->memcached->replace(
30✔
205
            $this->lockId,
30✔
206
            \time(),
30✔
207
            $this->getExpiration($this->config['lock_ttl'])
30✔
208
        );
30✔
209
        $maxlifetime = $this->getExpiration($this->getMaxlifetime());
30✔
210
        if ($this->hasSameFingerprint($data)) {
30✔
211
            return $this->memcached->touch($this->getKey($id), $maxlifetime);
×
212
        }
213
        if ($this->memcached->set($this->getKey($id), $data, $maxlifetime)) {
30✔
214
            $this->setFingerprint($data);
30✔
215
            return true;
30✔
216
        }
217
        return false;
×
218
    }
219

220
    public function updateTimestamp($id, $data) : bool
221
    {
222
        return $this->memcached->touch(
6✔
223
            $this->getKey($id),
6✔
224
            $this->getExpiration($this->getMaxlifetime())
6✔
225
        );
6✔
226
    }
227

228
    public function close() : bool
229
    {
230
        if ($this->lockId) {
64✔
231
            $this->memcached->delete($this->lockId);
64✔
232
        }
233
        if ($this->setByExternal === false) {
64✔
234
            if (!$this->memcached->quit()) {
64✔
NEW
235
                return false;
×
236
            }
237
            $this->memcached = null;
64✔
238
        }
239
        return true;
64✔
240
    }
241

242
    public function destroy($id) : bool
243
    {
244
        if (!$this->lockId) {
52✔
245
            return false;
2✔
246
        }
247
        $destroyed = $this->memcached->delete($this->getKey($id));
52✔
248
        return !($destroyed === false
52✔
249
            && $this->memcached->getResultCode() !== Memcached::RES_NOTFOUND);
52✔
250
    }
251

252
    public function gc($max_lifetime) : false | int
253
    {
254
        return 0;
2✔
255
    }
256

257
    protected function lock(string $id) : bool
258
    {
259
        $expiration = $this->getExpiration($this->config['lock_ttl']);
65✔
260
        if ($this->lockId && $this->memcached->get($this->lockId)) {
65✔
261
            return $this->memcached->replace($this->lockId, \time(), $expiration);
6✔
262
        }
263
        $lockId = $this->getKey($id) . ':lock';
65✔
264
        $attempt = 0;
65✔
265
        while ($attempt < $this->config['lock_attempts']) {
65✔
266
            $attempt++;
65✔
267
            if ($this->memcached->get($lockId)) {
65✔
268
                \usleep($this->config['lock_sleep']);
×
269
                continue;
×
270
            }
271
            if (!$this->memcached->set($lockId, \time(), $expiration)) {
65✔
272
                $this->log('Session (memcached): Error while trying to lock ' . $lockId);
×
273
                return false;
×
274
            }
275
            $this->lockId = $lockId;
65✔
276
            break;
65✔
277
        }
278
        if ($attempt === $this->config['lock_attempts']) {
65✔
279
            $this->log(
×
280
                "Session (memcached): Unable to lock {$lockId} after {$attempt} attempts"
×
281
            );
×
282
            return false;
×
283
        }
284
        return true;
65✔
285
    }
286

287
    protected function unlock() : bool
288
    {
289
        if ($this->lockId === false) {
6✔
290
            return true;
2✔
291
        }
292
        if (!$this->memcached->delete($this->lockId) &&
4✔
293
            $this->memcached->getResultCode() !== Memcached::RES_NOTFOUND
4✔
294
        ) {
295
            $this->log('Session (memcached): Error while trying to unlock ' . $this->lockId);
2✔
296
            return false;
2✔
297
        }
298
        $this->lockId = false;
2✔
299
        return true;
2✔
300
    }
301
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE TRIAL · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc